From 05f3173018bc9b18419339126a5142bbf1acb776 Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:43:10 +0800 Subject: [PATCH 1/3] feat(studio): add persistent sandbox code projects --- frontend/README.md | 58 ++ frontend/sandbox-image/Dockerfile | 27 + frontend/sandbox-image/Dockerfile.update | 7 + frontend/sandbox-image/README.md | 167 ++++ frontend/sandbox-image/assets.lock.json | 67 ++ frontend/sandbox-image/prepare-context.py | 120 +++ frontend/sandbox-image/pyrightconfig.json | 6 + frontend/sandbox-image/requirements.in | 2 + frontend/sandbox-image/requirements.lock | 665 ++++++++++++++++ .../sandbox-image/runtime/code-server-wrapper | 12 + .../sandbox-image/runtime/configure-apt.py | 45 ++ .../runtime/configure-runtime.py | 45 ++ .../sandbox-image/runtime/editor_assets.py | 84 ++ .../sandbox-image/runtime/editor_routing.py | 172 +++++ .../sandbox-image/runtime/install-image.py | 258 +++++++ .../runtime/local-arm64-entrypoint | 10 + .../sandbox-image/runtime/package-welcome.py | 41 + .../sandbox-image/runtime/refresh-editor.py | 66 ++ .../runtime/register-language.py | 45 ++ .../runtime/studio-project-create | 191 +++++ .../runtime/studio-vscode-upgrade | 258 +++++++ .../runtime/studio-welcome/extension.js | 37 + .../runtime/studio-welcome/package.json | 39 + .../runtime/studio-welcome/terminals.js | 27 + frontend/sandbox-image/settings.json | 47 ++ frontend/sandbox-image/tests/test_routing.py | 82 ++ frontend/sandbox-image/tests/test_runtime.py | 183 +++++ .../sandbox-image/tests/test_terminals.cjs | 54 ++ frontend/sandbox-integration/Dockerfile | 7 + .../server/intelligent_development_routes.py | 9 + frontend/server/studio_update_resources.py | 13 + .../templates/python-agent/.gitignore.tmpl | 9 + .../templates/python-agent/AGENTS.md.tmpl | 20 + .../templates/python-agent/README.md.tmpl | 6 + .../templates/python-agent/main.py.tmpl | 6 + frontend/server/workspace_editor.py | 167 ++++ frontend/server/workspace_preview.py | 193 +++++ frontend/server/workspace_projects.py | 344 +++++++++ frontend/server/workspace_templates.py | 34 + frontend/server/workspace_tool.py | 326 ++++++++ frontend/src/App.tsx | 50 +- frontend/src/adk/client.ts | 1 + frontend/src/adk/workspacePreview.ts | 79 ++ frontend/src/create/WorkspaceCreate.css | 60 ++ frontend/src/create/WorkspaceCreate.tsx | 241 ++++++ frontend/src/i18n/resources/en-US/adk.json | 22 + frontend/src/i18n/resources/en-US/app.json | 7 +- frontend/src/i18n/resources/en-US/create.json | 454 +++++++++-- frontend/src/i18n/resources/en-US/ui.json | 106 ++- frontend/src/i18n/resources/zh-CN/adk.json | 22 + frontend/src/i18n/resources/zh-CN/app.json | 7 +- frontend/src/i18n/resources/zh-CN/create.json | 454 +++++++++-- frontend/src/i18n/resources/zh-CN/ui.json | 106 ++- frontend/src/ui/EnvironmentCenter.tsx | 33 +- frontend/src/ui/WorkspaceCenter.tsx | 35 +- frontend/src/ui/WorkspaceCollectionLayout.tsx | 27 + frontend/tests/workspaceCenter.test.mjs | 5 +- frontend/tests/workspaceCreate.test.mjs | 118 +++ pyproject.toml | 1 + tests/cli/test_studio_deploy_target.py | 8 + tests/cli/test_studio_update.py | 14 + .../server/test_studio_update_resources.py | 15 +- .../frontend/server/test_workspace_editor.py | 124 +++ .../frontend/server/test_workspace_preview.py | 115 +++ .../server/test_workspace_projects.py | 316 ++++++++ .../server/test_workspace_templates.py | 99 +++ tests/frontend/server/test_workspace_tool.py | 196 +++++ veadk/cli/cli_frontend.py | 64 +- .../{index-y89rQfXU.js => index-2iRovZLg.js} | 550 +++++++------- ...B1zZ3C2s.js => CodeDiffEditor-2dNKDLa6.js} | 2 +- ...Ci.js => MarkdownPromptEditor-MKtAr9aq.js} | 2 +- .../{arc-BmSNZeim.js => arc-Djot2dyh.js} | 2 +- ...hannel-CK3FNdKP.js => channel-Bog9ZgQJ.js} | 2 +- ...ex.es-BwpKNMlY.js => index.es-CNvBGKha.js} | 2 +- ...n-DYdURk6H.js => jspdf.es.min-BKARXXpv.js} | 6 +- ...{linear-FRN7TzCz.js => linear-bWlWt5bc.js} | 2 +- ...{index-BilOAbdo.css => index-KdIv2V8y.css} | 2 +- ...8p.js => abnfDiagram-N423BO3Z-Cj-TSKcb.js} | 2 +- ... architectureDiagram-T3A2C74G-j3rDffTL.js} | 2 +- ...l.js => blockDiagram-VBNYF7ZC-DjgSn2_Q.js} | 2 +- ...RQ1P.js => c4Diagram-5PPSVZJV-DckBzVSl.js} | 2 +- ...w7u93fzV.js => chunk-2GRJ4B5K-CJghjmjZ.js} | 2 +- ...Df19aPp9.js => chunk-2Q5K7J3B-EUDVmDcl.js} | 2 +- ...CziPj31s.js => chunk-5RXB4S5H-vRom-wY3.js} | 2 +- ...0VUj4cdX.js => chunk-5VM5RSS4-DMbAMmv-.js} | 2 +- ...BuYPTb-v.js => chunk-6Q2QTUOP-DhxfukHZ.js} | 2 +- ...CdzaapU0.js => chunk-GF5L2VYU-B2InIFod.js} | 2 +- ...s5uAWi9x.js => chunk-JWPE2WC7-C3523i1R.js} | 2 +- ...BwJsQpfI.js => chunk-KBJHAD2P-XePsqaLl.js} | 2 +- ...CvZ-mg4G.js => chunk-RYQCIY6F-BO7RGtUO.js} | 2 +- ...DF1LpnDY.js => chunk-XXDRQBXY-Itp3I3H5.js} | 2 +- .../mermaid/classDiagram-JCYQIIEL-B86LdNwl.js | 1 - .../mermaid/classDiagram-JCYQIIEL-C4wSscBN.js | 1 + .../classDiagram-v2-OCEON4UE-B86LdNwl.js | 1 - .../classDiagram-v2-OCEON4UE-C4wSscBN.js | 1 + ...a.js => cose-bilkent-JH36ORCC-DRri5EYt.js} | 2 +- ...YIu5D_.js => cynefin-OW5HDTMX-Btm62eUN.js} | 2 +- ...js => cynefinDiagram-MW4NZA55-B4_rlgbG.js} | 2 +- ...Bh80CoKM.js => dagre-VZM6K2ZE-Dd8e5zHq.js} | 2 +- ...jc3YK7.js => diagram-7IWD3JNH-Be3c8ZOK.js} | 2 +- ...k5VMXY.js => diagram-B4RE2ZJO-CHdmYeAV.js} | 2 +- ...4oG5BD.js => diagram-LBJQPF4R-DhyhZBHZ.js} | 2 +- ...t3iGyV.js => diagram-Q27KOJAE-ClVdeuyS.js} | 2 +- ...u56O7S.js => diagram-UB23O5K3-BWjTIYZK.js} | 2 +- ...Du.js => ebnfDiagram-BXEA7PRR-JX6euzDN.js} | 2 +- ...xrdp.js => erDiagram-JOGREHBK-CXXk1HsZ.js} | 2 +- ...uo.js => flowDiagram-UKHOOZJN-DfpUMb12.js} | 2 +- ...6.js => ganttDiagram-PKOTCBZU-Cf1nllVV.js} | 2 +- ...s => gitGraphDiagram-DS77QQ5N-YajivH_Q.js} | 2 +- ...pi.js => infoDiagram-6WML65LV-BaFIb7i2.js} | 2 +- ...s => ishikawaDiagram-WSZJBQD7-GRq0tW4g.js} | 2 +- ...js => journeyDiagram-NVQOT4AX-xLlhDefH.js} | 2 +- ...=> kanban-definition-27J2QSJJ-dtoPapsO.js} | 2 +- ...e-Byx96n1y.js => mermaid.core-BOk8DszP.js} | 8 +- ...> mindmap-definition-FAOFIHXS-DvfUlI0J.js} | 2 +- ...G_k.js => pegDiagram-VL7TDLO6-B535Bqgh.js} | 2 +- ...edZ.js => pieDiagram-7S7Q4E2Y-D2dPB_x2.js} | 2 +- ...s => quadrantDiagram-CIZ2JOQS-v-xvWefV.js} | 2 +- ...s => railroadDiagram-AXF67PYL-DxvlU2r4.js} | 2 +- ...> requirementDiagram-LRYGKXZP-B8kHxpf1.js} | 2 +- ....js => sankeyDiagram-W5VNT64P-DsHC0K8t.js} | 2 +- ...s => sequenceDiagram-SI44F4Z6-9JOodLES.js} | 2 +- ...yn.js => sizeCapture-X5ZJPWSS-ZbdMN49_.js} | 2 +- ...s.js => stateDiagram-OKZ733FA-EXFVO3yB.js} | 2 +- .../stateDiagram-v2-UEYNNEHI-BUH5KdnU.js | 1 - .../stateDiagram-v2-UEYNNEHI-CNDrqvMv.js | 1 + ...x4nf.js => swimlanes-SLNWSIFB-Dyq27zQT.js} | 4 +- .../swimlanesDiagram-ULZ7WXOC-De2UsW7s.js | 8 + .../swimlanesDiagram-ULZ7WXOC-DpcZasdm.js | 8 - ... timeline-definition-Z64GVDOM-rJ5Mg92N.js} | 2 +- ...xC.js => vennDiagram-T6HMQDX7-D5wxW4ux.js} | 2 +- ...js => wardleyDiagram-T6FBY63Y-jD8OIwqZ.js} | 2 +- ...js => xychartDiagram-ELKLHX3M-F8zFAtjc.js} | 2 +- veadk/webui/index.html | 4 +- veadk/webui/website-integration.js | 718 +++++++++--------- 135 files changed, 7158 insertions(+), 988 deletions(-) create mode 100644 frontend/sandbox-image/Dockerfile create mode 100644 frontend/sandbox-image/Dockerfile.update create mode 100644 frontend/sandbox-image/README.md create mode 100644 frontend/sandbox-image/assets.lock.json create mode 100644 frontend/sandbox-image/prepare-context.py create mode 100644 frontend/sandbox-image/pyrightconfig.json create mode 100644 frontend/sandbox-image/requirements.in create mode 100644 frontend/sandbox-image/requirements.lock create mode 100755 frontend/sandbox-image/runtime/code-server-wrapper create mode 100644 frontend/sandbox-image/runtime/configure-apt.py create mode 100644 frontend/sandbox-image/runtime/configure-runtime.py create mode 100644 frontend/sandbox-image/runtime/editor_assets.py create mode 100644 frontend/sandbox-image/runtime/editor_routing.py create mode 100644 frontend/sandbox-image/runtime/install-image.py create mode 100755 frontend/sandbox-image/runtime/local-arm64-entrypoint create mode 100644 frontend/sandbox-image/runtime/package-welcome.py create mode 100644 frontend/sandbox-image/runtime/refresh-editor.py create mode 100644 frontend/sandbox-image/runtime/register-language.py create mode 100755 frontend/sandbox-image/runtime/studio-project-create create mode 100755 frontend/sandbox-image/runtime/studio-vscode-upgrade create mode 100644 frontend/sandbox-image/runtime/studio-welcome/extension.js create mode 100644 frontend/sandbox-image/runtime/studio-welcome/package.json create mode 100644 frontend/sandbox-image/runtime/studio-welcome/terminals.js create mode 100644 frontend/sandbox-image/settings.json create mode 100644 frontend/sandbox-image/tests/test_routing.py create mode 100644 frontend/sandbox-image/tests/test_runtime.py create mode 100644 frontend/sandbox-image/tests/test_terminals.cjs create mode 100644 frontend/sandbox-integration/Dockerfile create mode 100644 frontend/server/templates/python-agent/.gitignore.tmpl create mode 100644 frontend/server/templates/python-agent/AGENTS.md.tmpl create mode 100644 frontend/server/templates/python-agent/README.md.tmpl create mode 100644 frontend/server/templates/python-agent/main.py.tmpl create mode 100644 frontend/server/workspace_editor.py create mode 100644 frontend/server/workspace_preview.py create mode 100644 frontend/server/workspace_projects.py create mode 100644 frontend/server/workspace_templates.py create mode 100644 frontend/server/workspace_tool.py create mode 100644 frontend/src/adk/workspacePreview.ts create mode 100644 frontend/src/create/WorkspaceCreate.css create mode 100644 frontend/src/create/WorkspaceCreate.tsx create mode 100644 frontend/src/ui/WorkspaceCollectionLayout.tsx create mode 100644 frontend/tests/workspaceCreate.test.mjs create mode 100644 tests/frontend/server/test_workspace_editor.py create mode 100644 tests/frontend/server/test_workspace_preview.py create mode 100644 tests/frontend/server/test_workspace_projects.py create mode 100644 tests/frontend/server/test_workspace_templates.py create mode 100644 tests/frontend/server/test_workspace_tool.py rename veadk/webui/assets/app/{index-y89rQfXU.js => index-2iRovZLg.js} (58%) rename veadk/webui/assets/chunks/{CodeDiffEditor-B1zZ3C2s.js => CodeDiffEditor-2dNKDLa6.js} (99%) rename veadk/webui/assets/chunks/{MarkdownPromptEditor-B5lw1jCi.js => MarkdownPromptEditor-MKtAr9aq.js} (99%) rename veadk/webui/assets/chunks/{arc-BmSNZeim.js => arc-Djot2dyh.js} (98%) rename veadk/webui/assets/chunks/{channel-CK3FNdKP.js => channel-Bog9ZgQJ.js} (53%) rename veadk/webui/assets/chunks/{index.es-BwpKNMlY.js => index.es-CNvBGKha.js} (99%) rename veadk/webui/assets/chunks/{jspdf.es.min-DYdURk6H.js => jspdf.es.min-BKARXXpv.js} (99%) rename veadk/webui/assets/chunks/{linear-FRN7TzCz.js => linear-bWlWt5bc.js} (98%) rename veadk/webui/assets/styles/{index-BilOAbdo.css => index-KdIv2V8y.css} (83%) rename veadk/webui/assets/visualizations/mermaid/{abnfDiagram-N423BO3Z-DmJtql8p.js => abnfDiagram-N423BO3Z-Cj-TSKcb.js} (86%) rename veadk/webui/assets/visualizations/mermaid/{architectureDiagram-T3A2C74G-BC0iLzBL.js => architectureDiagram-T3A2C74G-j3rDffTL.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{blockDiagram-VBNYF7ZC-wh52Ecel.js => blockDiagram-VBNYF7ZC-DjgSn2_Q.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{c4Diagram-5PPSVZJV-B9klRQ1P.js => c4Diagram-5PPSVZJV-DckBzVSl.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2GRJ4B5K-w7u93fzV.js => chunk-2GRJ4B5K-CJghjmjZ.js} (93%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2Q5K7J3B-Df19aPp9.js => chunk-2Q5K7J3B-EUDVmDcl.js} (66%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5RXB4S5H-CziPj31s.js => chunk-5RXB4S5H-vRom-wY3.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5VM5RSS4-0VUj4cdX.js => chunk-5VM5RSS4-DMbAMmv-.js} (83%) rename veadk/webui/assets/visualizations/mermaid/{chunk-6Q2QTUOP-BuYPTb-v.js => chunk-6Q2QTUOP-DhxfukHZ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-GF5L2VYU-CdzaapU0.js => chunk-GF5L2VYU-B2InIFod.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-JWPE2WC7-s5uAWi9x.js => chunk-JWPE2WC7-C3523i1R.js} (78%) rename veadk/webui/assets/visualizations/mermaid/{chunk-KBJHAD2P-BwJsQpfI.js => chunk-KBJHAD2P-XePsqaLl.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{chunk-RYQCIY6F-CvZ-mg4G.js => chunk-RYQCIY6F-BO7RGtUO.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-XXDRQBXY-DF1LpnDY.js => chunk-XXDRQBXY-Itp3I3H5.js} (53%) delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-B86LdNwl.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-C4wSscBN.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-B86LdNwl.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-C4wSscBN.js rename veadk/webui/assets/visualizations/mermaid/{cose-bilkent-JH36ORCC-BwttPwJa.js => cose-bilkent-JH36ORCC-DRri5EYt.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefin-OW5HDTMX-CfYIu5D_.js => cynefin-OW5HDTMX-Btm62eUN.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefinDiagram-MW4NZA55-h-hlAvXc.js => cynefinDiagram-MW4NZA55-B4_rlgbG.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{dagre-VZM6K2ZE-Bh80CoKM.js => dagre-VZM6K2ZE-Dd8e5zHq.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-7IWD3JNH-CHjc3YK7.js => diagram-7IWD3JNH-Be3c8ZOK.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{diagram-B4RE2ZJO-Crk5VMXY.js => diagram-B4RE2ZJO-CHdmYeAV.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-LBJQPF4R-B24oG5BD.js => diagram-LBJQPF4R-DhyhZBHZ.js} (94%) rename veadk/webui/assets/visualizations/mermaid/{diagram-Q27KOJAE-CHt3iGyV.js => diagram-Q27KOJAE-ClVdeuyS.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{diagram-UB23O5K3-C0u56O7S.js => diagram-UB23O5K3-BWjTIYZK.js} (95%) rename veadk/webui/assets/visualizations/mermaid/{ebnfDiagram-BXEA7PRR-288CQcDu.js => ebnfDiagram-BXEA7PRR-JX6euzDN.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{erDiagram-JOGREHBK-Ccaixrdp.js => erDiagram-JOGREHBK-CXXk1HsZ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{flowDiagram-UKHOOZJN-DmwC0ruo.js => flowDiagram-UKHOOZJN-DfpUMb12.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{ganttDiagram-PKOTCBZU-CDLstIj6.js => ganttDiagram-PKOTCBZU-Cf1nllVV.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{gitGraphDiagram-DS77QQ5N-B7nKkelP.js => gitGraphDiagram-DS77QQ5N-YajivH_Q.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{infoDiagram-6WML65LV-B7-q34pi.js => infoDiagram-6WML65LV-BaFIb7i2.js} (69%) rename veadk/webui/assets/visualizations/mermaid/{ishikawaDiagram-WSZJBQD7-DWxeOhyJ.js => ishikawaDiagram-WSZJBQD7-GRq0tW4g.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{journeyDiagram-NVQOT4AX-CEEbz2Rm.js => journeyDiagram-NVQOT4AX-xLlhDefH.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{kanban-definition-27J2QSJJ-4S3DxCBE.js => kanban-definition-27J2QSJJ-dtoPapsO.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{mermaid.core-Byx96n1y.js => mermaid.core-BOk8DszP.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{mindmap-definition-FAOFIHXS-N5zHU5MK.js => mindmap-definition-FAOFIHXS-DvfUlI0J.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{pegDiagram-VL7TDLO6-BOK0qG_k.js => pegDiagram-VL7TDLO6-B535Bqgh.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{pieDiagram-7S7Q4E2Y-9it0uedZ.js => pieDiagram-7S7Q4E2Y-D2dPB_x2.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{quadrantDiagram-CIZ2JOQS-CTiy7YiT.js => quadrantDiagram-CIZ2JOQS-v-xvWefV.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{railroadDiagram-AXF67PYL-PGVg3Gs4.js => railroadDiagram-AXF67PYL-DxvlU2r4.js} (84%) rename veadk/webui/assets/visualizations/mermaid/{requirementDiagram-LRYGKXZP-BEni_WUb.js => requirementDiagram-LRYGKXZP-B8kHxpf1.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sankeyDiagram-W5VNT64P-CmNhKWbi.js => sankeyDiagram-W5VNT64P-DsHC0K8t.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sequenceDiagram-SI44F4Z6-ClGkLfJ7.js => sequenceDiagram-SI44F4Z6-9JOodLES.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sizeCapture-X5ZJPWSS-C7Roe_yn.js => sizeCapture-X5ZJPWSS-ZbdMN49_.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{stateDiagram-OKZ733FA-BrlX_tes.js => stateDiagram-OKZ733FA-EXFVO3yB.js} (96%) delete mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-BUH5KdnU.js create mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-CNDrqvMv.js rename veadk/webui/assets/visualizations/mermaid/{swimlanes-SLNWSIFB-CR3Yx4nf.js => swimlanes-SLNWSIFB-Dyq27zQT.js} (99%) create mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-De2UsW7s.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-DpcZasdm.js rename veadk/webui/assets/visualizations/mermaid/{timeline-definition-Z64GVDOM-Dq9PHz3C.js => timeline-definition-Z64GVDOM-rJ5Mg92N.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{vennDiagram-T6HMQDX7-CYHQcSxC.js => vennDiagram-T6HMQDX7-D5wxW4ux.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{wardleyDiagram-T6FBY63Y-Dkoo2yzf.js => wardleyDiagram-T6FBY63Y-jD8OIwqZ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{xychartDiagram-ELKLHX3M-CSukDV3q.js => xychartDiagram-ELKLHX3M-F8zFAtjc.js} (99%) diff --git a/frontend/README.md b/frontend/README.md index 370283488..8d51f9ebc 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -17,6 +17,20 @@ server that `veadk frontend` launches — no separate backend. existing Sessions or rebuild their snapshots. BytePlus has automated coverage, but its image update has not been verified against a live account. +- **Code projects**: open 工作区 → 代码项目 or 从工作区新建 to name, + create and reopen projects. Each user has one persistent cloud Sandbox; + projects are directories under `/home/gem/Projects`. Creating another project + reuses the same Session and opening a project changes VS Code's `folder` path. + The image initializes Git, `AGENTS.md` and a Python environment per project. + The dedicated Private Tool must enable snapshots. Studio reuses the user's + stable cloud session identity and automatically restores its latest ready + snapshot after hibernation, rather than creating an empty replacement. + Project lists are read from the Sandbox filesystem and survive Studio restarts. + The editor opens directly at `/code-server/`; signed routing parameters remain + private/no-store. Returning to management keeps the editor mounted, while + switching directories opens the selected project directly. Volcengine + defaults to Chinese and BytePlus to English. + - **Streaming chat** over the ADK `/run_sse` event stream. While an Agent is generating, the composer exposes a stop control that cancels only the active response, preserves content already received, and immediately enables the @@ -495,6 +509,11 @@ cd frontend && npm run dev # http://localhost:5173 The Vite development server proxies the ADK API routes, including the `/dev/apps/.../debug/trace` session-trace endpoint, to the backend on port 8000. +For code projects, configure `STUDIO_WORKSPACE_TOOL_ID` with a dedicated +snapshot-enabled Tool in the selected provider and region. The former +single-project `/web/workspace-preview/session` preview is replaced by the +personal-workspace project APIs. + ## Branding Set a custom title (up to six characters) and a local or remote image logo when @@ -968,3 +987,42 @@ to `DeleteSessionSnapshot`; it deletes the selected saved record only. If that logical agent has older records, the next latest record can appear on refresh. Failed records remain visible for deletion but cannot be opened. The UI uses agent terminology rather than exposing these control-plane resource types. + +### Studio Sandbox 工作区 + +`veadk studio deploy` 默认创建或复用启用持久化快照的 Studio Sandbox Tool, +新建规格为 8 核 CPU、16 GB 内存,更新时自动补建也使用相同规格, +按云环境和地域选择 `studio-sandbox-1.0.1` 镜像: + +| 云环境 | 地域 | 镜像 | +| --- | --- | --- | +| 火山引擎 | cn-beijing | `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/agentkit-sandbox:studio-sandbox-1.0.1` | +| 火山引擎 | cn-shanghai | `enterprise-cn-shanghai-cn-shanghai.cr.volces.com/vefaas-public/agentkit-sandbox:studio-sandbox-1.0.1` | +| BytePlus | ap-southeast-1 | `enterprise-public-ap-southeast-1.cr.volces.com/vefaas-public/agentkit-sandbox:studio-sandbox-1.0.1` | + +可通过 `STUDIO_WORKSPACE_IMAGE` 指定区域可访问的其他镜像。启动命令使用镜像内的 +`/opt/gem/run.sh`,不再注入编辑器补丁。火山引擎默认中文,BytePlus 默认英文, +模型配置沿用相应云环境的 Studio 配置。 + +部署通过 `STUDIO_WORKSPACE_TOOL_ID` 绑定工作区,系统信息显示对应 Tool ID。 +`veadk studio update` 和前端更新都会自动补齐缺失的持久化工作区 Tool,并保存绑定。 +已有 Tool ID 时保留绑定,避免切换个人项目存储;创建失败时更新报错,不忽略失败。 +从尚不支持补建的旧版本更新时,新版本首次启动会在后台补建并保存函数环境中的 Tool ID。 +补建期间代码项目暂不可用,失败会记录日志,可检查权限后重试更新。 +每位用户的项目共用自己的持久化 Session,打开项目时剩余不足一小时会通过 +`SetSessionTtl` 续期为八小时。标题栏显示倒计时,项目管理右侧支持全屏展开; +内嵌浏览器使用当前页面的可用空间。 + +### 工作区项目模板 + +默认模板位于 `frontend/server/templates/python-agent/`,由 Studio 在创建项目时传入 +Sandbox。修改这些模板只需要更新 Studio,不需要重建镜像,也不会覆盖已有项目。 +`${project_name}` 和 `${agent_name}` 在创建时替换为项目名和合法 Python Agent 名。 + +新镜像的 `studio-project-create NAME --json --template-stdin` 从标准输入接收 +`{"version":1,"files":{"main.py":"...","README.md":"..."}}` 格式的 UTF-8 文件项目, +支持子目录。最多 256 个文件、1 MiB,不接受绝对路径、父目录跳转或 `.git`、`.venv` +文件。工具仅将模板写入新项目,保留离线 Python 环境初始化和 `git init`。 +模板中的新依赖不会自动安装,运行环境依赖仍由镜像管理。 + +其他地域需要显式设置 `STUDIO_WORKSPACE_IMAGE`,避免错误使用跨地域镜像。 diff --git a/frontend/sandbox-image/Dockerfile b/frontend/sandbox-image/Dockerfile new file mode 100644 index 000000000..1f47fa26b --- /dev/null +++ b/frontend/sandbox-image/Dockerfile @@ -0,0 +1,27 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +ARG APT_MIRROR_URL=https://mirrors.aliyun.com/ubuntu +ARG PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ +ARG NPM_REGISTRY_URL=https://registry.npmmirror.com + +# Keep the base runtime's root entrypoint; it starts the editor and terminals as gem +USER root +ENV WORKSPACE=/home/gem/Projects \ + DISABLE_CODE_SERVER=false \ + DISABLE_JUPYTER=false \ + PIP_INDEX_URL=${PIP_INDEX_URL} \ + UV_INDEX_URL=${PIP_INDEX_URL} \ + UV_HTTP_TIMEOUT=60 \ + UV_HTTP_RETRIES=3 \ + npm_config_registry=${NPM_REGISTRY_URL} + +COPY runtime/ /opt/studio-sandbox/runtime/ +COPY assets/ /opt/studio-sandbox/assets/ +COPY assets.lock.json settings.json requirements.lock /opt/studio-sandbox/ + +# All browser extensions and fonts are local assets; Python uses an overridable domestic index +RUN /opt/agentkit-code-env/venv/bin/python /opt/studio-sandbox/runtime/configure-apt.py "${APT_MIRROR_URL}" \ + && /opt/agentkit-code-env/venv/bin/python /opt/studio-sandbox/runtime/install-image.py + +# Preserve the base image ENTRYPOINT and CMD diff --git a/frontend/sandbox-image/Dockerfile.update b/frontend/sandbox-image/Dockerfile.update new file mode 100644 index 000000000..754fe018d --- /dev/null +++ b/frontend/sandbox-image/Dockerfile.update @@ -0,0 +1,7 @@ +# BASE_IMAGE must be a previously built Studio image, pinned by digest for release builds +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +USER root +COPY runtime/ /opt/studio-sandbox/runtime/ +COPY settings.json /opt/studio-sandbox/settings.json +RUN /opt/agentkit-code-env/venv/bin/python /opt/studio-sandbox/runtime/refresh-editor.py diff --git a/frontend/sandbox-image/README.md b/frontend/sandbox-image/README.md new file mode 100644 index 000000000..3e3c5eca2 --- /dev/null +++ b/frontend/sandbox-image/README.md @@ -0,0 +1,167 @@ +# Studio Sandbox 镜像 + +在现有 AgentKit Code Sandbox 基础上预装开发环境,不更改 AIO 服务入口 + +## 镜像内容 + +- `/home/gem/Projects` 作为默认工作目录 +- 独立 VeADK 环境 `/opt/studio-sandbox/venv`,包含 `veadk-python 1.1.9` 和官方 `agentkit-sdk-python 0.8.6` 提供的 `agentkit` 命令 +- 固定版本 Python 依赖及 uv 离线缓存,新项目无需再次联网下载 +- 默认 Dark Modern,代码和终端使用 Maple Mono v7.9,其他界面字体不变 +- Python 语法高亮、BasedPyright 补全、Ruff 格式化和 `.venv` 解释器自动选择 +- code-server 与 Jupyter 开启,原 sandbox 启动入口和鉴权方式保持不变 + +| 插件 | 固定版本 | +| --- | --- | +| Git History | 0.6.20 | +| gitignore | 0.10.0 | +| MDX | 1.8.18 | +| Modern MDX Preview | 1.7.0 | +| Python | 2026.4.0 | +| BasedPyright | 1.40.0 | +| Python Debugger | 2026.6.0,Linux x64 | +| Ruff | 2026.78.0,Linux x64 | +| Even Better TOML | 0.21.2 | + +MDX Preview 采用 Open VSX 上的 `ggfincke.vsc-mdx-preview`,固定在兼容基础镜像 Node 22.18 / Code 1.104 的版本,不依赖微软 Marketplace 或 Pylance + +构建先校验原始 VSIX,再从本地安装副本中移除可选 `extensionPack` 捆绑列表,避免 Python 插件自动下载 Pylance 或额外插件;插件代码、许可证和必要依赖保持不变 + +Maple Mono 通过同源 WOFF2 文件和 `@font-face` 提供给浏览器,不要求用户电脑安装字体,字体许可证随文件保留 + +## 准备构建上下文 + +在可访问 Open VSX 和 GitHub 的准备环境执行,不能在云流水线内临时下载这些资产 + +```sh +python3 prepare-context.py \ + --cache /tmp/studio-sandbox-assets \ + --output /tmp/studio-sandbox-context.tar.gz \ + --base-image '<当前区域可访问的 Code Sandbox 镜像引用>' +``` + +脚本按 `assets.lock.json` 校验 SHA256,仅将 Dockerfile、固定依赖、运行脚本和必要插件/字体打包,排除整个仓库、`.env`、Git 信息和本机虚拟环境 + +压缩包内 Dockerfile 位于根目录,适用于 CodePipeline 从私有 TOS 下载解压后直接构建 + +Dockerfile 通过构建参数适配火山引擎和 BytePlus,不写入账号、区域或凭据 + +- `BASE_IMAGE`:对应区域的基础镜像,准备上下文时注入默认值 +- `APT_MIRROR_URL`:默认 `https://mirrors.aliyun.com/ubuntu` +- `PIP_INDEX_URL`:默认 `https://mirrors.aliyun.com/pypi/simple/` +- `NPM_REGISTRY_URL`:默认 `https://registry.npmmirror.com` + +BytePlus 可换成部署区域内可访问的官方源或企业镜像源,镜像内不包含 AK/SK、Git 授权或模型密钥 + +## 新建项目 + +```sh +studio-project-create my-agent --json +``` + +生成 `Projects/my-agent/main.py`、独立 `.venv`、`.gitignore`、`README.md` 和 `AGENTS.md`,并执行 `git init --initial-branch=main`,不自动提交或设置远程仓库,依赖从镜像缓存离线安装,不复制其他路径的虚拟环境,不覆盖重名目录 + +项目名以英文字母开头,允许字母、数字、短横线、下划线,最多 64 个字符;Agent 的 Python 名称会把短横线转换为下划线 + +初始化失败保留目录供检查,避免误删任何工作内容 + +## 手动升级编辑器 + +```sh +studio-vscode-upgrade --check +studio-vscode-upgrade --version 4.136.2 +``` + +检查默认使用 code-server 官方 Release API,有连接超时,不在 Session 启动时执行;可通过 `STUDIO_CODE_SERVER_RELEASE_API` 切换为兼容官方响应格式的企业镜像接口 + +国内环境建议先把官方安装包校验后放入账号对象存储,再指定下载地址和 SHA256 + +```sh +studio-vscode-upgrade --version '<版本号>' \ + --archive-url '<国内 HTTPS 下载地址>' \ + --sha256 '<官方 SHA256>' +``` + +命令要求先保存文件,只准备下一次启动使用的 code-server 版本,重新应用 Maple Mono 字体,不自动重启正在使用的编辑器,不修改项目、插件和用户设置 + +可用 `--rollback` 选择上一个版本,升级仅影响当前 Sandbox 文件系统,新 Session 的默认版本仍需更新镜像;不要为应用升级销毁正在使用的 Session + +基础镜像每次启动覆盖用户设置的行为已改为仅首次初始化,后续重启保留用户设置 + +## 轻量检查 + +```sh +python3 -m unittest discover -s tests -v +``` + +镜像构建还会验证 Python/AgentKit 导入和离线项目创建,检查成功后移除仅供构建验证的临时项目 + +## 编辑器和终端默认行为 + +`VSCODE_LANG` 未设置或为 `zh-CN` 时使用简体中文,为 `en` 时使用英文,编辑器和欢迎页保持一致 +火山引擎默认中文,BytePlus 启动时设置 `VSCODE_LANG=en` +中文界面使用官方 `MS-CEINTL.vscode-language-pack-zh-hans` 1.104.0 + +首次进入只显示 VS Code,欢迎页包含文档链接、VeADK 介绍和 AgentKit 介绍 +每个工作区默认创建 Bash 和 Codex 两个集成终端,工作目录均为当前项目 +刷新时复用已恢复的同名终端,Codex 使用 `--cd` 显式指定项目目录 +配置 `MODEL_AGENT_API_KEY`、`MODEL_AGENT_NAME` 和 `MODEL_AGENT_BASE_URL` 后,基础镜像生成 Codex 模型配置,无需交互登录;凭据只在启动时传入,不写入镜像 + +Bash 使用上游 ble.sh v0.4.0-devel3 提供输入高亮,采用固定 SHA256 的官方发布包,不切换 Shell + +本地 Apple Silicon 在 QEMU 中运行 x64 Codex 0.139.0 时,可能因不支持 TCGETS2 终端接口报 `os error 38` +本地预览容器可通过基础镜像已有的 `CODEX_REAL_BIN` 覆盖为相同版本 Linux ARM64 musl 程序 +此替换仅用于本地兼容,不改变云端 x64 镜像的默认程序,也不放宽权限或沙箱限制 + +## Apple Silicon 本地预览 + +云端继续使用基础镜像的 `/opt/gem/run.sh` 和原始 x64 Codex +本地 ARM 预览显式选择 `/opt/studio-sandbox/runtime/local-arm64-entrypoint`,它设置已有的 `CODEX_REAL_BIN` 覆盖变量并统一 fnm 命令入口,再执行原始启动脚本 +该入口随镜像提供,不是镜像默认入口 + +建议本地虚拟机至少提供 4 CPU / 12 GB 内存,构建时避免并行运行多个编辑器窗口 +本地运行设置 `DISABLE_BROWSER=true`,避免 x64 浏览器在 QEMU 下反复崩溃 +运行示例中的模型环境文件应设置为仅当前用户可读 + +```sh +docker run -d --name studio-sandbox-local --platform linux/amd64 \ + --entrypoint /opt/studio-sandbox/runtime/local-arm64-entrypoint \ + --ulimit core=0 --shm-size=512m \ + --env-file /path/to/model.env \ + -e AIO_USER=gem -e DISABLE_BROWSER=true \ + -e DISABLE_DEEPSEEK_HARNESS_WEBUI=true \ + -e DISABLE_CODEX_APP_SERVER=true -e DISABLE_CODEX_MCP_SERVER=true \ + -p 127.0.0.1:18080:8080 studio-sandbox:local +``` + +项目的 `.git` 在资源管理器中默认可见 +`AGENTS.md` 仅包含 Python 开发规范、项目目录说明和 VeADK / AgentKit 文档链接 +项目技能可放在 `.agents/skills//SKILL.md`,不默认复制个人技能 + +## 云端默认镜像与资源加载 + +镜像内置云端会话资源路由:主题、语法文件、语言包、工作台脚本、字体和欢迎页的 +Service Worker 使用当前页面的 `Authorization`、`faasInstanceName`,不向外部域名转发 +鉴权参数;本地地址不带这些参数时保持原有访问方式 +无需在 Tool 的启动命令里再注入编辑器补丁,直接使用 `/opt/gem/run.sh` + +终端仅在项目首次打开时自动初始化,后续刷新交给 VS Code 恢复,不因恢复较慢重复创建 +手动关闭后不会在刷新时重新打开;需要时通过命令面板执行 `Studio: Open Bash and Codex / 打开终端` +现有用户自行创建的终端不会被删除 + +文件监听排除 `.venv`、Python 系统依赖、`__pycache__` 和 `node_modules`,减少云端 +inotify 配额占用;目录仍可见,项目源码仍被监听,Python 补全仍可读取依赖类型信息 + +基于已经发布的 Studio 镜像更新编辑器时,可以使用 `Dockerfile.update`,`BASE_IMAGE` +应固定为已验证镜像的 SHA256 digest;全量构建继续使用 `Dockerfile` +更新过程只在构建镜像时执行,不覆盖挂载进来的用户设置和项目文件 + +```sh +docker build --platform linux/amd64 -f Dockerfile.update \ + --build-arg BASE_IMAGE='' \ + -t studio-sandbox:updated . +node --test tests/test_terminals.cjs +``` + +Tool 建议保持已验证的 4 CPU / 8 GB 内存、8080 端口和快照能力,模型环境变量在 +创建 Tool 时传入;火山引擎默认中文,BytePlus 设置 `VSCODE_LANG=en` diff --git a/frontend/sandbox-image/assets.lock.json b/frontend/sandbox-image/assets.lock.json new file mode 100644 index 000000000..1ea392bee --- /dev/null +++ b/frontend/sandbox-image/assets.lock.json @@ -0,0 +1,67 @@ +[ + { + "file": "donjayamanne.githistory-0.6.20.vsix", + "url": "https://open-vsx.org/api/donjayamanne/githistory/0.6.20/file/donjayamanne.githistory-0.6.20.vsix", + "sha256": "aaea597276f089922e5b785f5c1ca1f1bef523ee17d6766889b67c0c76c35104" + }, + { + "file": "codezombiech.gitignore-0.10.0.vsix", + "url": "https://open-vsx.org/api/codezombiech/gitignore/0.10.0/file/codezombiech.gitignore-0.10.0.vsix", + "sha256": "0c13696b23838c6523f1e4eba8427bc5a3082f50cd8ee63072f3e0335fb4e95b" + }, + { + "file": "unifiedjs.vscode-mdx-1.8.18.vsix", + "url": "https://open-vsx.org/api/unifiedjs/vscode-mdx/1.8.18/file/unifiedjs.vscode-mdx-1.8.18.vsix", + "sha256": "1e3c27c6c66d11ab2333362ec4a6255741a57ca6d226b33315308beaff634c85" + }, + { + "file": "ggfincke.vsc-mdx-preview-1.7.0.vsix", + "url": "https://open-vsx.org/api/ggfincke/vsc-mdx-preview/1.7.0/file/ggfincke.vsc-mdx-preview-1.7.0.vsix", + "sha256": "de677b31a443ade525bf2ec8d28d4ab03112bfc8647fa55a3c23b8f30e89a7ed" + }, + { + "file": "ms-python.python-2026.4.0.vsix", + "url": "https://open-vsx.org/api/ms-python/python/2026.4.0/file/ms-python.python-2026.4.0.vsix", + "sha256": "232aeafb01f069824fdd92d3e628c1c442bbcfa1d3cc945ff97076340bb2b4a6" + }, + { + "file": "detachhead.basedpyright-1.40.0.vsix", + "url": "https://open-vsx.org/api/detachhead/basedpyright/1.40.0/file/detachhead.basedpyright-1.40.0.vsix", + "sha256": "67e47122039ab2a687dd31e470eaa282093efefe7ad20f502c76331b8d058333" + }, + { + "file": "ms-python.debugpy-2026.6.0-linux-x64.vsix", + "url": "https://open-vsx.org/api/ms-python/debugpy/linux-x64/2026.6.0/file/ms-python.debugpy-2026.6.0@linux-x64.vsix", + "sha256": "c7744af4bf72978f5792624a71c80e2b622a1118574fada3a903d70ac03d5bca" + }, + { + "file": "charliermarsh.ruff-2026.78.0-linux-x64.vsix", + "url": "https://open-vsx.org/api/charliermarsh/ruff/linux-x64/2026.78.0/file/charliermarsh.ruff-2026.78.0@linux-x64.vsix", + "sha256": "d127e787181cc2cfcd939ee5175515ec3421da9ab9389945a5168f1e14920258" + }, + { + "file": "tamasfe.even-better-toml-0.21.2.vsix", + "url": "https://open-vsx.org/api/tamasfe/even-better-toml/0.21.2/file/tamasfe.even-better-toml-0.21.2.vsix", + "sha256": "f3dc44f1c551edef397a79f0f8c5db4066c6a547e210b5c3cec4400bf173b008" + }, + { + "file": "MapleMono-Woff2.zip", + "url": "https://github.com/subframe7536/maple-font/releases/download/v7.9/MapleMono-Woff2.zip", + "sha256": "5e38e83b007e7157c253c3f57c0a6f80415378f4859d43eb3cf4b1d858001681" + }, + { + "file": "MS-CEINTL.vscode-language-pack-zh-hans-1.104.0.vsix", + "url": "https://open-vsx.org/api/MS-CEINTL/vscode-language-pack-zh-hans/1.104.0/file/MS-CEINTL.vscode-language-pack-zh-hans-1.104.0.vsix", + "sha256": "eed5d80fb1b319f35ad984bbaa78e4122c43c9dcee1df8780fd8c30fcea9fe09" + }, + { + "file": "blesh-0.4.0-devel3.tar.xz", + "url": "https://github.com/akinomyoga/ble.sh/releases/download/v0.4.0-devel3/ble-0.4.0-devel3.tar.xz", + "sha256": "c8612ee612bc6b10dbfd6e85c6cbdfd7caf152a12d1f9de22ea0a9d735b3080c" + }, + { + "file": "codex-0.139.0-linux-arm64.tgz", + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.139.0-linux-arm64.tgz", + "sha256": "6195677e4ba51f22a9a1b5f0e2a01a255a42a5755b740123ce68f530ed2dd3ca" + } +] diff --git a/frontend/sandbox-image/prepare-context.py b/frontend/sandbox-image/prepare-context.py new file mode 100644 index 000000000..1c8ffd270 --- /dev/null +++ b/frontend/sandbox-image/prepare-context.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Prepare an allowlisted build context; no repository or credential files are copied.""" + +import argparse +import concurrent.futures +import hashlib +import json +import shutil +import subprocess +import tarfile +import tempfile +import re +from pathlib import Path + + +def fetch_asset(asset: dict[str, str], cache: Path) -> Path: + path = cache / asset["file"] + if path.exists(): + with path.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() == asset["sha256"]: + return path + partial = path.with_suffix(path.suffix + ".part") + subprocess.run( + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--location", + "--retry", + "3", + "--connect-timeout", + "15", + "--max-time", + "600", + "--output", + str(partial), + asset["url"], + ], + check=True, + ) + with partial.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + if digest != asset["sha256"]: + raise ValueError(f"Checksum mismatch: {asset['file']}") + partial.replace(path) + print(f"Verified {path.name}: {path.stat().st_size:,} bytes", flush=True) + return path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--base-image", + required=True, + help="Registry image reference used only in the generated context", + ) + args = parser.parse_args() + if args.output.exists(): + parser.error("Output already exists; choose a new archive path") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9./_:@-]+", args.base_image): + parser.error("Invalid base image reference") + args.cache.mkdir(parents=True, exist_ok=True) + source = Path(__file__).resolve().parent + assets = json.loads((source / "assets.lock.json").read_text()) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + paths = list(pool.map(lambda asset: fetch_asset(asset, args.cache), assets)) + with tempfile.TemporaryDirectory(prefix="studio-sandbox-context-") as temporary: + context = Path(temporary) + for filename in ( + "Dockerfile", + "requirements.in", + "requirements.lock", + "settings.json", + "assets.lock.json", + ): + shutil.copyfile(source / filename, context / filename) + dockerfile = context / "Dockerfile" + dockerfile.write_text( + dockerfile.read_text().replace( + "ARG BASE_IMAGE\n", f"ARG BASE_IMAGE={args.base_image}\n", 1 + ) + ) + shutil.copytree( + source / "runtime", + context / "runtime", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + (context / "assets").mkdir() + for path in paths: + shutil.copyfile(path, context / "assets" / path.name) + with tarfile.open(args.output, "w:gz") as archive: + for path in sorted(context.rglob("*")): + if path.is_file(): + archive.add( + path, arcname=str(path.relative_to(context)), recursive=False + ) + with args.output.open("rb") as stream: + print(f"sha256 {hashlib.file_digest(stream, 'sha256').hexdigest()}") + print(f"archive {args.output.resolve()} ({args.output.stat().st_size:,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/frontend/sandbox-image/pyrightconfig.json b/frontend/sandbox-image/pyrightconfig.json new file mode 100644 index 000000000..64fddb7c6 --- /dev/null +++ b/frontend/sandbox-image/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "include": ["prepare-context.py", "runtime", "tests"], + "extraPaths": ["runtime"], + "pythonVersion": "3.12", + "typeCheckingMode": "basic" +} diff --git a/frontend/sandbox-image/requirements.in b/frontend/sandbox-image/requirements.in new file mode 100644 index 000000000..1f149d41d --- /dev/null +++ b/frontend/sandbox-image/requirements.in @@ -0,0 +1,2 @@ +veadk-python==1.1.9 +agentkit-sdk-python==0.8.6 diff --git a/frontend/sandbox-image/requirements.lock b/frontend/sandbox-image/requirements.lock new file mode 100644 index 000000000..fe988bc3b --- /dev/null +++ b/frontend/sandbox-image/requirements.lock @@ -0,0 +1,665 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile frontend/sandbox-image/requirements.in --python-version 3.12 --python-platform x86_64-unknown-linux-gnu --output-file frontend/sandbox-image/requirements.lock --no-emit-index-url +a2a-sdk==0.3.26 + # via + # agentkit-sdk-python + # veadk-python +agent-pilot-sdk==0.1.2 + # via veadk-python +agentkit-sdk-python==0.8.6 + # via + # -r frontend/sandbox-image/requirements.in + # veadk-python +aiofile==3.12.3 + # via py-key-value-aio +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.13.4 + # via + # litellm + # vikingdb-python-sdk +aiomysql==0.3.2 + # via veadk-python +aiosignal==1.4.0 + # via aiohttp +aiosqlite==0.22.1 + # via google-adk +annotated-doc==0.0.5 + # via + # fastapi + # typer +annotated-types==0.8.0 + # via pydantic +antlr4-python3-runtime==4.9.3 + # via omegaconf +anyio==4.15.1 + # via + # google-genai + # httpx + # mcp + # openai + # py-key-value-aio + # sse-starlette + # starlette + # volcengine-python-sdk + # watchfiles +arrow==1.4.0 + # via cookiecutter +asyncpg==0.31.0 + # via veadk-python +attrs==26.1.0 + # via + # aiohttp + # cyclopts + # jsonschema + # jsonschema-path + # referencing +authlib==1.8.0 + # via + # fastmcp-slim + # google-adk +babel==2.18.0 + # via courlan +beartype==0.22.9 + # via py-key-value-aio +beautifulsoup4==4.15.0 + # via google +binaryornot==0.6.0 + # via cookiecutter +cachetools==7.1.8 + # via py-key-value-aio +caio==0.12.4 + # via aiofile +certifi==2026.7.22 + # via + # httpcore + # httpx + # requests + # trafilatura + # volcengine-python-sdk +cffi==2.1.1 + # via cryptography +charset-normalizer==3.5.1 + # via + # htmldate + # requests + # trafilatura +chevron==0.14.0 + # via agent-pilot-sdk +click==8.1.8 + # via + # cookiecutter + # google-adk + # litellm + # uvicorn +cookiecutter==2.6.0 + # via veadk-python +courlan==1.4.0 + # via trafilatura +crcmod==1.7 + # via tos +cryptography==50.0.1 + # via + # authlib + # google-auth + # joserfc + # pyjwt + # secretstorage + # trustedmcp + # volcengine-python-sdk +cyclopts==4.25.2 + # via fastmcp-slim +dateparser==1.4.3 + # via htmldate +decorator==5.3.1 + # via retry +deprecated==1.2.18 + # via + # tos + # veadk-python +distro==1.9.0 + # via + # google-genai + # openai +dnspython==2.8.0 + # via email-validator +docker==7.2.0 + # via agentkit-sdk-python +docstring-parser==0.18.0 + # via cyclopts +email-validator==2.3.0 + # via pydantic +et-xmlfile==2.0.0 + # via openpyxl +exceptiongroup==1.3.1 + # via fastmcp-slim +fastapi==0.141.1 + # via + # agentkit-sdk-python + # google-adk +fastmcp==3.4.7 + # via + # agentkit-sdk-python + # veadk-python +fastmcp-slim==3.4.7 + # via fastmcp +fastuuid==0.14.0 + # via litellm +filelock==3.32.5 + # via huggingface-hub +filetype==1.2.0 + # via veadk-python +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.7.0 + # via huggingface-hub +google==3.0.0 + # via volcengine +google-adk==2.2.0 + # via veadk-python +google-api-core==2.34.0 + # via a2a-sdk +google-auth==2.57.1 + # via + # google-adk + # google-api-core + # google-genai +google-genai==2.22.0 + # via google-adk +googleapis-common-protos==1.75.3 + # via + # google-api-core + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +graphviz==0.21 + # via google-adk +greenlet==3.5.5 + # via sqlalchemy +griffelib==2.3.0 + # via fastmcp-slim +grpcio==1.83.1 + # via opentelemetry-exporter-otlp-proto-grpc +h11==0.16.0 + # via + # httpcore + # uvicorn +hf-xet==1.6.0 + # via huggingface-hub +htmldate==1.10.0 + # via trafilatura +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # a2a-sdk + # fastmcp-slim + # google-adk + # google-genai + # huggingface-hub + # litellm + # mcp + # openai + # openviking-sdk + # veadk-python + # vikingdb-python-sdk + # volcengine-python-sdk +httpx-sse==0.4.3 + # via + # a2a-sdk + # mcp +huggingface-hub==1.16.1 + # via tokenizers +idna==3.19 + # via + # anyio + # email-validator + # httpx + # requests + # yarl +importlib-metadata==8.5.0 + # via + # litellm + # opentelemetry-api +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.1.2 + # via keyring +jaraco-functools==4.6.0 + # via keyring +jeepney==0.9.0 + # via + # keyring + # secretstorage +jinja2==3.1.6 + # via + # agentkit-sdk-python + # cookiecutter + # litellm +jiter==0.16.0 + # via openai +joserfc==1.7.5 + # via + # authlib + # fastmcp-slim +jsonpickle==4.1.2 + # via agent-pilot-sdk +jsonref==1.1.0 + # via fastmcp-slim +jsonschema==4.23.0 + # via + # google-adk + # litellm + # mcp + # veadk-python +jsonschema-path==0.5.0 + # via fastmcp-slim +jsonschema-specifications==2025.9.1 + # via jsonschema +justext==3.0.2 + # via trafilatura +keyring==25.7.0 + # via py-key-value-aio +litellm==1.83.14 + # via veadk-python +loguru==0.7.3 + # via + # agent-pilot-sdk + # trustedmcp +lxml==6.1.3 + # via + # htmldate + # justext + # lxml-html-clean + # trafilatura +lxml-html-clean==0.4.5 + # via lxml +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.3 + # via jinja2 +mcp==1.26.0 + # via + # fastmcp-slim + # trustedmcp + # veadk-python +mdurl==0.1.2 + # via markdown-it-py +more-itertools==11.1.0 + # via + # jaraco-classes + # jaraco-functools +multidict==6.7.1 + # via + # aiohttp + # yarl +omegaconf==2.3.0 + # via veadk-python +openai==2.24.0 + # via litellm +openapi-pydantic==0.5.1 + # via fastmcp-slim +openpyxl==3.1.5 + # via agent-pilot-sdk +opentelemetry-api==1.37.0 + # via + # agentkit-sdk-python + # fastmcp-slim + # google-adk + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-instrumentation + # opentelemetry-instrumentation-logging + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp==1.37.0 + # via veadk-python +opentelemetry-exporter-otlp-proto-common==1.37.0 + # via + # agentkit-sdk-python + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.37.0 + # via + # agentkit-sdk-python + # opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-proto-http==1.37.0 + # via opentelemetry-exporter-otlp +opentelemetry-instrumentation==0.58b0 + # via opentelemetry-instrumentation-logging +opentelemetry-instrumentation-logging==0.58b0 + # via veadk-python +opentelemetry-proto==1.37.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.37.0 + # via + # agentkit-sdk-python + # google-adk + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.58b0 + # via + # opentelemetry-instrumentation + # opentelemetry-sdk +openviking-sdk==0.1.10 + # via veadk-python +packaging==26.3 + # via + # fastmcp-slim + # google-adk + # huggingface-hub + # opentelemetry-instrumentation +pathable==0.6.0 + # via jsonschema-path +pillow==12.3.0 + # via veadk-python +platformdirs==4.11.7 + # via fastmcp-slim +prompt-toolkit==3.0.53 + # via agentkit-sdk-python +propcache==0.5.2 + # via + # aiohttp + # yarl +proto-plus==1.28.4 + # via google-api-core +protobuf==6.33.6 + # via + # a2a-sdk + # google-api-core + # googleapis-common-protos + # opentelemetry-proto + # proto-plus + # volcengine +psycopg2-binary==2.9.12 + # via veadk-python +py==1.11.0 + # via retry +py-key-value-aio==0.4.5 + # via fastmcp-slim +pyasn1==0.6.4 + # via pyasn1-modules +pyasn1-modules==0.4.2 + # via google-auth +pycparser==3.0 + # via cffi +pycryptodome==3.23.0 + # via volcengine +pydantic==2.12.5 + # via + # a2a-sdk + # agent-pilot-sdk + # agentkit-sdk-python + # fastapi + # fastmcp-slim + # google-adk + # google-genai + # litellm + # mcp + # openai + # openapi-pydantic + # pydantic-settings + # vikingdb-python-sdk + # volcengine-python-sdk +pydantic-core==2.41.5 + # via pydantic +pydantic-settings==2.10.1 + # via + # fastmcp-slim + # mcp + # veadk-python +pyfiglet==1.0.4 + # via agentkit-sdk-python +pygments==2.21.0 + # via + # rich + # rich-rst +pyjwt==2.13.0 + # via + # agentkit-sdk-python + # mcp +pymysql==1.1.1 + # via + # aiomysql + # veadk-python +pypdfium2==5.13.0 + # via veadk-python +pyperclip==1.11.0 + # via fastmcp-slim +python-dateutil==2.9.0.post0 + # via + # arrow + # dateparser + # htmldate + # volcengine-python-sdk +python-dotenv==1.2.2 + # via + # agentkit-sdk-python + # fastmcp-slim + # google-adk + # litellm + # pydantic-settings +python-frontmatter==1.1.0 + # via veadk-python +python-multipart==0.0.32 + # via + # fastmcp-slim + # google-adk + # mcp +python-slugify==8.0.4 + # via cookiecutter +pytz==2026.3.post1 + # via + # dateparser + # tos + # volcengine +pyyaml==6.0.3 + # via + # agentkit-sdk-python + # cookiecutter + # fastmcp-slim + # google-adk + # huggingface-hub + # jsonschema-path + # omegaconf + # python-frontmatter + # veadk-python +referencing==0.37.0 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +regex==2026.9.3 + # via + # dateparser + # tiktoken +requests==2.34.2 + # via + # agent-pilot-sdk + # agentkit-sdk-python + # cookiecutter + # docker + # google-adk + # google-api-core + # google-auth + # google-genai + # opentelemetry-exporter-otlp-proto-http + # tiktoken + # tos + # trustedmcp + # vikingdb-python-sdk + # volcengine +retry==0.9.2 + # via volcengine +rich==15.0.0 + # via + # agentkit-sdk-python + # cookiecutter + # cyclopts + # fastmcp-slim + # rich-rst + # typer +rich-rst==2.1.0 + # via cyclopts +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +secretstorage==3.5.0 + # via keyring +shellingham==1.5.4 + # via typer +six==1.17.0 + # via + # python-dateutil + # tos + # volcengine + # volcengine-python-sdk +sniffio==1.3.1 + # via + # google-genai + # openai +soupsieve==2.9.2 + # via beautifulsoup4 +sqlalchemy==2.0.52 + # via veadk-python +sse-starlette==3.4.11 + # via mcp +starlette==1.6.0 + # via + # fastapi + # fastmcp-slim + # google-adk + # mcp + # sse-starlette +tenacity==9.1.4 + # via + # google-adk + # google-genai + # volcengine +text-unidecode==1.3 + # via python-slugify +tiktoken==0.12.0 + # via litellm +tld==0.13.2 + # via courlan +tokenizers==0.22.2 + # via litellm +tos==2.8.7 + # via + # agentkit-sdk-python + # veadk-python +tqdm==4.70.0 + # via + # huggingface-hub + # openai +trafilatura==2.0.0 + # via veadk-python +trustedmcp==0.0.5 + # via veadk-python +typer==0.27.2 + # via + # agentkit-sdk-python + # huggingface-hub +typing-extensions==4.16.0 + # via + # agentkit-sdk-python + # aiosignal + # anyio + # beautifulsoup4 + # exceptiongroup + # fastapi + # fastmcp-slim + # google-adk + # google-genai + # grpcio + # huggingface-hub + # mcp + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # py-key-value-aio + # pydantic + # pydantic-core + # referencing + # sqlalchemy + # starlette + # trustedmcp + # typing-inspection + # vikingdb-python-sdk +typing-inspection==0.4.4 + # via + # fastapi + # mcp + # pydantic + # pydantic-settings +tzdata==2026.3 + # via arrow +tzlocal==5.4.4 + # via + # dateparser + # google-adk +uncalled-for==0.4.0 + # via fastmcp-slim +urllib3==2.7.0 + # via + # courlan + # docker + # htmldate + # requests + # trafilatura + # vikingdb-python-sdk + # volcengine-python-sdk +uv==0.12.10 + # via agent-pilot-sdk +uvicorn==0.52.4 + # via + # agentkit-sdk-python + # fastmcp-slim + # google-adk + # mcp + # trustedmcp +veadk-python==1.1.9 + # via -r frontend/sandbox-image/requirements.in +vikingdb-python-sdk==0.1.32 + # via veadk-python +volcengine==1.0.228 + # via + # agentkit-sdk-python + # veadk-python + # vikingdb-python-sdk +volcengine-python-sdk==5.0.48 + # via + # agent-pilot-sdk + # trustedmcp + # veadk-python +watchdog==6.0.0 + # via google-adk +watchfiles==1.2.0 + # via fastmcp-slim +wcwidth==0.8.3 + # via prompt-toolkit +websocket-client==1.9.2 + # via agentkit-sdk-python +websockets==15.0.1 + # via + # fastmcp-slim + # google-adk + # google-genai + # veadk-python +wrapt==1.17.2 + # via + # deprecated + # opentelemetry-instrumentation + # veadk-python +yarl==1.24.5 + # via aiohttp +zipp==4.1.0 + # via importlib-metadata diff --git a/frontend/sandbox-image/runtime/code-server-wrapper b/frontend/sandbox-image/runtime/code-server-wrapper new file mode 100755 index 000000000..6bda9fba0 --- /dev/null +++ b/frontend/sandbox-image/runtime/code-server-wrapper @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +studio_code_home="${STUDIO_CODE_SERVER_HOME:-/home/gem/.local/share/studio-code-server}" +case "${VSCODE_LANG:-zh-CN}" in + zh-CN|zh-cn) export VSCODE_LANG=zh-CN; studio_locale=zh-cn ;; + en|en-US|en-us) export VSCODE_LANG=en; studio_locale=en ;; + *) echo 'VSCODE_LANG must be zh-CN or en' >&2; exit 2 ;; +esac +if [ -x "$studio_code_home/current/bin/code-server" ]; then + exec "$studio_code_home/current/bin/code-server" --extensions-dir /home/gem/.local/share/code-server/extensions --locale "$studio_locale" "$@" +fi +exec /opt/studio-sandbox/base-code-server --extensions-dir /home/gem/.local/share/code-server/extensions --locale "$studio_locale" "$@" diff --git a/frontend/sandbox-image/runtime/configure-apt.py b/frontend/sandbox-image/runtime/configure-apt.py new file mode 100644 index 000000000..cf0594c14 --- /dev/null +++ b/frontend/sandbox-image/runtime/configure-apt.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configure only the base Ubuntu archive URLs, without changing suites or signing.""" + +import re +import sys +from pathlib import Path +from urllib.parse import urlsplit + +mirror = sys.argv[1].rstrip("/") +parsed = urlsplit(mirror) +if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment +): + raise ValueError( + "APT mirror must be an HTTPS URL without credentials or query parameters" + ) +source = Path("/etc/apt/sources.list") +content = source.read_text() +updated, count = re.subn( + r"https?://(?:archive|security)\.ubuntu\.com/ubuntu/?", mirror, content +) +if count == 0: + raise ValueError( + "Base APT sources changed; review mirror configuration before building" + ) +source.write_text(updated) diff --git a/frontend/sandbox-image/runtime/configure-runtime.py b/frontend/sandbox-image/runtime/configure-runtime.py new file mode 100644 index 000000000..9ebc4fdaa --- /dev/null +++ b/frontend/sandbox-image/runtime/configure-runtime.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Apply Studio's first-start dashboard and Bash defaults.""" + +import re +from pathlib import Path + +for target in (Path("/opt/aio/index.html"), Path("/opt/aio/index.html.template")): + if not target.exists(): + continue + content = target.read_text() + for name in ("terminal", "vnc"): + content = re.sub( + rf"(\{{ id: '{name}'[^\n]*?)open: true", + r"\1open: false", + content, + ) + content = content.replace( + "url: '/code-server/', enabled: off(cfg.code_server) }", + "url: '/code-server/', enabled: off(cfg.code_server), open: true }", + ) + target.write_text(content) + +snippet = """ +# Studio Bash highlighting uses the upstream ble.sh runtime +if [[ $- == *i* ]] && [[ -f /opt/studio-sandbox/ble/share/blesh/ble.sh ]]; then + source /opt/studio-sandbox/ble/share/blesh/ble.sh +fi +""" +for target in (Path("/opt/gem/bashrc"), Path("/home/gem/.bashrc")): + content = target.read_text() if target.exists() else "" + if "# Studio Bash highlighting" not in content: + target.write_text(content + snippet) diff --git a/frontend/sandbox-image/runtime/editor_assets.py b/frontend/sandbox-image/runtime/editor_assets.py new file mode 100644 index 000000000..202205c61 --- /dev/null +++ b/frontend/sandbox-image/runtime/editor_assets.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code-server font assets and first-start defaults shared by build and upgrades.""" + +import shutil +import json +import re +import zipfile +from pathlib import Path + +FONT_MARKER = "/* studio-maple-mono */" +WORKBENCH = Path("lib/vscode/out/vs/code/browser/workbench") +FONTS = { + "MapleMono-Regular.ttf.woff2": ("normal", 400), + "MapleMono-Italic.ttf.woff2": ("italic", 400), + "MapleMono-Bold.ttf.woff2": ("normal", 700), + "MapleMono-BoldItalic.ttf.woff2": ("italic", 700), +} + + +def offline_vsix(source: Path, target: Path) -> None: + """Remove optional extension-pack downloads, keeping code and required dependencies.""" + with zipfile.ZipFile(source) as original, zipfile.ZipFile(target, "w") as output: + for entry in original.infolist(): + data = original.read(entry.filename) + if entry.filename == "extension/package.json": + manifest = json.loads(data) + if manifest.get("extensionPack"): + manifest["extensionPack"] = [] + data = json.dumps(manifest, ensure_ascii=False, indent=2).encode() + elif entry.filename == "extension.vsixmanifest": + data = re.sub( + rb'', + b"", + data, + ) + output.writestr(entry, data) + + +def apply_fonts(code_server: Path, fonts: Path) -> None: + workbench = code_server / WORKBENCH + stylesheet = workbench / "workbench.css" + if not stylesheet.is_file(): + raise ValueError("Unsupported code-server layout: workbench.css is missing") + content = stylesheet.read_text() + if FONT_MARKER in content: + content = content.split(FONT_MARKER, 1)[0].rstrip() + target = workbench / "studio-fonts" + target.mkdir(exist_ok=True) + rules = [] + for filename, (style, weight) in FONTS.items(): + shutil.copyfile(fonts / filename, target / filename) + rules.append( + "@font-face{font-family:'Maple Mono';" + f"src:url('./studio-fonts/{filename}') format('woff2');" + f"font-style:{style};font-weight:{weight};font-display:swap;}}" + ) + shutil.copyfile(fonts / "LICENSE.txt", target / "LICENSE.txt") + stylesheet.write_text(content + "\n" + FONT_MARKER + "\n" + "\n".join(rules) + "\n") + + +def preserve_editor_settings(script: Path) -> None: + old = "cp -rf /opt/gem/vscode /home/$USER/.config/code-server/vscode" + new = f'if [ ! -d "/home/$USER/.config/code-server/vscode" ]; then\n {old}\nfi' + content = script.read_text() + if new in content: + return + if content.splitlines().count(old) != 1: + raise ValueError( + "Base image changed: expected one editor settings seed command" + ) + script.write_text(content.replace(old, new, 1)) diff --git a/frontend/sandbox-image/runtime/editor_routing.py b/frontend/sandbox-image/runtime/editor_routing.py new file mode 100644 index 000000000..6c4681f10 --- /dev/null +++ b/frontend/sandbox-image/runtime/editor_routing.py @@ -0,0 +1,172 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Keep language-pack requests on the same authenticated cloud session.""" + +import base64 +import hashlib +import re +from pathlib import Path + +_NLS_TAG = '' +_ROUTED_NLS = r"""""" + + +_RESOURCE_QUERY = "let r=`path=${encodeURIComponent(i.path)}`;return" +_ROUTED_QUERY = r"""let r=`path=${encodeURIComponent(i.path)}`; +/* studio-session-editor-resource */ +if(typeof window!=="undefined"){ + const routing=new URLSearchParams(window.location.search); + for(const key of ["Authorization","faasInstanceName"]){ + const value=routing.get(key); + if(value!==null)r+="&"+encodeURIComponent(key)+"="+encodeURIComponent(value); + } +} +return""" + + +_ROUTED_FONTS = r"""""" + + +def patch_editor(root: Path) -> None: + template = root / "lib/vscode/out/vs/code/browser/workbench/workbench.html" + source = template.read_text() + if "studio-session-language-resource" not in source: + if source.count(_NLS_TAG) != 1: + raise ValueError("Unsupported editor language-resource template") + template.write_text(source.replace(_NLS_TAG, _ROUTED_NLS)) + refreshed = template.read_text() + fallback = '' + refreshed = refreshed.replace( + fallback, + _ROUTED_NLS.replace( + "studio-session-language-resource", "studio-session-fallback-resource" + ).replace("{{WORKBENCH_NLS_URL}}", "{{WORKBENCH_NLS_FALLBACK_URL}}"), + ) + if "studio-session-workbench-resource" not in refreshed: + pattern = r'' + + def route_workbench(match): + return _ROUTED_NLS.replace( + "studio-session-language-resource", "studio-session-workbench-resource" + ).replace( + "{{WORKBENCH_NLS_URL}}", match.group(1) + "?studio-resource-version=4" + ) + + refreshed = re.sub(pattern, route_workbench, refreshed) + css_tag = '' + css_loader = _ROUTED_NLS.replace( + "studio-session-language-resource", "studio-session-style-resource" + ).replace( + "{{WORKBENCH_NLS_URL}}", + "{{WORKBENCH_WEB_BASE_URL}}/out/vs/code/browser/workbench/workbench.css", + ) + css_loader = css_loader.replace( + "'', content, re.S) + if body and "studio-session-webview-path" in body.group(1): + digest = base64.b64encode( + hashlib.sha256(body.group(1).encode()).digest() + ).decode() + content = re.sub( + r"sha256-[A-Za-z0-9+/=]+", "sha256-" + digest, content, count=1 + ) + webview.write_text(content) + bundle = template.with_name("workbench.js") + script = bundle.read_text() + if "studio-session-editor-resource" not in script: + if script.count(_RESOURCE_QUERY) != 1: + raise ValueError("Unsupported editor resource URL builder") + bundle.write_text(script.replace(_RESOURCE_QUERY, _ROUTED_QUERY)) diff --git a/frontend/sandbox-image/runtime/install-image.py b/frontend/sandbox-image/runtime/install-image.py new file mode 100644 index 000000000..199807ce4 --- /dev/null +++ b/frontend/sandbox-image/runtime/install-image.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Build-time installation only; the original sandbox entrypoint remains intact.""" + +import hashlib +import json +import os +import pwd +import shutil +import subprocess +import tempfile +import tarfile +import zipfile +from pathlib import Path + +from editor_assets import FONTS, apply_fonts, offline_vsix, preserve_editor_settings +from editor_routing import patch_editor + +STUDIO = Path("/opt/studio-sandbox") + + +def run(*command: str, env: dict[str, str] | None = None) -> None: + subprocess.run(command, check=True, env=env) + + +def main() -> None: + if os.geteuid() != 0: + raise RuntimeError("Image setup must run as root during the image build") + try: + account = pwd.getpwnam("gem") + except KeyError: + # The base entrypoint normally creates this account on first startup + run("groupadd", "--gid", "1000", "gem") + run( + "useradd", + "--uid", + "1000", + "--gid", + "1000", + "--shell", + "/bin/bash", + "--no-create-home", + "gem", + ) + account = pwd.getpwnam("gem") + home = Path(account.pw_dir) + binary = Path("/usr/bin/code-server") + base_binary = binary.resolve(strict=True) + code_server = base_binary.parent.parent + if not binary.is_symlink() or base_binary.name != "code-server": + raise RuntimeError( + "Base image changed: /usr/bin/code-server must be the original symlink" + ) + for asset in json.loads((STUDIO / "assets.lock.json").read_text()): + with (STUDIO / "assets" / asset["file"]).open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + if digest != asset["sha256"]: + raise ValueError(f"Asset checksum mismatch: {asset['file']}") + + fonts = STUDIO / "fonts" + fonts.mkdir() + with zipfile.ZipFile(STUDIO / "assets" / "MapleMono-Woff2.zip") as archive: + for filename in (*FONTS, "LICENSE.txt"): + (fonts / filename).write_bytes(archive.read(filename)) + apply_fonts(code_server, fonts) + patch_editor(code_server) + builtin_python = ( + code_server + / "lib/vscode/extensions/python/syntaxes/MagicPython.tmLanguage.json" + ) + if not builtin_python.is_file(): + raise RuntimeError("Base image is missing Python syntax highlighting") + + seed = Path("/opt/gem/vscode/User/settings.json") + defaults = json.loads(seed.read_text()) if seed.exists() else {} + defaults.update(json.loads((STUDIO / "settings.json").read_text())) + seed.parent.mkdir(parents=True, exist_ok=True) + seed.write_text(json.dumps(defaults, ensure_ascii=False, indent=2) + "\n") + preserve_editor_settings(Path("/opt/gem/gem.sh")) + + user_data = home / ".config/code-server/vscode" + user_data.mkdir(parents=True, exist_ok=True) + shutil.copytree(seed.parent.parent, user_data, dirs_exist_ok=True) + extensions = home / ".local/share/code-server/extensions" + extensions.mkdir(parents=True, exist_ok=True) + # Install dependency extensions before dependents, using only pinned local VSIX files + assets = json.loads((STUDIO / "assets.lock.json").read_text()) + for asset in assets: + if asset["file"].endswith(".vsix"): + with tempfile.TemporaryDirectory(prefix="studio-vsix-") as temporary: + local = Path(temporary) / asset["file"] + offline_vsix(STUDIO / "assets" / asset["file"], local) + run( + str(code_server / "lib/node"), + str(code_server / "lib/vscode/out/server-main.js"), + "--user-data-dir", + str(user_data), + "--extensions-dir", + str(extensions), + "--install-extension", + str(local), + "--do-not-include-pack-dependencies", + "--force", + ) + + with tempfile.TemporaryDirectory(prefix="studio-welcome-") as temporary: + welcome = Path(temporary) / "studio-welcome.vsix" + run( + "/opt/agentkit-code-env/venv/bin/python", + str(STUDIO / "runtime/package-welcome.py"), + str(welcome), + ) + run( + str(code_server / "lib/node"), + str(code_server / "lib/vscode/out/server-main.js"), + "--user-data-dir", + str(user_data), + "--extensions-dir", + str(extensions), + "--install-extension", + str(welcome), + "--do-not-include-pack-dependencies", + "--force", + ) + run( + "/opt/agentkit-code-env/venv/bin/python", + str(STUDIO / "runtime/register-language.py"), + ) + + with tempfile.TemporaryDirectory(prefix="studio-blesh-") as temporary: + with tarfile.open(STUDIO / "assets/blesh-0.4.0-devel3.tar.xz") as archive: + archive.extractall(temporary, filter="data") + source = next(Path(temporary).iterdir()) + shutil.copytree(source, STUDIO / "ble/share/blesh", dirs_exist_ok=True) + run( + "/opt/agentkit-code-env/venv/bin/python", + str(STUDIO / "runtime/configure-runtime.py"), + ) + + uv = Path("/opt/agentkit-code-env/venv/bin/uv") + if not uv.is_file(): + raise RuntimeError("Base image is missing uv") + uv_link = Path("/usr/local/bin/uv") + if not uv_link.exists(): + uv_link.symlink_to(uv) + environment = dict(os.environ, UV_CACHE_DIR=str(STUDIO / "uv-cache")) + run( + str(uv), + "venv", + "--python", + "/opt/agentkit-code-env/venv/bin/python", + str(STUDIO / "venv"), + env=environment, + ) + run( + str(uv), + "pip", + "sync", + "--python", + str(STUDIO / "venv/bin/python"), + "--link-mode", + "copy", + str(STUDIO / "requirements.lock"), + env=environment, + ) + run( + str(STUDIO / "venv/bin/python"), + "-c", + "from veadk import Agent; from agentkit.toolkit.cli.cli import app", + ) + for name in ("agentkit", "veadk"): + link = Path("/usr/local/bin") / name + if link.exists() or link.is_symlink(): + expected = Path("/opt/agentkit-code-env/venv/bin") / name + if not link.is_symlink() or link.readlink() != expected: + raise RuntimeError( + f"Base image changed the {name} entrypoint; inspect before replacing it" + ) + link.unlink() + link.symlink_to(STUDIO / "venv/bin" / name) + for name in ("studio-project-create", "studio-vscode-upgrade"): + script = STUDIO / "runtime" / name + script.chmod(0o755) + (Path("/usr/local/bin") / name).symlink_to(script) + wrapper = STUDIO / "runtime/code-server-wrapper" + wrapper.chmod(0o755) + (STUDIO / "base-code-server").symlink_to(base_binary) + binary.unlink() + binary.symlink_to(wrapper) + + with tarfile.open(STUDIO / "assets/codex-0.139.0-linux-arm64.tgz") as archive: + member = archive.getmember( + "package/vendor/aarch64-unknown-linux-musl/bin/codex" + ) + source = archive.extractfile(member) + if source is None: + raise ValueError("Local ARM64 archive is missing the executable") + with source: + native = Path("/usr/local/libexec/codex-arm64-local") + native.write_bytes(source.read()) + native.chmod(0o755) + + (home / "Projects").mkdir(exist_ok=True) + (home / ".local/share/studio-code-server").mkdir(exist_ok=True) + for folder in ( + home / ".config", + home / ".config/code-server", + home / ".local", + home / ".local/share", + home / ".local/share/code-server", + ): + shutil.chown(folder, user=account.pw_uid, group=account.pw_gid) + for folder in ( + user_data, + extensions, + home / "Projects", + home / ".local/share/studio-code-server", + STUDIO / "uv-cache", + ): + shutil.chown(folder, user=account.pw_uid, group=account.pw_gid) + for path in folder.rglob("*"): + if not path.is_symlink(): + shutil.chown(path, user=account.pw_uid, group=account.pw_gid) + # Verify the complete project environment can be recreated without any network access + run( + "runuser", + "-u", + "gem", + "--", + "/usr/local/bin/studio-project-create", + "StudioBuildCheck", + "--json", + ) + check_project = home / "Projects/StudioBuildCheck" + run( + str(check_project / ".venv/bin/python"), + "-c", + "from veadk import Agent; import agentkit", + ) + shutil.rmtree(check_project) + shutil.rmtree(STUDIO / "assets") + + +if __name__ == "__main__": + main() diff --git a/frontend/sandbox-image/runtime/local-arm64-entrypoint b/frontend/sandbox-image/runtime/local-arm64-entrypoint new file mode 100755 index 000000000..1680737d2 --- /dev/null +++ b/frontend/sandbox-image/runtime/local-arm64-entrypoint @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +# Local Apple Silicon preview only; the cloud entrypoint remains /opt/gem/run.sh +export CODEX_REAL_BIN=/usr/local/libexec/codex-arm64-local +for candidate in /opt/fnm/node-versions/*/installation/bin/codex; do + if [ -L "$candidate" ]; then + ln -sfn /usr/local/bin/codex "$candidate" + fi +done +exec /opt/gem/run.sh "$@" diff --git a/frontend/sandbox-image/runtime/package-welcome.py b/frontend/sandbox-image/runtime/package-welcome.py new file mode 100644 index 000000000..7106918d6 --- /dev/null +++ b/frontend/sandbox-image/runtime/package-welcome.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Package the local welcome extension without a registry upload.""" + +import sys +import json +import zipfile +from pathlib import Path + +source = Path(__file__).parent / "studio-welcome" +version = json.loads((source / "package.json").read_text())["version"] +with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive: + for item in source.iterdir(): + if item.is_file(): + archive.write(item, f"extension/{item.name}") + archive.writestr( + "extension.vsixmanifest", + """ + +Studio WelcomeStudio project welcome pageOtherPublic + +""".replace( + "STUDIO_VERSION", version + ), + ) + archive.writestr( + "[Content_Types].xml", + """""", + ) diff --git a/frontend/sandbox-image/runtime/refresh-editor.py b/frontend/sandbox-image/runtime/refresh-editor.py new file mode 100644 index 000000000..0990ccce8 --- /dev/null +++ b/frontend/sandbox-image/runtime/refresh-editor.py @@ -0,0 +1,66 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Update editor assets in an existing Studio image during a Docker build.""" + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +from editor_routing import patch_editor + +studio = Path("/opt/studio-sandbox") +editor = (studio / "base-code-server").resolve(strict=True).parent.parent +patch_editor(editor) +seed = Path("/opt/gem/vscode/User/settings.json") +settings = json.loads(seed.read_text()) +settings.update(json.loads((studio / "settings.json").read_text())) +seed.write_text(json.dumps(settings, ensure_ascii=False, indent=2) + "\n") +user_data = Path("/home/gem/.config/code-server/vscode") +shutil.copyfile(seed, user_data / "User/settings.json") +extensions = Path("/home/gem/.local/share/code-server/extensions") +with tempfile.TemporaryDirectory(prefix="studio-welcome-") as temporary: + package = Path(temporary) / "studio-welcome.vsix" + subprocess.run( + ["python3", str(studio / "runtime/package-welcome.py"), str(package)], + check=True, + ) + subprocess.run( + [ + str(editor / "lib/node"), + str(editor / "lib/vscode/out/server-main.js"), + "--user-data-dir", + str(user_data), + "--extensions-dir", + str(extensions), + "--install-extension", + str(package), + "--do-not-include-pack-dependencies", + "--force", + ], + check=True, + ) +for folder in (user_data, extensions): + for path in (folder, *folder.rglob("*")): + if not path.is_symlink(): + shutil.chown(path, user="gem", group="gem") +for name in ( + "code-server-wrapper", + "studio-project-create", + "studio-vscode-upgrade", + "local-arm64-entrypoint", +): + (studio / "runtime" / name).chmod(0o755) diff --git a/frontend/sandbox-image/runtime/register-language.py b/frontend/sandbox-image/runtime/register-language.py new file mode 100644 index 000000000..57dc1656c --- /dev/null +++ b/frontend/sandbox-image/runtime/register-language.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Seed the native language-pack cache for an offline code-server image.""" + +import hashlib +import json +from pathlib import Path + +extensions = Path("/home/gem/.local/share/code-server/extensions") +target = Path("/home/gem/.config/code-server/vscode/languagepacks.json") +packs = {} +for folder in extensions.glob("ms-ceintl.vscode-language-pack-*"): + manifest = json.loads((folder / "package.json").read_text()) + for locale in manifest.get("contributes", {}).get("localizations", []): + translations = { + item["id"]: str((folder / item["path"]).resolve(strict=True)) + for item in locale["translations"] + } + packs[locale["languageId"]] = { + "hash": hashlib.sha256((folder / "package.json").read_bytes()).hexdigest(), + "extensions": [ + { + "extensionIdentifier": { + "id": f"{manifest['publisher']}.{manifest['name']}".lower() + }, + "version": manifest["version"], + } + ], + "translations": translations, + "label": locale.get("localizedLanguageName", locale["languageName"]), + } +target.parent.mkdir(parents=True, exist_ok=True) +target.write_text(json.dumps(packs, ensure_ascii=False, indent=2) + "\n") diff --git a/frontend/sandbox-image/runtime/studio-project-create b/frontend/sandbox-image/runtime/studio-project-create new file mode 100755 index 000000000..af087ba3c --- /dev/null +++ b/frontend/sandbox-image/runtime/studio-project-create @@ -0,0 +1,191 @@ +#!/opt/studio-sandbox/venv/bin/python +"""Create an independent Agent project using the image's pre-warmed uv cache.""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + + +MAX_TEMPLATE_BYTES = 1_048_576 + + +def validate_template(value: object) -> dict[str, str]: + """Accept only bounded UTF-8 files inside a fresh project directory.""" + if not isinstance(value, dict) or value.get("version") != 1: + raise ValueError("Template version must be 1") + files = value.get("files") + if not isinstance(files, dict) or not files or len(files) > 256: + raise ValueError("Template must contain between 1 and 256 files") + size = 0 + for name, content in files.items(): + if not isinstance(name, str) or not isinstance(content, str): + raise ValueError("Template files must map paths to text") + path = PurePosixPath(name) + if ( + not name + or path.is_absolute() + or "\\" in name + or "\0" in name + or any(part in {"", ".", "..", ".git", ".venv"} for part in name.split("/")) + ): + raise ValueError("Invalid template path") + size += len(name.encode("utf-8")) + len(content.encode("utf-8")) + if size > MAX_TEMPLATE_BYTES: + raise ValueError("Template exceeds 1 MiB") + if any(str(parent) in files for parent in path.parents if str(parent) != "."): + raise ValueError("Template file conflicts with directory") + return files + + +def initialize_repository(project: Path) -> None: + files = { + ".gitignore": ".venv/\n.env\n.env.*\n!.env.example\n__pycache__/\n*.py[cod]\n.pytest_cache/\n.ruff_cache/\n.DS_Store\n", + "AGENTS.md": "# Python development guide\n\n" + "- Use the project .venv and keep dependencies reproducible\n" + "- Follow PEP 8, use descriptive snake_case names and type hints for public functions\n" + "- Keep functions focused, handle exceptions explicitly and use logging for diagnostics\n" + "- Keep credentials in environment variables, never in source control\n" + "- Use Ruff for formatting and linting, and pytest for affected behavior\n\n" + "## Project directories\n\n" + "- main.py: Agent entry point\n" + "- .venv/: project Python environment\n" + "- tests/: add focused tests when needed\n\n" + "## Documentation\n\n" + "- VeADK: https://volcengine.github.io/veadk-python/\n" + "- VeADK source and examples: https://github.com/volcengine/veadk-python\n" + "- AgentKit (Volcengine): https://www.volcengine.com/docs/86681\n" + "- AgentKit (BytePlus): https://docs.byteplus.com/en/docs/agentkit\n", + "README.md": f"# {project.name}\n\nVeADK agent project\n\n" + "Activate the environment with `source .venv/bin/activate`\n" + "Configure the required model credentials using environment variables\n", + } + for name, content in files.items(): + target = project / name + if not target.exists(): + target.write_text(content) + if not (project / ".git").exists(): + subprocess.run( + ["git", "init", "--initial-branch=main", str(project)], + check=True, + stdout=sys.stderr, + stderr=sys.stderr, + ) + + +def create_project( + name: str, + projects: Path, + studio: Path, + uv: str = "/opt/agentkit-code-env/venv/bin/uv", + template: object = None, +) -> Path: + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", name): + raise ValueError( + "项目名须以英文字母开头,仅含字母、数字、短横线或下划线,最多 64 个字符" + ) + files = validate_template(template) if template is not None else None + root = projects.resolve(strict=True) + project = root / name + project.mkdir(mode=0o755) + python = project / ".venv" / "bin" / "python" + environment = dict(os.environ, UV_CACHE_DIR=str(studio / "uv-cache")) + subprocess.run( + [ + uv, + "venv", + "--offline", + "--python", + str(studio / "venv" / "bin" / "python"), + str(project / ".venv"), + ], + env=environment, + check=True, + stdout=sys.stderr, + stderr=sys.stderr, + ) + subprocess.run( + [ + uv, + "pip", + "sync", + "--offline", + "--python", + str(python), + "--link-mode", + "copy", + str(studio / "requirements.lock"), + ], + env=environment, + check=True, + stdout=sys.stderr, + stderr=sys.stderr, + ) + agent_name = name.replace("-", "_") + if files is None: + files = { + "main.py": ( + "from veadk import Agent\n\n" + "agent = Agent(\n" + f' name="{agent_name}",\n' + ' instruction="You are a helpful assistant",\n' + ")\n" + ) + } + for relative, content in files.items(): + target = project / relative + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("x", encoding="utf-8") as output: + output.write(content) + initialize_repository(project) + return project + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("name") + parser.add_argument( + "--json", action="store_true", help="Return the created project path as JSON" + ) + parser.add_argument( + "--template-stdin", + action="store_true", + help="Read a version 1 JSON template project from stdin", + ) + args = parser.parse_args() + root = Path(os.environ.get("STUDIO_PROJECTS_DIR", "/home/gem/Projects")) + studio = Path(os.environ.get("STUDIO_SANDBOX_DIR", "/opt/studio-sandbox")) + try: + template = None + if args.template_stdin: + raw = sys.stdin.buffer.read(MAX_TEMPLATE_BYTES + 1) + if len(raw) > MAX_TEMPLATE_BYTES: + raise ValueError("Template exceeds 1 MiB") + template = json.loads(raw) + validate_template(template) + path = create_project(args.name, root, studio, template=template) + except FileExistsError: + print("项目目录已存在,未覆盖任何文件,请使用其他项目名", file=sys.stderr) + return 2 + except ValueError as error: + print(error, file=sys.stderr) + return 2 + except (OSError, subprocess.CalledProcessError): + print( + "项目初始化失败,已保留当前目录供检查,请检查镜像依赖缓存和目录权限", + file=sys.stderr, + ) + return 1 + print( + json.dumps({"name": args.name, "path": str(path)}, ensure_ascii=False) + if args.json + else path + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/sandbox-image/runtime/studio-vscode-upgrade b/frontend/sandbox-image/runtime/studio-vscode-upgrade new file mode 100755 index 000000000..820796cda --- /dev/null +++ b/frontend/sandbox-image/runtime/studio-vscode-upgrade @@ -0,0 +1,258 @@ +#!/opt/studio-sandbox/venv/bin/python +"""Check or install a code-server release without restarting the running editor.""" + +import argparse +import fcntl +import hashlib +import json +import os +import platform +import re +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlsplit + +from editor_assets import apply_fonts +from editor_routing import patch_editor + +STUDIO = Path("/opt/studio-sandbox") + + +def https_url(url: str) -> str: + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + ): + raise ValueError("下载地址必须使用 HTTPS,不能携带账号密码") + return url + + +def get_release( + version: str | None, api: str, architecture: str +) -> tuple[str, str, str]: + suffix = f"tags/v{version}" if version else "latest" + request = urllib.request.Request( + https_url(api.rstrip("/") + "/" + suffix), + headers={"Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + data = json.load(response) + actual = str(data["tag_name"]).removeprefix("v") + if ( + not re.fullmatch(r"\d+\.\d+\.\d+", actual) + or data.get("prerelease") + or data.get("draft") + ): + raise ValueError("升级源未返回正式稳定版本") + filename = f"code-server-{actual}-linux-{architecture}.tar.gz" + for asset in data["assets"]: + if asset["name"] == filename: + digest = str(asset.get("digest", "")) + if not re.fullmatch(r"sha256:[a-f0-9]{64}", digest): + raise ValueError( + "该版本没有官方 SHA256,请使用 --archive-url 和 --sha256 指定已校验安装包" + ) + return ( + actual, + https_url(asset["browser_download_url"]), + digest.split(":", 1)[1], + ) + raise ValueError("该版本没有适配当前架构的安装包") + + +def activate(home: Path, target: Path) -> None: + current = home / "current" + if current.exists(): + previous = home / "previous.next" + if previous.is_symlink(): + previous.unlink() + previous.symlink_to(current.resolve()) + previous.replace(home / "previous") + pending = home / "current.next" + if pending.is_symlink(): + pending.unlink() + pending.symlink_to(target) + pending.replace(current) + + +def confirm(yes: bool) -> None: + print("请先保存编辑器中尚未保存的文件;本命令不会重启编辑器或修改项目和设置") + if not yes: + if ( + not sys.stdin.isatty() + or input("已保存并继续?[y/N] ").strip().lower() != "y" + ): + raise ValueError("已取消,可在保存文件后使用 --yes 确认升级") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + choice = parser.add_mutually_exclusive_group() + choice.add_argument( + "--check", + action="store_true", + help="Check the stable release without installing", + ) + choice.add_argument( + "--latest", action="store_true", help="Install the latest stable release" + ) + choice.add_argument( + "--version", help="Install a specific stable version, for example 4.104.0" + ) + choice.add_argument( + "--rollback", + action="store_true", + help="Select the previous installation for the next editor restart", + ) + parser.add_argument( + "--archive-url", help="Use a trusted HTTPS mirror or private object-storage URL" + ) + parser.add_argument("--sha256", help="Expected SHA256, required with --archive-url") + parser.add_argument( + "--yes", action="store_true", help="Confirm that unsaved files have been saved" + ) + parser.add_argument( + "--release-api", + default=os.environ.get( + "STUDIO_CODE_SERVER_RELEASE_API", + "https://api.github.com/repos/coder/code-server/releases", + ), + ) + args = parser.parse_args() + if args.version and not re.fullmatch(r"\d+\.\d+\.\d+", args.version): + parser.error("版本号格式须为 x.y.z") + if args.archive_url and ( + not args.version or not re.fullmatch(r"[a-fA-F0-9]{64}", args.sha256 or "") + ): + parser.error("镜像下载地址必须同时指定 --version 和 --sha256") + if args.sha256 and not args.archive_url: + parser.error("--sha256 必须与 --archive-url 一起使用") + home = Path( + os.environ.get( + "STUDIO_CODE_SERVER_HOME", "/home/gem/.local/share/studio-code-server" + ) + ) + try: + if args.rollback: + confirm(args.yes) + if not (home / "previous").exists(): + raise ValueError("没有可回退的版本,当前编辑器未更改") + activate(home, (home / "previous").resolve()) + print("已选择上一版本,保存所有文件后手动重启编辑器生效") + return 0 + architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get(platform.machine()) + if architecture is None or platform.system() != "Linux": + raise ValueError("升级命令仅支持 Linux amd64/arm64") + if args.archive_url: + version, url, digest = ( + args.version, + https_url(args.archive_url), + args.sha256.lower(), + ) + else: + version, url, digest = get_release( + args.version, args.release_api, architecture + ) + current = subprocess.run( + ["/usr/bin/code-server", "--version"], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines()[0] + print(f"当前版本 {current}\n目标稳定版本 {version}") + if args.check or not (args.latest or args.version): + print( + "仅检查,没有下载或更改;国内下载可指定 --version、--archive-url 和 --sha256" + ) + return 0 + confirm(args.yes) + home.mkdir(parents=True, exist_ok=True) + with (home / "upgrade.lock").open("w") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + releases = home / "releases" + releases.mkdir(exist_ok=True) + release_name = f"code-server-{version}-linux-{architecture}" + target = releases / release_name + if target.exists(): + raise ValueError("目标版本目录已存在,未覆盖已有安装") + with tempfile.TemporaryDirectory(prefix=".upgrade-", dir=home) as temporary: + archive_path = Path(temporary) / "release.tar.gz" + # curl gives bounded, resumable transport; signed mirror URLs are never printed + subprocess.run( + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--location", + "--retry", + "2", + "--connect-timeout", + "15", + "--max-time", + "600", + "--output", + str(archive_path), + url, + ], + check=True, + ) + with archive_path.open("rb") as stream: + actual = hashlib.file_digest(stream, "sha256").hexdigest() + if actual != digest: + raise ValueError("安装包校验失败,当前编辑器未更改") + with tarfile.open(archive_path, "r:gz") as archive: + if any( + not member.name.startswith(release_name + "/") + and member.name != release_name + for member in archive.getmembers() + ): + raise ValueError("安装包目录结构不符合预期") + archive.extractall(temporary, filter="data") + staged = Path(temporary) / release_name + apply_fonts(staged, STUDIO / "fonts") + patch_editor(staged) + subprocess.run( + [str(staged / "bin/code-server"), "--version"], + check=True, + stdout=subprocess.DEVNULL, + ) + staged.rename(target) + if not (home / "current").exists(): + (home / "current").symlink_to( + (STUDIO / "base-code-server").resolve().parent.parent + ) + activate(home, target) + print("新版本已准备好,保存所有文件后手动重启编辑器生效;当前会话不会自动重启") + print( + "用户设置、插件、Projects 和 .venv 保持不变;新 Sandbox 会话仍使用镜像中的默认版本" + ) + return 0 + except ( + ValueError, + OSError, + KeyError, + tarfile.TarError, + subprocess.CalledProcessError, + urllib.error.URLError, + ) as error: + if isinstance(error, ValueError): + print(error, file=sys.stderr) + else: + print( + "升级未完成,请检查下载源、网络、目录权限或是否已有升级任务;当前编辑器不会被重启", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/sandbox-image/runtime/studio-welcome/extension.js b/frontend/sandbox-image/runtime/studio-welcome/extension.js new file mode 100644 index 000000000..f56ea1c77 --- /dev/null +++ b/frontend/sandbox-image/runtime/studio-welcome/extension.js @@ -0,0 +1,37 @@ +const vscode = require('vscode'); +const crypto = require('node:crypto'); +const {initializeTerminals, openTerminals} = require('./terminals'); +const zh = !/^en(?:-|$)/i.test(process.env.VSCODE_LANG || 'zh-CN'); +const t = (cn, en) => zh ? cn : en; +let panel; +function show() { + if (panel) { panel.reveal(); return; } + panel = vscode.window.createWebviewPanel('studio.welcome', t('欢迎使用 AgentKit Studio','Welcome to AgentKit Studio'), vscode.ViewColumn.One, {retainContextWhenHidden:true}); + panel.onDidDispose(() => { panel = undefined; }); + const nonce = crypto.randomBytes(18).toString('base64'); + const docs = zh ? 'https://www.volcengine.com/docs/86681' : 'https://docs.byteplus.com/en/docs/agentkit'; + panel.webview.html = `

${t('欢迎使用 AgentKit Studio','Welcome to AgentKit Studio')}

+

${t('常用链接','Useful links')}

+

VeADK

${t('VeADK 是面向 AI Agent 开发的开源 Python 框架,提供模型调用、工具集成、记忆管理与工作流编排等能力,帮助你从几行代码开始构建 Agent,并逐步完成调试、评估和部署。','VeADK is an open-source Python framework for building AI Agents. It brings together models, tools, memory and workflows, helping you move from a few lines of code to debugging, evaluation and deployment.')}

+

AgentKit

${t('AgentKit 是火山引擎面向 AI Agent 的云平台,为 Agent 提供运行环境、开发工具和配套云服务,帮助团队将本地开发的 Agent 部署到云端,并持续运行和管理。','AgentKit is BytePlus’s cloud platform for AI Agents. It provides runtime environments, development tools and supporting cloud services to help teams deploy, run and manage Agents in the cloud.')}

+
`; +} +async function activate(context) { + await initializeTerminals(vscode, context.workspaceState); + context.subscriptions.push(vscode.commands.registerCommand('studio.openTerminals', () => openTerminals(vscode))); + context.subscriptions.push(vscode.commands.registerCommand('studio.welcome',show)); + if(vscode.workspace.getConfiguration('studio').get('showWelcomeOnStartup',true)) { + const oldWelcomeTabs = vscode.window.tabGroups.all.flatMap(group => group.tabs) + .filter(tab => tab.input instanceof vscode.TabInputWebview && tab.input.viewType.endsWith('studio.welcome')); + if (oldWelcomeTabs.length) await vscode.window.tabGroups.close(oldWelcomeTabs, true); + show(); + } +} +module.exports = {activate}; diff --git a/frontend/sandbox-image/runtime/studio-welcome/package.json b/frontend/sandbox-image/runtime/studio-welcome/package.json new file mode 100644 index 000000000..190464951 --- /dev/null +++ b/frontend/sandbox-image/runtime/studio-welcome/package.json @@ -0,0 +1,39 @@ +{ + "name": "studio-welcome", + "displayName": "Studio Welcome", + "description": "Start and manage Agent projects in Studio", + "version": "0.1.3", + "publisher": "studio-tools", + "engines": { + "vscode": "^1.95.0" + }, + "main": "./extension.js", + "activationEvents": [ + "onStartupFinished" + ], + "extensionKind": [ + "workspace" + ], + "contributes": { + "commands": [ + { + "command": "studio.openTerminals", + "title": "Studio: Open Bash and Codex / 打开终端" + }, + { + "command": "studio.welcome", + "title": "Studio: Welcome / 欢迎页" + } + ], + "configuration": { + "title": "Studio", + "properties": { + "studio.showWelcomeOnStartup": { + "type": "boolean", + "default": true, + "description": "Show Studio welcome page when opening the editor" + } + } + } + } +} diff --git a/frontend/sandbox-image/runtime/studio-welcome/terminals.js b/frontend/sandbox-image/runtime/studio-welcome/terminals.js new file mode 100644 index 000000000..629a3929e --- /dev/null +++ b/frontend/sandbox-image/runtime/studio-welcome/terminals.js @@ -0,0 +1,27 @@ +const initializedKey = 'studio.terminalsInitialized'; + +function openTerminals(vscode) { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder || folder.uri.scheme !== 'file') return false; + const cwd = folder.uri.fsPath; + const definitions = [ + {name: 'Bash', shellPath: '/bin/bash', shellArgs: []}, + {name: 'Codex', shellPath: '/usr/local/bin/codex', shellArgs: ['--cd', cwd]} + ]; + for (const definition of definitions) { + if (!vscode.window.terminals.some(terminal => terminal.name === definition.name)) { + const terminal = vscode.window.createTerminal({...definition, cwd}); + if (definition.name === 'Bash') terminal.show(true); + } + } + return true; +} + +async function initializeTerminals(vscode, state) { + // Restored terminals can arrive after activation. A persisted first-open marker + // prevents a reload from racing restoration or reopening terminals the user closed. + if (state.get(initializedKey, false)) return; + if (openTerminals(vscode)) await state.update(initializedKey, true); +} + +module.exports = {initializeTerminals, openTerminals}; diff --git a/frontend/sandbox-image/settings.json b/frontend/sandbox-image/settings.json new file mode 100644 index 000000000..aee19efd4 --- /dev/null +++ b/frontend/sandbox-image/settings.json @@ -0,0 +1,47 @@ +{ + "workbench.colorTheme": "Default Dark Modern", + "workbench.preferredDarkColorTheme": "Default Dark Modern", + "window.autoDetectColorScheme": false, + "workbench.startupEditor": "none", + "workbench.secondarySideBar.defaultVisibility": "hidden", + "editor.fontFamily": "'Maple Mono', monospace", + "editor.fontSize": 15, + "window.menuBarVisibility": "visible", + "window.titleBarStyle": "custom", + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.profiles.linux": { + "bash": { + "path": "/bin/bash" + } + }, + "editor.fontLigatures": true, + "editor.semanticHighlighting.enabled": true, + "terminal.integrated.fontFamily": "'Maple Mono', monospace", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.useEnvironmentsExtension": false, + "python.languageServer": "None", + "basedpyright.analysis.autoSearchPaths": true, + "basedpyright.analysis.typeCheckingMode": "standard", + "files.associations": { + "*.py": "python", + "*.mdx": "mdx" + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + }, + "extensions.autoUpdate": false, + "extensions.autoCheckUpdates": false, + "files.watcherExclude": { + "**/.venv/**": true, + "**/__pycache__/**": true, + "**/node_modules/**": true, + "/opt/python3.12/**": true, + "/opt/studio-sandbox/venv/**": true, + "/opt/agentkit-code-env/venv/**": true + }, + "files.exclude": { + "**/.git": false + } +} diff --git a/frontend/sandbox-image/tests/test_routing.py b/frontend/sandbox-image/tests/test_routing.py new file mode 100644 index 000000000..89724581e --- /dev/null +++ b/frontend/sandbox-image/tests/test_routing.py @@ -0,0 +1,82 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import hashlib +import re +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime")) +from editor_routing import patch_editor + + +class EditorRoutingTest(unittest.TestCase): + def test_cloud_resources_and_webview_keep_routing_after_repeated_patch(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workbench = root / "lib/vscode/out/vs/code/browser/workbench" + workbench.mkdir(parents=True) + template = workbench / "workbench.html" + template.write_text(""" + + + + +""") + bundle = workbench / "workbench.js" + bundle.write_text( + "let r=`path=${encodeURIComponent(i.path)}`;return result" + ) + webview = ( + root + / "lib/vscode/out/vs/workbench/contrib/webview/browser/pre/index.html" + ) + webview.parent.mkdir(parents=True) + webview.write_text(""" +""") + patch_editor(root) + first = [path.read_text() for path in (template, bundle, webview)] + patch_editor(root) + self.assertEqual( + first, [path.read_text() for path in (template, bundle, webview)] + ) + for resource in ("language", "fallback", "workbench", "style", "font"): + self.assertIn(f"studio-session-{resource}-resource", first[0]) + self.assertIn("routing.get(key)", first[1]) + self.assertIn("swPath = workerUrl.href", first[2]) + match = re.search( + r'', first[2], re.S + ) + assert match is not None + body = match.group(1) + digest = base64.b64encode(hashlib.sha256(body.encode()).digest()).decode() + self.assertIn(f"sha256-{digest}", first[2]) + + def test_unknown_editor_layout_is_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + template = root / "lib/vscode/out/vs/code/browser/workbench/workbench.html" + template.parent.mkdir(parents=True) + template.write_text("unknown release") + with self.assertRaises(ValueError): + patch_editor(root) + self.assertEqual(template.read_text(), "unknown release") + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/sandbox-image/tests/test_runtime.py b/frontend/sandbox-image/tests/test_runtime.py new file mode 100644 index 000000000..a590649c9 --- /dev/null +++ b/frontend/sandbox-image/tests/test_runtime.py @@ -0,0 +1,183 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import runpy +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SOURCE / "runtime")) + +from editor_assets import ( # noqa: E402 + FONT_MARKER, + FONTS, + WORKBENCH, + apply_fonts, + offline_vsix, + preserve_editor_settings, +) + +create_project = runpy.run_path(str(SOURCE / "runtime/studio-project-create"))[ + "create_project" +] +upgrade = runpy.run_path(str(SOURCE / "runtime/studio-vscode-upgrade")) + + +class StudioImageTest(unittest.TestCase): + def test_offline_vsix_does_not_pull_optional_extension_packs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, target = ( + Path(temporary) / "source.vsix", + Path(temporary) / "local.vsix", + ) + with zipfile.ZipFile(source, "w") as archive: + archive.writestr( + "extension/package.json", + json.dumps( + { + "extensionPack": ["ms-python.vscode-pylance"], + "extensionDependencies": ["required.extension"], + } + ), + ) + archive.writestr("extension/code.js", "original code") + archive.writestr( + "extension.vsixmanifest", + '', + ) + offline_vsix(source, target) + with zipfile.ZipFile(target) as archive: + manifest = json.loads(archive.read("extension/package.json")) + self.assertEqual(manifest["extensionPack"], []) + self.assertEqual( + manifest["extensionDependencies"], ["required.extension"] + ) + self.assertEqual(archive.read("extension/code.js"), b"original code") + self.assertNotIn( + b"vscode-pylance", archive.read("extension.vsixmanifest") + ) + + def test_font_patch_is_local_and_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fonts = root / "fonts" + fonts.mkdir() + for filename in (*FONTS, "LICENSE.txt"): + (fonts / filename).write_text("fixture") + css = root / "code" / WORKBENCH / "workbench.css" + css.parent.mkdir(parents=True) + css.write_text(".editor { color: inherit; }\n") + apply_fonts(root / "code", fonts) + apply_fonts(root / "code", fonts) + content = css.read_text() + self.assertEqual(content.count(FONT_MARKER), 1) + self.assertEqual(content.count("@font-face"), 4) + self.assertNotIn("https://", content) + self.assertIn("font-display:swap", content) + + def test_settings_seed_preserves_existing_user_config(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + script = Path(temporary) / "gem.sh" + script.write_text( + "before\ncp -rf /opt/gem/vscode /home/$USER/.config/code-server/vscode\nafter\n" + ) + preserve_editor_settings(script) + once = script.read_text() + preserve_editor_settings(script) + self.assertEqual(script.read_text(), once) + self.assertIn( + 'if [ ! -d "/home/$USER/.config/code-server/vscode" ]; then', once + ) + self.assertTrue(once.startswith("before\n")) + self.assertTrue(once.endswith("after\n")) + + def test_unexpected_base_script_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + script = Path(temporary) / "gem.sh" + script.write_text("changed upstream script") + with self.assertRaises(ValueError): + preserve_editor_settings(script) + self.assertEqual(script.read_text(), "changed upstream script") + + def test_project_creation_seeds_git_project_offline(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + + def simulate_uv(command: list[str], **_: object) -> None: + if command[1] == "venv": + (Path(command[-1]) / "bin").mkdir(parents=True) + + with patch("subprocess.run", side_effect=simulate_uv) as run: + project = create_project("my-agent", root, root / "studio") + self.assertEqual( + {path.name for path in project.iterdir()}, + {"main.py", ".venv", ".gitignore", "AGENTS.md", "README.md"}, + ) + self.assertIn('name="my_agent"', (project / "main.py").read_text()) + for call in run.call_args_list[:2]: + self.assertIn("--offline", call.args[0]) + self.assertIn("copy", run.call_args_list[1].args[0]) + + def test_project_rejects_traversal_and_does_not_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name in ("../escape", "/tmp/escape", "a/b", ".", "-agent", "a\nagent"): + with self.assertRaises(ValueError): + create_project(name, root, root / "studio") + existing = root / "existing" + existing.mkdir() + (existing / "main.py").write_text("keep me") + with self.assertRaises(FileExistsError): + create_project("existing", root, root / "studio") + self.assertEqual((existing / "main.py").read_text(), "keep me") + + def test_upgrade_switch_keeps_previous_version(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + first, second = root / "first", root / "second" + first.mkdir() + second.mkdir() + (root / "current").symlink_to(first) + upgrade["activate"](root, second) + self.assertEqual((root / "current").resolve(), second.resolve()) + self.assertEqual((root / "previous").resolve(), first.resolve()) + + def test_upgrade_rejects_insecure_download_urls(self) -> None: + for url in ( + "http://example.com/archive", + "file:///tmp/archive", + "https://user:password@example.com/archive", + ): + with self.assertRaises(ValueError): + upgrade["https_url"](url) + + def test_editor_defaults_cover_python_and_only_editor_fonts(self) -> None: + settings = json.loads((SOURCE / "settings.json").read_text()) + self.assertEqual(settings["files.associations"]["*.py"], "python") + self.assertEqual( + settings["python.defaultInterpreterPath"], + "${workspaceFolder}/.venv/bin/python", + ) + self.assertEqual(settings["workbench.colorTheme"], "Default Dark Modern") + self.assertEqual(settings["editor.fontFamily"], "'Maple Mono', monospace") + self.assertNotIn("window.fontFamily", settings) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/sandbox-image/tests/test_terminals.cjs b/frontend/sandbox-image/tests/test_terminals.cjs new file mode 100644 index 000000000..5d6263a41 --- /dev/null +++ b/frontend/sandbox-image/tests/test_terminals.cjs @@ -0,0 +1,54 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const {initializeTerminals} = require('../runtime/studio-welcome/terminals'); + +function fixture(state = new Map(), terminals = []) { + const created = []; + return { + created, + state: {get: (key, fallback) => state.get(key) ?? fallback, update: async (key, value) => state.set(key, value)}, + vscode: { + workspace: {workspaceFolders: [{uri: {scheme: 'file', fsPath: '/home/gem/Projects/demo'}}]}, + window: {terminals, createTerminal: options => { + created.push(options); + terminals.push(options); + return {show() {}}; + }} + } + }; +} + +test('first open creates Bash and Codex in the project', async () => { + const f = fixture(); + await initializeTerminals(f.vscode, f.state); + assert.deepEqual(f.created.map(t => t.name), ['Bash', 'Codex']); + assert.ok(f.created.every(t => t.cwd === '/home/gem/Projects/demo')); + assert.deepEqual(f.created[1].shellArgs, ['--cd', '/home/gem/Projects/demo']); +}); + +test('reload before terminal restoration does not create another pair', async () => { + const state = new Map(); + const first = fixture(state); + await initializeTerminals(first.vscode, first.state); + const reload = fixture(state); + await initializeTerminals(reload.vscode, reload.state); + assert.equal(reload.created.length, 0); + reload.vscode.window.terminals.push(...first.created); + assert.equal(reload.vscode.window.terminals.length, 2); +}); + +test('first activation reuses existing terminals and does not close user terminals', async () => { + const terminals = [{name: 'Bash'}, {name: 'my server'}]; + const f = fixture(new Map(), terminals); + await initializeTerminals(f.vscode, f.state); + assert.deepEqual(f.created.map(t => t.name), ['Codex']); + assert.equal(terminals[1].name, 'my server'); +}); + +test('empty window does not mark a project initialized', async () => { + const f = fixture(); + f.vscode.workspace.workspaceFolders = []; + await initializeTerminals(f.vscode, f.state); + assert.equal(f.created.length, 0); + assert.equal(f.state.get('studio.terminalsInitialized', false), false); +}); diff --git a/frontend/sandbox-integration/Dockerfile b/frontend/sandbox-integration/Dockerfile new file mode 100644 index 000000000..8bd9b0f50 --- /dev/null +++ b/frontend/sandbox-integration/Dockerfile @@ -0,0 +1,7 @@ +ARG STUDIO_SANDBOX_IMAGE +FROM ${STUDIO_SANDBOX_IMAGE} +USER root +# Preserve executable entrypoints after the final runtime source copy +RUN chmod 755 /opt/studio-sandbox/runtime/code-server-wrapper \ + /opt/studio-sandbox/runtime/studio-project-create \ + /opt/studio-sandbox/runtime/studio-vscode-upgrade diff --git a/frontend/server/intelligent_development_routes.py b/frontend/server/intelligent_development_routes.py index ace60db0f..6037bdddb 100644 --- a/frontend/server/intelligent_development_routes.py +++ b/frontend/server/intelligent_development_routes.py @@ -838,6 +838,15 @@ def mount_intelligent_development_routes( ) -> None: """Mount the Codex-gated SANDBOX_DEV surface.""" + from frontend.server.workspace_preview import mount_workspace_preview_routes + + mount_workspace_preview_routes( + app, + service._gateway, + owner_resolver, + creator_resolver, + ) + delegated = FastAPI() task_locks: dict[tuple[str, str], asyncio.Lock] = {} task_locks_guard = asyncio.Lock() diff --git a/frontend/server/studio_update_resources.py b/frontend/server/studio_update_resources.py index abe3ae47f..fb54e4593 100644 --- a/frontend/server/studio_update_resources.py +++ b/frontend/server/studio_update_resources.py @@ -142,6 +142,19 @@ def reconcile_studio_update_resources( function, environment = _function_state(function_client, function_id) overrides: dict[str, str] = {} + from frontend.server.workspace_tool import workspace_update_environment + + overrides.update( + workspace_update_environment( + environment, + provider=provider, + region=region, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + ) + ) + from veadk.cli.studio_knowledge_signing import ( STUDIO_KNOWLEDGE_SIGNING_KEY_ENV, resolve_studio_knowledge_signing_key, diff --git a/frontend/server/templates/python-agent/.gitignore.tmpl b/frontend/server/templates/python-agent/.gitignore.tmpl new file mode 100644 index 000000000..46384ca5c --- /dev/null +++ b/frontend/server/templates/python-agent/.gitignore.tmpl @@ -0,0 +1,9 @@ +.venv/ +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.DS_Store diff --git a/frontend/server/templates/python-agent/AGENTS.md.tmpl b/frontend/server/templates/python-agent/AGENTS.md.tmpl new file mode 100644 index 000000000..22ef62d6d --- /dev/null +++ b/frontend/server/templates/python-agent/AGENTS.md.tmpl @@ -0,0 +1,20 @@ +# Python development guide + +- Use the project .venv and keep dependencies reproducible +- Follow PEP 8, use descriptive snake_case names and type hints for public functions +- Keep functions focused, handle exceptions explicitly and use logging for diagnostics +- Keep credentials in environment variables, never in source control +- Use Ruff for formatting and linting, and pytest for affected behavior + +## Project directories + +- main.py: Agent entry point +- .venv/: project Python environment +- tests/: add focused tests when needed + +## Documentation + +- VeADK: https://volcengine.github.io/veadk-python/ +- VeADK source and examples: https://github.com/volcengine/veadk-python +- AgentKit (Volcengine): https://www.volcengine.com/docs/86681 +- AgentKit (BytePlus): https://docs.byteplus.com/en/docs/agentkit diff --git a/frontend/server/templates/python-agent/README.md.tmpl b/frontend/server/templates/python-agent/README.md.tmpl new file mode 100644 index 000000000..305304b94 --- /dev/null +++ b/frontend/server/templates/python-agent/README.md.tmpl @@ -0,0 +1,6 @@ +# ${project_name} + +VeADK agent project + +Activate the environment with `source .venv/bin/activate` +Configure the required model credentials using environment variables diff --git a/frontend/server/templates/python-agent/main.py.tmpl b/frontend/server/templates/python-agent/main.py.tmpl new file mode 100644 index 000000000..dc5f22ab5 --- /dev/null +++ b/frontend/server/templates/python-agent/main.py.tmpl @@ -0,0 +1,6 @@ +from veadk import Agent + +agent = Agent( + name="${agent_name}", + instruction="You are a helpful assistant", +) diff --git a/frontend/server/workspace_editor.py b/frontend/server/workspace_editor.py new file mode 100644 index 000000000..f03d6e0d4 --- /dev/null +++ b/frontend/server/workspace_editor.py @@ -0,0 +1,167 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Keep language-pack requests on the same authenticated cloud session.""" + +import base64 +import inspect +import hashlib +import re +import shlex +import zlib +from pathlib import Path + +_NLS_TAG = '' +_ROUTED_NLS = r"""""" + + +_RESOURCE_QUERY = "let r=`path=${encodeURIComponent(i.path)}`;return" +_ROUTED_QUERY = r"""let r=`path=${encodeURIComponent(i.path)}`; +/* studio-session-editor-resource */ +if(typeof window!=="undefined"){ + const routing=new URLSearchParams(window.location.search); + for(const key of ["Authorization","faasInstanceName"]){ + const value=routing.get(key); + if(value!==null)r+="&"+encodeURIComponent(key)+"="+encodeURIComponent(value); + } +} +return""" + + +def patch_editor(root: Path) -> None: + template = root / "lib/vscode/out/vs/code/browser/workbench/workbench.html" + source = template.read_text() + if "studio-session-language-resource" not in source: + if source.count(_NLS_TAG) != 1: + raise ValueError("Unsupported editor language-resource template") + template.write_text(source.replace(_NLS_TAG, _ROUTED_NLS)) + refreshed = template.read_text() + fallback = '' + refreshed = refreshed.replace( + fallback, + _ROUTED_NLS.replace( + "studio-session-language-resource", "studio-session-fallback-resource" + ).replace("{{WORKBENCH_NLS_URL}}", "{{WORKBENCH_NLS_FALLBACK_URL}}"), + ) + if "studio-session-workbench-resource" not in refreshed: + pattern = r'' + + def route_workbench(match): + return _ROUTED_NLS.replace( + "studio-session-language-resource", "studio-session-workbench-resource" + ).replace( + "{{WORKBENCH_NLS_URL}}", match.group(1) + "?studio-resource-version=3" + ) + + refreshed = re.sub(pattern, route_workbench, refreshed) + css_tag = '' + css_loader = _ROUTED_NLS.replace( + "studio-session-language-resource", "studio-session-style-resource" + ).replace( + "{{WORKBENCH_NLS_URL}}", + "{{WORKBENCH_WEB_BASE_URL}}/out/vs/code/browser/workbench/workbench.css", + ) + css_loader = css_loader.replace( + "'', content, re.S) + if body and "studio-session-webview-path" in body.group(1): + digest = base64.b64encode( + hashlib.sha256(body.group(1).encode()).digest() + ).decode() + content = re.sub( + r"sha256-[A-Za-z0-9+/=]+", "sha256-" + digest, content, count=1 + ) + webview.write_text(content) + bundle = template.with_name("workbench.js") + script = bundle.read_text() + if "studio-session-editor-resource" not in script: + if script.count(_RESOURCE_QUERY) != 1: + raise ValueError("Unsupported editor resource URL builder") + bundle.write_text(script.replace(_RESOURCE_QUERY, _ROUTED_QUERY)) + + +def bootstrap_source() -> str: + """Run before the native entrypoint drops to the restricted user.""" + return ( + "from pathlib import Path\nimport re, base64, hashlib\n" + + f"_NLS_TAG = {_NLS_TAG!r}\n_ROUTED_NLS = {_ROUTED_NLS!r}\n" + + f"_RESOURCE_QUERY = {_RESOURCE_QUERY!r}\n_ROUTED_QUERY = {_ROUTED_QUERY!r}\n" + + inspect.getsource(patch_editor) + + "\npatch_editor(Path('/opt/studio-sandbox/base-code-server').resolve().parent.parent)\n" + ) + + +def workspace_bootstrap() -> str: + return base64.b64encode(zlib.compress(bootstrap_source().encode())).decode() + + +def workspace_command() -> str: + patch = "python3 -c " + shlex.quote( + "import os,base64,zlib; exec(zlib.decompress(base64.b64decode(os.environ['STUDIO_EDITOR_BOOTSTRAP'])))" + ) + return "/bin/sh -c " + shlex.quote(patch + " && exec /opt/gem/run.sh") diff --git a/frontend/server/workspace_preview.py b/frontend/server/workspace_preview.py new file mode 100644 index 000000000..8a276aaa1 --- /dev/null +++ b/frontend/server/workspace_preview.py @@ -0,0 +1,193 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Authenticated routes for projects in a personal persistent Sandbox.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import PurePosixPath + +from urllib.parse import parse_qs, quote, urlsplit, urlunsplit + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from veadk.cli.frontend_sandbox import ( + SandboxCloudGateway, + SandboxCloudSession, + SandboxError, +) + + +def _validate_folder(folder: str) -> None: + path = PurePosixPath(folder) + if not path.is_absolute() or ".." in path.parts or "\x00" in folder: + raise ValueError("Invalid Sandbox workspace folder") + + +def aio_url(endpoint: str, *, folder: str | None = None, local: bool = False) -> str: + """Preserve the session routing and authorization query on the AIO root.""" + parts = urlsplit(endpoint) + local_host = parts.hostname in {"localhost", "127.0.0.1", "::1"} + valid_scheme = parts.scheme == "https" or ( + local and local_host and parts.scheme == "http" + ) + if ( + not valid_scheme + or not parts.hostname + or parts.username + or parts.password + or (local and not local_host) + ): + raise ValueError("Invalid Sandbox endpoint") + query = parts.query + if folder is not None: + _validate_folder(folder) + if "folder" not in parse_qs(query, keep_blank_values=True): + # Leave signed and provider-specific parameters byte-for-byte intact. + query += ("&" if query else "") + "folder=" + quote(folder, safe="/") + return urlunsplit( + (parts.scheme, parts.netloc, parts.path.rstrip("/") + "/", query, "") + ) + + +def _expired(session: SandboxCloudSession) -> bool: + if session.status.lower() in {"expired", "deleted", "failed", "error"}: + return True + if not session.expire_at: + return False + try: + expiry = datetime.fromisoformat(session.expire_at.replace("Z", "+00:00")) + return expiry.replace(tzinfo=expiry.tzinfo or timezone.utc) <= datetime.now( + timezone.utc + ) + except ValueError: + return True + + +def editor_url(endpoint: str, name: str) -> str: + """Open the editor directly while preserving cloud routing credentials.""" + parts = urlsplit(aio_url(endpoint)) + path = parts.path.rstrip("/") + if not path.endswith("/code-server"): + path += "/code-server" + return ( + aio_url( + urlunsplit((parts.scheme, parts.netloc, path, parts.query, "")), + folder="/home/gem/Projects/" + name, + ) + + "&studio-resource-version=3" + ) + + +class ProjectInput(BaseModel): + name: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,63}$") + + +def project_summary(session: SandboxCloudSession) -> dict[str, str]: + return { + "sessionId": session.instance_id, + "name": session.display_name, + "status": "expired" if _expired(session) else session.status.lower(), + "expireAt": session.expire_at, + "region": session.region, + } + + +def project_response(session: SandboxCloudSession, name: str) -> JSONResponse: + return JSONResponse( + { + **project_summary(session), + "name": name, + "url": editor_url(session.endpoint, name), + }, + headers={ + "Cache-Control": "private, no-store", + "Referrer-Policy": "no-referrer", + }, + ) + + +def mount_workspace_preview_routes( + app: FastAPI, + gateway: SandboxCloudGateway, + owner_resolver: Callable[[Request], str], + creator_resolver: Callable[[Request], str], +) -> None: + tool_id = (os.getenv("STUDIO_WORKSPACE_TOOL_ID") or "").strip() + from frontend.server.workspace_projects import PersistentWorkspaceProjects + from frontend.server.sandbox_remote import SandboxRemoteError + + projects = PersistentWorkspaceProjects(gateway, tool_id) + + def require_tool() -> None: + nonlocal tool_id + tool_id = (os.getenv("STUDIO_WORKSPACE_TOOL_ID") or "").strip() + projects.tool_id = tool_id + if not tool_id: + raise HTTPException(503, "请先配置工作区 Sandbox 镜像") + + @app.get("/web/workspace-preview/state") + async def workspace_state(request: Request) -> JSONResponse: + owner = owner_resolver(request) + require_tool() + try: + return JSONResponse( + await projects.state(owner), + headers={"Cache-Control": "private, no-store"}, + ) + except (SandboxError, TimeoutError, ValueError) as error: + raise HTTPException(502, "暂时无法确认工作区状态,请重试") from error + + @app.get("/web/workspace-preview/projects") + async def list_projects(request: Request) -> JSONResponse: + owner = owner_resolver(request) + require_tool() + try: + cloud, names = await projects.list(owner, creator_resolver(request)) + details = await projects.describe(cloud, names) + return JSONResponse( + { + "projects": [ + {**project_summary(cloud), **detail} for detail in details + ] + }, + headers={"Cache-Control": "private, no-store"}, + ) + except (SandboxError, SandboxRemoteError, TimeoutError, ValueError) as error: + raise HTTPException(502, "恢复工作区或读取项目列表失败,请重试") from error + + @app.post("/web/workspace-preview/projects") + async def create_project(request: Request, body: ProjectInput) -> JSONResponse: + owner = owner_resolver(request) + require_tool() + try: + cloud = await projects.create(owner, creator_resolver(request), body.name) + return project_response(cloud, body.name) + except (SandboxError, SandboxRemoteError, TimeoutError, ValueError) as error: + raise HTTPException(502, "项目初始化失败,请确认镜像可用后重试") from error + + @app.post("/web/workspace-preview/projects/{project_name}/open") + async def open_project(request: Request, project_name: str) -> JSONResponse: + owner = owner_resolver(request) + require_tool() + try: + cloud = await projects.open(owner, creator_resolver(request), project_name) + return project_response(cloud, project_name) + except (SandboxError, SandboxRemoteError, TimeoutError, ValueError) as error: + raise HTTPException(502, "恢复工作区或打开项目失败,请重试") from error diff --git a/frontend/server/workspace_projects.py b/frontend/server/workspace_projects.py new file mode 100644 index 000000000..900c098b4 --- /dev/null +++ b/frontend/server/workspace_projects.py @@ -0,0 +1,344 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""One recoverable cloud Sandbox per owner, with projects as directories.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shlex +from dataclasses import replace +from datetime import datetime, timezone + +from agentkit.sdk.tools.types import SetSessionTtlRequest +from typing import Any + +from fastapi import HTTPException + +from frontend.server.sandbox_remote import SandboxRemoteTransport +from frontend.server.workspace_preview import ProjectInput, _expired +from veadk.cli.agentkit_session_metadata import build_create_session_request +from veadk.cli.frontend_sandbox import SandboxCloudSession, STUDIO_SANDBOX_TTL_SECONDS + +_KIND = "studio-workspace" +_TRANSITIONAL_SESSIONS = { + "creating", + "starting", + "pending", + "initializing", + "resuming", + "stopping", + "snapshotting", + "hibernating", +} +_PENDING_SNAPSHOTS = {"creating", "pending", "running", "snapshotting", "inprogress"} +_READY_SNAPSHOTS = {"completed", "ready", "success", "succeeded"} + + +def owner_session_key(tool_id: str, owner: str) -> str: + return "studio-" + hashlib.sha256(f"{tool_id}\0{owner}".encode()).hexdigest()[:32] + + +class PersistentWorkspaceProjects: + def __init__( + self, gateway: Any, tool_id: str, *, recovery_timeout: float = 120 + ) -> None: + self.recovery_timeout = recovery_timeout + self.gateway = gateway + self.tool_id = tool_id + self.region = os.getenv("AGENTKIT_SANDBOX_REGION", "") + self.locks: dict[str, asyncio.Lock] = {} + + def _lock(self, owner: str) -> asyncio.Lock: + if owner not in self.locks and len(self.locks) >= 128: + raise HTTPException(429, "工作区正在处理较多请求,请稍后重试") + return self.locks.setdefault(owner, asyncio.Lock()) + + def _check_owner(self, session: SandboxCloudSession, owner: str) -> None: + if ( + session.user_session_id != owner_session_key(self.tool_id, owner) + or session.created_by not in {"", owner} + or session.agent_kind not in {"", _KIND} + ): + raise HTTPException(404, "工作区不存在") + + async def _resolve( + self, owner: str, creator: str, *, create_if_missing: bool = True + ) -> SandboxCloudSession: + """Caller holds the owner lock; snapshots retain the stable user-session ID.""" + key = owner_session_key(self.tool_id, owner) + deadline = asyncio.get_running_loop().time() + self.recovery_timeout + while True: + sessions = [ + s + for s in await self.gateway.list_sessions(self.tool_id) + if s.user_session_id == key + ] + for session in sessions: + self._check_owner(session, owner) + ready = [ + s + for s in sessions + if not _expired(s) and s.status.lower() == "ready" and s.endpoint + ] + if len(ready) > 1: + raise HTTPException(409, "检测到多个个人工作区会话,请联系管理员处理") + if ready: + return ready[0] + snapshots = [ + s + for s in await self.gateway.list_snapshots(self.tool_id) + if s.user_session_id == key + ] + for snapshot in snapshots: + if snapshot.created_by not in {"", owner}: + raise HTTPException(404, "工作区不存在") + latest = max(snapshots, key=lambda s: s.created_at) if snapshots else None + transitioning = any( + s.status.lower() in _TRANSITIONAL_SESSIONS for s in sessions + ) + if ( + not transitioning + and latest + and latest.status.lower() in _READY_SNAPSHOTS + ): + session = await self.gateway.resume_snapshot(latest) + self._check_owner(session, owner) + if ( + session.status.lower() == "ready" + and session.endpoint + and not _expired(session) + ): + return session + # Another request may already be resuming this same session. + transitioning = True + pending_snapshot = latest and latest.status.lower() in _PENDING_SNAPSHOTS + awaiting_snapshot = ( + bool(sessions) + and not latest + and all( + s.status.lower() + in {"expired", "deleted", "stopped", "sleeping", "hibernated"} + for s in sessions + ) + ) + missing_existing = not create_if_missing and not snapshots and not sessions + if ( + transitioning + or pending_snapshot + or awaiting_snapshot + or missing_existing + ): + if asyncio.get_running_loop().time() >= deadline: + raise HTTPException(504, "工作区恢复超时,项目仍保留,请重试") + await asyncio.sleep(2) + continue + if snapshots or sessions: + # Never replace a user's existing filesystem with an empty one. + raise HTTPException(409, "工作区暂时无法恢复,原项目仍保留,请重试") + break + tool = await self.gateway.get_tool(self.tool_id) + if not getattr(tool, "enable_snapshot", False): + raise HTTPException(503, "当前 Sandbox 未启用持久化快照,请检查工作区配置") + request = build_create_session_request( + tool_id=self.tool_id, + ttl_seconds=STUDIO_SANDBOX_TTL_SECONDS, + user_session_id=key, + display_name="Studio Workspace", + username=owner, + creator_name=creator, + agent_kind=_KIND, + ) + # The shared gateway handles SDK metadata compatibility and provider credentials. + response = await self.gateway._call( + "create_session", request, region=self.region + ) + if not response.session_id: + raise ValueError("Workspace creation returned no session ID") + for _ in range(60): + session = await self.gateway.get_session(self.tool_id, response.session_id) + self._check_owner(session, owner) + if session.status.lower() == "ready" and session.endpoint: + return session + if _expired(session): + raise HTTPException(502, "个人工作区启动失败,请检查 Sandbox 状态") + await asyncio.sleep(2) + raise TimeoutError("Workspace startup timed out") + + async def state(self, owner: str) -> dict[str, str]: + """Inspect the control plane without waking an idle workspace.""" + key = owner_session_key(self.tool_id, owner) + sessions = [ + s + for s in await self.gateway.list_sessions(self.tool_id) + if s.user_session_id == key + ] + for session in sessions: + self._check_owner(session, owner) + ready = [ + s + for s in sessions + if s.status.lower() == "ready" and not _expired(s) and s.endpoint + ] + if len(ready) > 1: + raise HTTPException(409, "检测到多个个人工作区会话,请联系管理员处理") + if ready: + return { + "status": "ready", + "sessionId": ready[0].instance_id, + "expireAt": ready[0].expire_at, + } + return {"status": "sleeping", "sessionId": "", "expireAt": ""} + + @staticmethod + async def _projects(session: SandboxCloudSession) -> list[str]: + code = """import json,re +from pathlib import Path +root=Path('/home/gem/Projects') +names=sorted(p.name for p in root.iterdir() if p.is_dir() and not p.is_symlink() and (p/'.git').exists() + and re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,63}',p.name)) if root.exists() else [] +print(json.dumps({'projects':names})) +""" + result = await SandboxRemoteTransport(session.endpoint).exec_json( + "python3 -c " + shlex.quote(code), timeout=20 + ) + names = result.get("projects") + if not isinstance(names, list) or not all(isinstance(n, str) for n in names): + raise ValueError("Invalid project directory response") + for name in names: + ProjectInput(name=name) + return names + + @staticmethod + async def describe(session: SandboxCloudSession, names: list[str]) -> list[dict]: + code = """import json,os,subprocess +from pathlib import Path +from datetime import datetime,timezone +root=Path('/home/gem/Projects') +skip={'.git','.venv','venv','node_modules','__pycache__','.pytest_cache','.ruff_cache','.mypy_cache'} +items=[] +for name in NAMES: + p=root/name + if not p.is_dir() or p.is_symlink(): + continue + files=directories=0 + for base,dirs,entries in os.walk(p,followlinks=False): + dirs[:]=[d for d in dirs if d not in skip and not (Path(base)/d).is_symlink()] + directories+=len(dirs) + files+=sum(1 for f in entries if (Path(base)/f).is_file() and not (Path(base)/f).is_symlink()) + marker=p/'.git'/'studio-created-at' + created=None + try: + created=float(marker.read_text()) + except (OSError,ValueError): + created=getattr(p.stat(),'st_birthtime',None) + if not created: + result=subprocess.run(['stat','-c','%W',str(p)],capture_output=True,text=True,timeout=2) + if result.returncode == 0 and result.stdout.strip().isdigit(): + created=int(result.stdout.strip()) or None + items.append({'name':name,'fileCount':files,'directoryCount':directories, + 'createdAt':datetime.fromtimestamp(created,timezone.utc).isoformat() if created else None}) +print(json.dumps({'projects':items})) +""".replace("NAMES", repr(names)) + result = await SandboxRemoteTransport(session.endpoint).exec_json( + "python3 -c " + shlex.quote(code), timeout=20 + ) + items = result.get("projects") + if not isinstance(items, list) or any( + not isinstance(item, dict) + or item.get("name") not in names + or type(item.get("fileCount")) is not int + or type(item.get("directoryCount")) is not int + for item in items + ): + raise ValueError("Invalid project metadata response") + return items + + async def list( + self, owner: str, creator: str + ) -> tuple[SandboxCloudSession, list[str]]: + async with self._lock(owner): + session = await self._resolve(owner, creator) + return session, await self._projects(session) + + async def create(self, owner: str, creator: str, name: str) -> SandboxCloudSession: + ProjectInput(name=name) + async with self._lock(owner): + session = await self._resolve(owner, creator) + if name in await self._projects(session): + raise HTTPException(409, "项目名称已存在,请从项目列表打开") + index = os.getenv( + "STUDIO_WORKSPACE_DEPENDENCY_INDEX", + "https://mirrors.aliyun.com/pypi/simple/", + ) + from frontend.server.workspace_templates import default_project_template + + template_json = json.dumps( + default_project_template(name), ensure_ascii=False + ) + code = ( + "import os,subprocess,json,time; from pathlib import Path; " + f"p=Path('/home/gem/Projects')/{name!r}; " + "assert not p.exists() and not p.is_symlink(), 'Project already exists'; " + f"cmd=['studio-project-create',{name!r},'--json','--template-stdin']; " + "cmd=(['runuser','-u','gem','--']+cmd) if os.geteuid()==0 else cmd; " + f"env=dict(os.environ,UV_DEFAULT_INDEX={index!r},UV_INDEX_URL={index!r}); " + f"subprocess.run(cmd,input={template_json!r},text=True,check=True,capture_output=True,env=env); " + "assert (p/'.git').exists() and (p/'.venv/bin/python').exists(); " + "(p/'.git'/'studio-created-at').write_text(str(time.time())); " + "print(json.dumps({'ready':True}))" + ) + result = await SandboxRemoteTransport(session.endpoint).exec_json( + "python3 -c " + shlex.quote(code), timeout=90 + ) + if result.get("ready") is not True: + raise ValueError("Project initialization failed") + return session + + async def _renew_if_needed( + self, session: SandboxCloudSession + ) -> SandboxCloudSession: + if not session.expire_at: + raise ValueError("Workspace session has no expiration time") + expiry = datetime.fromisoformat(session.expire_at.replace("Z", "+00:00")) + remaining = ( + expiry.replace(tzinfo=expiry.tzinfo or timezone.utc) + - datetime.now(timezone.utc) + ).total_seconds() + if remaining >= 3600: + return session + response = await self.gateway._call( + "set_session_ttl", + SetSessionTtlRequest( + ToolId=self.tool_id, + SessionId=session.instance_id, + Ttl=STUDIO_SANDBOX_TTL_SECONDS, + TtlUnit="second", + ), + region=session.region or self.region, + ) + if not response.expire_at: + raise ValueError("Session renewal returned no expiration time") + return replace(session, expire_at=response.expire_at) + + async def open(self, owner: str, creator: str, name: str) -> SandboxCloudSession: + ProjectInput(name=name) + async with self._lock(owner): + session = await self._resolve(owner, creator, create_if_missing=False) + if name not in await self._projects(session): + raise HTTPException(404, "项目目录不存在") + return await self._renew_if_needed(session) diff --git a/frontend/server/workspace_templates.py b/frontend/server/workspace_templates.py new file mode 100644 index 000000000..b848a6661 --- /dev/null +++ b/frontend/server/workspace_templates.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Studio-owned project templates sent to the Sandbox initialization tool.""" + +from pathlib import Path +from string import Template + +from frontend.server.workspace_preview import ProjectInput + + +def default_project_template(name: str) -> dict[str, object]: + ProjectInput(name=name) + root = Path(__file__).parent / "templates" / "python-agent" + files = { + path.name.removesuffix(".tmpl"): Template( + path.read_text(encoding="utf-8") + ).substitute(project_name=name, agent_name=name.replace("-", "_")) + for path in sorted(root.glob("*.tmpl")) + } + if "main.py" not in files: + raise ValueError("Studio default project template is missing") + return {"version": 1, "files": files} diff --git a/frontend/server/workspace_tool.py b/frontend/server/workspace_tool.py new file mode 100644 index 000000000..81304b82f --- /dev/null +++ b/frontend/server/workspace_tool.py @@ -0,0 +1,326 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Provision an isolated Studio workspace Tool from an immutable CR image.""" + +from __future__ import annotations + +import hashlib +import os +import secrets +import time +from collections.abc import Mapping +from typing import Any + +from agentkit.sdk.tools import types + +DEFAULT_WORKSPACE_IMAGES = { + ( + "volcengine", + "cn-beijing", + ): "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/agentkit-sandbox:studio-sandbox-1.0.1", + ( + "volcengine", + "cn-shanghai", + ): "enterprise-cn-shanghai-cn-shanghai.cr.volces.com/vefaas-public/agentkit-sandbox:studio-sandbox-1.0.1", + ( + "byteplus", + "ap-southeast-1", + ): "enterprise-public-ap-southeast-1.cr.volces.com/vefaas-public/agentkit-sandbox:studio-sandbox-1.0.1", +} + + +def resolve_workspace_image(provider: str, region: str) -> str: + override = os.getenv("STUDIO_WORKSPACE_IMAGE", "").strip() + if override: + return override + image = DEFAULT_WORKSPACE_IMAGES.get((provider, region)) + if not image: + raise ValueError( + f"No default Studio Sandbox image for {provider}/{region}; set STUDIO_WORKSPACE_IMAGE" + ) + return image + + +def workspace_tool_request( + image: str, provider: str, model_environment: Mapping[str, str] +) -> Any: + if provider not in {"volcengine", "byteplus"}: + raise ValueError("Unsupported workspace provider") + if not image or image != image.strip() or "://" in image or "/" not in image: + raise ValueError("A registry image reference is required") + required = {"MODEL_AGENT_NAME", "MODEL_AGENT_BASE_URL", "MODEL_AGENT_API_KEY"} + if any(not model_environment.get(key) for key in required): + raise ValueError("Workspace model configuration is incomplete") + envs = {key: model_environment[key] for key in required} + envs.update( + { + "VSCODE_LANG": "en" if provider == "byteplus" else "zh-CN", + "WORKSPACE": "/home/gem/Projects", + "AIO_USER": "gem", + "DISABLE_CODE_SERVER": "false", + "UV_DEFAULT_INDEX": os.getenv( + "STUDIO_WORKSPACE_DEPENDENCY_INDEX", + "https://mirrors.aliyun.com/pypi/simple/", + ), + "UV_INDEX_URL": os.getenv( + "STUDIO_WORKSPACE_DEPENDENCY_INDEX", + "https://mirrors.aliyun.com/pypi/simple/", + ), + } + ) + name = ( + "studio-workspace-" + + hashlib.sha256((provider + image + "persistent-v1").encode()).hexdigest()[:16] + ) + return types.CreateToolRequest( + Name=name, + ToolType="Private", + EnableSnapshot=True, + ProjectName="default", + Description="AgentKit Studio Sandbox", + ImageUrl=image, + Command="/opt/gem/run.sh", + Port=8080, + CpuMilli=8000, + MemoryMb=16384, + ModelAgentName=envs["MODEL_AGENT_NAME"], + ClientToken=secrets.token_hex(16), + Envs=[types.EnvsItemForCreateTool(Key=k, Value=v) for k, v in envs.items()], + AuthorizerConfiguration=types.AuthorizerForCreateTool( + KeyAuth=types.AuthorizerKeyAuthForCreateTool( + ApiKeyName=name, ApiKeyLocation="Header" + ) + ), + NetworkConfiguration=types.NetworkForCreateTool( + EnablePublicNetwork=True, EnablePrivateNetwork=False + ), + ) + + +def ensure_workspace_tool( + client: Any, + image: str, + provider: str, + model_environment: Mapping[str, str], + timeout: float = 600, +) -> str: + request = workspace_tool_request(image, provider, model_environment) + response = client.list_tools( + types.ListToolsRequest( + ProjectName="default", + MaxResults=100, + Filters=[types.FiltersItemForListTools(Name="Name", Values=[request.name])], + ) + ) + matches = [tool for tool in response.tools or [] if tool.name == request.name] + if len(matches) > 1: + raise RuntimeError("Multiple workspace tools match the image") + if matches: + tool_id = matches[0].tool_id + if matches[0].tool_type != "Private" or matches[0].image_url != image: + raise RuntimeError( + "Existing workspace tool does not match the requested image" + ) + current = client.get_tool(types.GetToolRequest(ToolId=tool_id)) + if not current.enable_snapshot: + raise RuntimeError("Workspace tool must have snapshots enabled") + current_env = {item.key: item.value for item in current.envs or []} + required_env = {item.key: item.value for item in request.envs or []} + if current.command != request.command or any( + current_env.get(key) != value for key, value in required_env.items() + ): + current_env.update(required_env) + client.update_tool( + types.UpdateToolRequest( + ToolId=tool_id, + Command=request.command, + Envs=[ + types.EnvsItemForUpdateTool(Key=k, Value=v) + for k, v in current_env.items() + ], + ) + ) + else: + tool_id = client.create_tool(request).tool_id + if not tool_id: + raise RuntimeError("AgentKit returned no Tool ID") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + tool = client.get_tool(types.GetToolRequest(ToolId=tool_id)) + status = (tool.status or "").lower() + if status == "ready": + return tool_id + if status in {"failed", "error", "createfailed", "deleted"}: + raise RuntimeError(f"Workspace tool is {status}") + time.sleep(3) + raise TimeoutError("Workspace tool did not become ready") + + +def provision_workspace_tool( + *, + provider: str, + region: str, + access_key: str, + secret_key: str, + session_token: str = "", + image: str = "", +) -> str: + """Provision the image and model environment during Studio deployment.""" + from agentkit.sdk.tools.client import AgentkitToolsClient + from veadk.auth.veauth.ark_veauth import get_ark_token + from veadk.cli.studio_sandbox_tools import ( + studio_sandbox_agent_model_name, + studio_sandbox_model_base_url, + ) + + image = image.strip() or resolve_workspace_image(provider, region) + client = AgentkitToolsClient( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + region=region, + ) + key = get_ark_token( + cloud_provider=provider, + region=region, + access_key=access_key, + secret_key=secret_key, + session_token=session_token or None, + ) + return ensure_workspace_tool( + client, + image, + provider, + { + "MODEL_AGENT_NAME": studio_sandbox_agent_model_name(provider), + "MODEL_AGENT_BASE_URL": studio_sandbox_model_base_url(provider), + "MODEL_AGENT_API_KEY": key, + }, + ) + + +def workspace_update_environment( + environment: Mapping[str, str], + *, + provider: str, + region: str, + access_key: str, + secret_key: str, + session_token: str = "", +) -> dict[str, str]: + """Add a workspace binding without changing existing users' storage identity.""" + if environment.get("STUDIO_WORKSPACE_TOOL_ID", "").strip(): + return {} + tool_id = provision_workspace_tool( + provider=provider, + region=region, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + image=environment.get("STUDIO_WORKSPACE_IMAGE", ""), + ) + if not tool_id: + raise RuntimeError("Studio Sandbox provisioning returned no Tool ID") + return {"STUDIO_WORKSPACE_TOOL_ID": tool_id} + + +def repair_deployed_workspace_binding(*, provider: str, resolve_credentials) -> str: + """Backfill older releases whose updater did not know about workspace Tools.""" + import volcenginesdkvefaas as faas + from veadk.integrations.ve_faas.ve_faas import VeFaaS + + function_id = os.environ["VEADK_STUDIO_FUNCTION_ID"] + region = ( + os.getenv("VEADK_STUDIO_DEPLOY_REGION") or os.environ["AGENTKIT_SANDBOX_REGION"] + ) + access_key, secret_key, session_token = resolve_credentials() + service = VeFaaS( + access_key=access_key, + secret_key=secret_key, + session_token=session_token or "", + region=region, + provider=provider, + ) + + def read_environment(): + function = service.client.get_function(faas.GetFunctionRequest(id=function_id)) + return {item.key: item.value for item in function.envs or []} + + environment = read_environment() + overrides = workspace_update_environment( + environment, + provider=provider, + region=region, + access_key=access_key, + secret_key=secret_key, + session_token=session_token or "", + ) + if not overrides: + return environment["STUDIO_WORKSPACE_TOOL_ID"] + # Re-read before writing so concurrent configuration changes are preserved. + current = read_environment() + if current.get("STUDIO_WORKSPACE_TOOL_ID", "").strip(): + return current["STUDIO_WORKSPACE_TOOL_ID"] + service.client.update_function( + faas.UpdateFunctionRequest( + id=function_id, + envs=[ + faas.EnvForUpdateFunctionInput(key=k, value=v) + for k, v in {**current, **overrides}.items() + ], + ) + ) + service.client.release( + faas.ReleaseRequest(function_id=function_id, revision_number=0) + ) + return overrides["STUDIO_WORKSPACE_TOOL_ID"] + + +def mount_workspace_upgrade_repair(app, *, provider: str, resolve_credentials) -> None: + """Run only for deployed Studios lacking the new binding, without blocking HTTP.""" + if os.getenv("STUDIO_WORKSPACE_TOOL_ID", "").strip() or not os.getenv( + "VEADK_STUDIO_FUNCTION_ID" + ): + return + import asyncio + import logging + from contextlib import asynccontextmanager + + original_lifespan = app.router.lifespan_context + + async def repair(): + try: + tool_id = await asyncio.to_thread( + repair_deployed_workspace_binding, + provider=provider, + resolve_credentials=resolve_credentials, + ) + os.environ["STUDIO_WORKSPACE_TOOL_ID"] = tool_id + except Exception: + logging.getLogger(__name__).error( + "Studio workspace setup failed; retry the Studio update after checking cloud permissions" + ) + + @asynccontextmanager + async def lifespan(current_app): + async with original_lifespan(current_app): + task = asyncio.create_task(repair()) + try: + yield + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + app.router.lifespan_context = lifespan diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69e4fbcb9..875a5d50d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -147,6 +147,7 @@ import { import { IntelligentDeployment } from "./create/IntelligentDeployment"; import { CustomCreate } from "./create/CustomCreate"; import { AgentCreationModePicker } from "./create/AgentCreationModePicker"; +import { WorkspaceCreate, WorkspaceCreateIcon } from "./create/WorkspaceCreate"; import { CodePackageCreate } from "./create/CodePackageCreate"; import { MigrationWorkspace } from "./migrations/MigrationWorkspace"; import type { AgentDraft } from "./create/types"; @@ -362,7 +363,7 @@ async function loadHydratedSessions( ); } -type CreateView = "custom" | "package" | "migration" | null; +type CreateView = "custom" | "package" | "migration" | "workspace" | null; type AppView = CreateView | "intelligent"; type CustomCreateMode = "custom" | "yaml_import"; type StudioPageId = @@ -2126,6 +2127,9 @@ export default function App() { const [importedDraft, setImportedDraft] = useState(null); const [customCreateMode, setCustomCreateMode] = useState("custom"); + const [workspaceLandingSection, setWorkspaceLandingSection] = useState<"workspaces" | "environments">("workspaces"); + const [workspacePreviewOpened, setWorkspacePreviewOpened] = useState(false); + const [workspaceCreateRequest, setWorkspaceCreateRequest] = useState(0); const [savedAgentDrafts, setSavedAgentDrafts] = useState([]); const savedAgentDraftsRef = useRef([]); const pendingWorkspaceDraftRef = useRef(null); @@ -3236,7 +3240,9 @@ export default function App() { : t("titles.createAgent") : createView === "package" ? t("titles.addFromPackage") - : t("titles.migrateAgent"), + : createView === "workspace" + ? t("titles.codeProjects") + : t("titles.migrateAgent"), }; } else if (sandboxSession) { const activeThread = sandboxCommands.threads.find( @@ -6448,7 +6454,7 @@ export default function App() { ? "feedback" : environmentView ? "environments" - : workspaceView + : workspaceView || visibleCreateView === "workspace" ? "workspaces" : skillCenter ? "library" @@ -6478,7 +6484,7 @@ export default function App() { ? "feedback" : environmentView ? "environments" - : workspaceView + : workspaceView || visibleCreateView === "workspace" ? "workspaces" : skillCenter ? "library" @@ -7020,7 +7026,20 @@ export default function App() { )} - {systemInfo ? ( + {workspacePreviewOpened && canCreateRuntimeAgents && ( + { setAddMenu(false); setWorkspaceView(false); setCreateView("workspace"); }} + onBack={(section = "workspaces") => { + setCreateView(null); + setWorkspaceLandingSection(section); + setWorkspaceView(true); + }} + /> + )} + {visibleCreateView === "workspace" ? null : systemInfo ? ( ) : workspaceView ? ( - + { + setWorkspaceView(false); + setWorkspacePreviewOpened(true); + setCreateView("workspace"); + } : undefined} /> ) : cronJobsView ? ( ) : applicationsView === "coding-agents" ? ( @@ -7395,6 +7418,21 @@ export default function App() { setCreateView("package"); }, }, + { + key: "workspace", + icon: WorkspaceCreateIcon, + title: t("workspaceProjectEntry.title"), + desc: t("workspaceProjectEntry.description"), + onClick: () => { + setAddMenu(false); + setImportedDraft(null); + setRuntimeUpdateTarget(null); + setWorkspacePreviewOpened(true); + setWorkspaceView(false); + setWorkspaceCreateRequest(value => value + 1); + setCreateView("workspace"); + }, + }, { key: "migration", icon: MigrationIcon, diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index c1a402ba9..ce54146b0 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -2089,6 +2089,7 @@ export type SandboxToolKind = | "openclaw_snapshot" | "hermes" | "hermes_snapshot" + | "studio_workspace" | "dev"; export type CodexSandboxToolKind = Extract< SandboxToolKind, diff --git a/frontend/src/adk/workspacePreview.ts b/frontend/src/adk/workspacePreview.ts new file mode 100644 index 000000000..63cfcf0d0 --- /dev/null +++ b/frontend/src/adk/workspacePreview.ts @@ -0,0 +1,79 @@ +import { adkT } from "./i18n"; +import zhAdk from "../i18n/resources/zh-CN/adk.json"; +import { studioFetch } from "./client"; + +function projectError(detail: unknown, fallback: string): string { + if (typeof detail !== "string") return fallback; + const key = Object.entries(zhAdk.workspaceProjects).find(([, value]) => value === detail)?.[0]; + return key ? adkT(`workspaceProjects.${key}`) : fallback; +} + +export interface WorkspacePreview { + sessionId: string; + url: string; + expireAt: string; + region: string; +} + +export async function launchWorkspacePreview(signal: AbortSignal): Promise { + const response = await studioFetch("/web/workspace-preview/session", { + method: "POST", + signal, + cache: "no-store", + }, 180_000); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(projectError(body?.detail, adkT("workspaceProjects.connection"))); + } + const data = await response.json() as WorkspacePreview; + const url = new URL(data.url); + const localPreview = data.region === "local" && url.protocol === "http:" + && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (!data.sessionId || (url.protocol !== "https:" && !localPreview)) { + throw new Error(adkT("workspaceProjects.invalidWorkspaceUrl")); + } + return data; +} + +export interface WorkspaceProject { + createdAt?: string | null; + fileCount?: number; + directoryCount?: number; + sessionId: string; + name: string; + status: string; + expireAt: string; + region: string; +} + +async function projectRequest(path: string, signal: AbortSignal, name?: string) { + const response = await studioFetch(`/web/workspace-preview/projects${path}`, { + method: "POST", signal, cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: name === undefined ? undefined : JSON.stringify({ name }), + }, 180_000); + const data = await response.json().catch(() => null); + if (!response.ok || !data) throw new Error(projectError(data?.detail, adkT("workspaceProjects.operation"))); + const url = new URL(data.url); + if (url.protocol !== "https:" || !data.sessionId) throw new Error(adkT("workspaceProjects.invalidProjectUrl")); + return data as WorkspacePreview & WorkspaceProject; +} + +export async function listWorkspaceProjects(signal: AbortSignal): Promise { + const response = await studioFetch("/web/workspace-preview/projects", { signal, cache: "no-store" }, 180_000); + const data = await response.json().catch(() => null); + if (!response.ok || !data) throw new Error(projectError(data?.detail, adkT("workspaceProjects.listFallback"))); + return data.projects; +} + +export const createWorkspaceProject = (name: string, signal: AbortSignal) => projectRequest("", signal, name); +export const openWorkspaceProject = (id: string, signal: AbortSignal) => projectRequest(`/${encodeURIComponent(id)}/open`, signal); + +export async function getWorkspaceState(signal: AbortSignal): Promise<{status: string; sessionId: string; expireAt: string}> { + const response = await studioFetch("/web/workspace-preview/state", { signal, cache: "no-store" }, 20_000); + const data = await response.json().catch(() => null); + if (!response.ok || !data || typeof data.status !== "string") { + throw new Error(projectError(data?.detail, adkT("workspaceProjects.connectionState"))); + } + return data; +} diff --git a/frontend/src/create/WorkspaceCreate.css b/frontend/src/create/WorkspaceCreate.css new file mode 100644 index 000000000..5b7df37cd --- /dev/null +++ b/frontend/src/create/WorkspaceCreate.css @@ -0,0 +1,60 @@ +.workspace-project-host { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; } +.workspace-project-host[hidden] { display: none; } +.workspace-create { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + color: hsl(var(--foreground)); + background: hsl(var(--background)); +} +.workspace-create[hidden] { display: none; } +.workspace-create__header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px; + border-bottom: 1px solid hsl(var(--border)); +} +.workspace-create__heading { flex: 1; min-width: 0; } +.workspace-create h1 { margin: 0; font-size: 16px; font-weight: 600; line-height: 24px; } +.workspace-create__heading p { margin: 2px 0 0; font-size: 12px; color: hsl(var(--muted-foreground)); } +.workspace-create__surface { display: flex; position: relative; flex: 1; min-height: 0; } +.workspace-create iframe { flex: 1; width: 100%; height: 100%; border: 0; background: hsl(var(--background)); } +.workspace-create__state { display: flex; flex: 1; align-items: center; justify-content: center; flex-direction: column; gap: 12px; padding: 32px; text-align: center; } +.workspace-create__state > svg { width: 36px; height: 36px; margin-bottom: 8px; color: hsl(var(--muted-foreground)); } +.workspace-create__state h2 { margin: 0; font-size: 16px; font-weight: 500; } +.workspace-create__state p { margin: 0; max-width: 420px; font-size: 14px; line-height: 1.6; color: hsl(var(--muted-foreground)); } +@media (max-width: 640px) { + .workspace-create__header { flex-wrap: wrap; padding: 12px; } +} +.workspace-create__surface[hidden] { display: none; } +.workspace-create h1 { font-size: 20px; } +.workspace-create__recovery { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; background: hsl(var(--background) / .95); color: hsl(var(--foreground)); font-size: 14px; } + +.workspace-create:fullscreen { width: 100vw; height: 100vh; } +.workspace-create__countdown { font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; color: hsl(var(--muted-foreground)); } +.workspace-create__fullscreen { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px; border: 0; border-radius: 6px; color: hsl(var(--foreground)); background: transparent; cursor: pointer; } +.workspace-create__fullscreen svg { width: 18px; height: 18px; } +.workspace-create__fullscreen:hover { background: hsl(var(--muted)); } +.workspace-create__fullscreen:active { background: hsl(var(--secondary)); } +.workspace-create__fullscreen:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; } + +.workspace-create.is-fullscreen { position: fixed; inset: 0; z-index: 1000; width: 100vw; height: 100vh; } + +.workspace-project-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 32px); overflow: auto; width: min(440px, calc(100vw - 32px)); padding: 0; border: 1px solid hsl(var(--border)); border-radius: 12px; color: hsl(var(--foreground)); background: hsl(var(--panel)); box-shadow: 0 16px 48px hsl(var(--foreground) / .15); } +.workspace-project-dialog::backdrop { background: hsl(var(--foreground) / .32); } +.workspace-project-dialog header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 0; gap: 16px; } +.workspace-project-dialog h2 { margin: 0; font-size: 17px; font-weight: 600; } +.workspace-project-dialog__body { padding: 20px 24px; } +.workspace-project-dialog label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 500; } +.workspace-project-dialog input { box-sizing: border-box; width: 100%; height: 36px; border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 0 12px; font: inherit; font-size: 14px; color: hsl(var(--foreground)); background: hsl(var(--background)); } +.workspace-project-dialog input:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; } +.workspace-project-dialog p { margin: 8px 0 0; font-size: 12px; line-height: 1.5; color: hsl(var(--muted-foreground)); } +.workspace-project-dialog footer { display: flex; justify-content: flex-end; gap: 8px; padding: 0 24px 24px; } +.workspace-project-dialog .workspace-create__error, .workspace-create__error { color: hsl(var(--destructive)); font-size: 13px; } +.workspace-create__empty { font-size: 14px; color: hsl(var(--muted-foreground)); } +.workspace-create__heading h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (max-width: 800px) { .workspace-create__countdown { white-space: normal; max-width: 260px; } } diff --git a/frontend/src/create/WorkspaceCreate.tsx b/frontend/src/create/WorkspaceCreate.tsx new file mode 100644 index 000000000..b867a8ee7 --- /dev/null +++ b/frontend/src/create/WorkspaceCreate.tsx @@ -0,0 +1,241 @@ +import { useTranslation } from "react-i18next"; +import "./i18n"; +import { formatRelativeTimeLabel } from "../ui/relativeTime"; +import { WorkspaceCollectionLayout } from "../ui/WorkspaceCollectionLayout"; +import { useEffect, useRef, useState } from "react"; +import { createWorkspaceProject, getWorkspaceState, listWorkspaceProjects, openWorkspaceProject, type WorkspaceProject, type WorkspacePreview } from "../adk/workspacePreview"; +import { Button } from "@openai/apps-sdk-ui/components/Button"; +import { PageBackButton } from "../ui/PageBackButton"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { LibraryResourceCard } from "../ui/LibraryResourceCard"; +import { ResourceSearch, ResourceResults, ResourceGrid, ResourceCreateCard, ResourceLoadingState } from "../ui/ResourceCollection"; +import "./WorkspaceCreate.css"; + +export function WorkspaceCreateIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function WorkspaceCreate({ active, onBack, onReturnToProjects, createRequest = 0 }: { active: boolean; createRequest?: number; onReturnToProjects?: () => void; onBack: (section?: "workspaces" | "environments") => void }) { + const { t, i18n } = useTranslation("create"); + const [preview, setPreview] = useState<(WorkspacePreview & WorkspaceProject) | null>(null); + const [projects, setProjects] = useState([]); + const [creating, setCreating] = useState(false); + const [query, setQuery] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [listError, setListError] = useState(""); + const [busy, setBusy] = useState(false); + const [openingProject, setOpeningProject] = useState(null); + const [listing, setListing] = useState(true); + const [refresh, setRefresh] = useState(0); + const [managing, setManaging] = useState(true); + const [recovering, setRecovering] = useState(false); + const [recoveryError, setRecoveryError] = useState(""); + const [retryRecovery, setRetryRecovery] = useState(0); + const [editorGeneration, setEditorGeneration] = useState(0); + const container = useRef(null); + const [fullscreen, setFullscreen] = useState(false); + const [now, setNow] = useState(Date.now); + useEffect(() => { + const escape = (event: KeyboardEvent) => { if (event.key === "Escape") setFullscreen(false); }; + document.addEventListener("keydown", escape); + return () => document.removeEventListener("keydown", escape); + }, []); + useEffect(() => { + if (!active) setFullscreen(false); + if (!active || managing) return; + setNow(Date.now()); + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [active, managing]); + const secondsLeft = preview?.expireAt ? Math.max(0, Math.ceil((Date.parse(preview.expireAt) - now) / 1000)) : null; + const countdown = secondsLeft === null || !Number.isFinite(secondsLeft) ? t("workspace.checkingExpiry") : secondsLeft === 0 ? t("workspace.waitingRecovery") : + `${String(Math.floor(secondsLeft / 3600)).padStart(2, "0")}:${String(Math.floor(secondsLeft / 60) % 60).padStart(2, "0")}:${String(secondsLeft % 60).padStart(2, "0")}`; + const operation = useRef(null); + const composing = useRef(false); + const handledCreateRequest = useRef(0); + useEffect(() => { + if (!active || !createRequest || createRequest === handledCreateRequest.current) return; + handledCreateRequest.current = createRequest; + setManaging(true); setName(""); setError(""); setCreating(true); + }, [active, createRequest]); + + useEffect(() => () => operation.current?.abort(), []); + useEffect(() => { if (active) setManaging(true); else setCreating(false); }, [active]); + useEffect(() => { + if (!active || !managing) return; + const controller = new AbortController(); + setListing(true); + setListError(""); + void listWorkspaceProjects(controller.signal).then(setProjects).catch((cause: unknown) => { + if (!controller.signal.aborted) setListError(cause instanceof Error ? cause.message : t("workspace.listFailed")); + }).finally(() => { if (!controller.signal.aborted) setListing(false); }); + return () => controller.abort(); + }, [active, managing, refresh]); + + useEffect(() => { + if (!active || managing || !preview) return; + const controller = new AbortController(); + let checking = false; + let failed = false; + async function checkWorkspace() { + if (checking || failed || operation.current || document.visibilityState === "hidden") return; + checking = true; + try { + const state = await getWorkspaceState(controller.signal); + if (controller.signal.aborted) return; + const remaining = state.expireAt ? Date.parse(state.expireAt) - Date.now() : 0; + if (state.status === "ready" && state.sessionId === preview!.sessionId && remaining >= 3_600_000) { + if (state.expireAt !== preview!.expireAt) setPreview(current => current ? { ...current, expireAt: state.expireAt } : current); + return; + } + setRecovering(true); + setRecoveryError(""); + const next = await openWorkspaceProject(preview!.name, controller.signal); + if (controller.signal.aborted) return; + setPreview(next); + if (next.sessionId !== preview!.sessionId) setEditorGeneration(value => value + 1); + setRefresh(value => value + 1); + } catch (cause) { + if (!controller.signal.aborted) { + failed = true; + setRecoveryError(cause instanceof Error ? cause.message : t("workspace.recoveryFailed")); + } + } finally { + checking = false; + if (!controller.signal.aborted) setRecovering(false); + } + } + setRecoveryError(""); + void checkWorkspace(); + const timer = window.setInterval(() => { void checkWorkspace(); }, 30_000); + const onVisible = () => { if (document.visibilityState === "visible") void checkWorkspace(); }; + document.addEventListener("visibilitychange", onVisible); + return () => { + controller.abort(); + window.clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [active, managing, preview, retryRecovery]); + + // VS Code owns unsaved-file prompts. An unconditional outer beforeunload + // handler can cancel navigation after the iframe has disposed its connection. + + async function launch(project?: WorkspaceProject) { + if (operation.current) return; + const controller = new AbortController(); + operation.current = controller; + setBusy(true); + setOpeningProject(project?.name ?? null); + setError(""); + try { + const next = project ? await openWorkspaceProject(project.name, controller.signal) + : await createWorkspaceProject(name.trim(), controller.signal); + if (controller.signal.aborted) return; + setPreview(next); + setRecoveryError(""); + setRecovering(false); + setManaging(false); + setCreating(false); + setName(""); + setRefresh(value => value + 1); + } catch (cause) { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : t("workspace.operationFailed")); + } finally { + operation.current = null; + if (!controller.signal.aborted) { setBusy(false); setOpeningProject(null); } + } + } + + return ( +