@vantran-se/openchamber-web 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// spaceMetadata(spaceId) resolves, as text, everything the place's own records show about the space container
|
|
11
11
|
// gatekeeperMetadata(spaceId) the same for the gatekeeper container
|
|
12
12
|
// gatekeeperAddresses(spaceId) resolves { inner, outer }, the gatekeeper's own addresses
|
|
13
|
+
// probeFromOuterNetwork(spaceId, script) runs a Node script in a helper on the space's outer network
|
|
13
14
|
// startWindowUpstream(spaceId) a stand-in provider on the space's outer network. It answers with
|
|
14
15
|
// the sha256 of the credentials it saw, never with the credentials
|
|
15
16
|
|
|
@@ -190,17 +191,51 @@ Promise.all(Array.from({ length: 80 }, attempt)).then((outcomes) => {
|
|
|
190
191
|
const LONG_LIVED_CREDENTIAL = '"refresh"[[:space:]]*:[[:space:]]*"|BEGIN [A-Z ]*PRIVATE KEY|gh[pousr]_[A-Za-z0-9]{16}|sk-[A-Za-z0-9_-]{20}|xox[baprs]-';
|
|
191
192
|
|
|
192
193
|
/** Everything the space holds that could be a long-lived credential, as text for the host to read.
|
|
193
|
-
* HOME, the work directory and /tmp, which is writable and 256 MiB.
|
|
194
|
+
* HOME, the work directory and /tmp, which is writable and 256 MiB. `-a` reads binary files as
|
|
195
|
+
* text: OpenCode 2 keeps its logins in the SQLite database `opencode.db` under HOME, and `-I`
|
|
196
|
+
* skipped that file. `-o` prints the matched shape and its file, never the binary around it. */
|
|
194
197
|
const credentialSearch = (spaceId) => `
|
|
195
198
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;
|
|
196
199
|
echo "auth-record: $([ -e "$HOME/.local/share/opencode/auth.json" ] && echo present || echo absent)";
|
|
197
200
|
echo "--- files";
|
|
198
|
-
grep -
|
|
201
|
+
grep -raosE '${LONG_LIVED_CREDENTIAL}' "$HOME" /tmp /spaces/${spaceId} 2>/dev/null | head -20;
|
|
199
202
|
echo "--- environments";
|
|
200
203
|
for process in /proc/[0-9]*/environ; do tr '\\0' '\\n' < "$process" 2>/dev/null; done | grep -E '${LONG_LIVED_CREDENTIAL}' | head -20;
|
|
201
204
|
echo "--- end";
|
|
202
205
|
`;
|
|
203
206
|
|
|
207
|
+
// OpenCode 2's own login store: rows in the `credential` table of `opencode.db`. The control
|
|
208
|
+
// writes two the way OpenCode stores them: an OpenAI browser login, and a key for a provider of the
|
|
209
|
+
// user's own that OpenCode's catalog does not know, which no integration route lists and no key
|
|
210
|
+
// pattern matches. No route can create an OAuth record without a real login, so the rows are
|
|
211
|
+
// written with Node's built-in SQLite into the database the running OpenCode uses. The timeout
|
|
212
|
+
// waits for OpenCode's own lock instead of failing on it.
|
|
213
|
+
const DECOY_CREDENTIAL_ID = 'cred_decoy000000000000000000';
|
|
214
|
+
const DECOY_CUSTOM_CREDENTIAL_ID = 'cred_decoy000000000000000001';
|
|
215
|
+
const openDatabase = (databasePath, options = '{ timeout: 5000 }') => `
|
|
216
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
217
|
+
const db = new DatabaseSync(${JSON.stringify(databasePath)}, ${options});
|
|
218
|
+
`;
|
|
219
|
+
const plantCredential = (databasePath) => `${openDatabase(databasePath)}
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
const insert = db.prepare('INSERT INTO credential (id, integration_id, label, value, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
222
|
+
insert.run('${DECOY_CREDENTIAL_ID}', 'openai', 'default', JSON.stringify({ type: 'oauth', methodID: 'chatgpt-browser', refresh: 'decoy-refresh-value', access: 'decoy-access-value', expires: now + 3600000 }), 1, now, now);
|
|
223
|
+
insert.run('${DECOY_CUSTOM_CREDENTIAL_ID}', 'acme-llm', 'default', JSON.stringify({ type: 'key', key: 'acme_live_decoy' }), 1, now, now);
|
|
224
|
+
db.close();
|
|
225
|
+
`;
|
|
226
|
+
const removeCredential = (databasePath) => `${openDatabase(databasePath)}
|
|
227
|
+
const { changes } = db.prepare('DELETE FROM credential WHERE id IN (?, ?)').run('${DECOY_CREDENTIAL_ID}', '${DECOY_CUSTOM_CREDENTIAL_ID}');
|
|
228
|
+
db.close();
|
|
229
|
+
console.log('removed:' + changes);
|
|
230
|
+
`;
|
|
231
|
+
// Every stored login, by integration and kind, read straight from the table. OpenCode lists only
|
|
232
|
+
// the integrations its catalog knows, so this is the check that sees a custom provider's key.
|
|
233
|
+
const readCredentials = (databasePath) => `${openDatabase(databasePath, '{ readOnly: true, timeout: 5000 }')}
|
|
234
|
+
const rows = db.prepare("SELECT integration_id AS integration, json_extract(value, '$.type') AS type FROM credential ORDER BY id").all();
|
|
235
|
+
db.close();
|
|
236
|
+
console.log(JSON.stringify(rows.map((row) => ({ integration: row.integration, type: row.type }))));
|
|
237
|
+
`;
|
|
238
|
+
|
|
204
239
|
const sleep = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
|
|
205
240
|
|
|
206
241
|
const sha256 = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
@@ -458,7 +493,6 @@ export function runEscapeSuite(title, { enabled = true, setup }) {
|
|
|
458
493
|
// Positive control: the metadata is real and complete enough to show the environment.
|
|
459
494
|
expect(metadata).toContain('OPENCODE_DISABLE_AUTOUPDATE=1');
|
|
460
495
|
expect(metadata).not.toContain(token);
|
|
461
|
-
expect(metadata).not.toContain('OPENCODE_AUTH_CONTENT');
|
|
462
496
|
});
|
|
463
497
|
|
|
464
498
|
it('cannot steer the requests of the host through its own ~/.curlrc', async () => {
|
|
@@ -796,6 +830,37 @@ export function runEscapeSuite(title, { enabled = true, setup }) {
|
|
|
796
830
|
expect(JSON.stringify(journal)).not.toContain(MODEL_KEY);
|
|
797
831
|
}, 120_000);
|
|
798
832
|
|
|
833
|
+
// The obligation of STAGES.md on the first real credential behind the window: on a Linux
|
|
834
|
+
// Docker host every local process can reach the outer bridge, so the corridor and the
|
|
835
|
+
// window listen on the gatekeeper's inner address only. A neighbour on the outer network
|
|
836
|
+
// stands in for such a process here; the same measurement from a Linux host itself is in
|
|
837
|
+
// the module documentation.
|
|
838
|
+
it('cannot be reached on its outer address: the corridor and the window listen on the inner network only', async () => {
|
|
839
|
+
const addresses = await host.gatekeeperAddresses(spec.id);
|
|
840
|
+
expect(addresses.outer).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
|
841
|
+
const fromOutside = async (address, port) => (await host.probeFromOuterNetwork(spec.id, tcpProbe(address, port))).stdout.trim();
|
|
842
|
+
// Positive control: the neighbour's networking works, it reaches the stand-in upstream beside it.
|
|
843
|
+
expect(await fromOutside('upstream', 9000)).toMatch(/^connected:/);
|
|
844
|
+
for (const port of [GATEKEEPER_CORRIDOR_PORT, GATEKEEPER_WINDOW_PORT, GATEKEEPER_CONTROL_PORT]) {
|
|
845
|
+
expect(await fromOutside(addresses.outer, port), `outer address, port ${port}`).toBe('failed:ECONNREFUSED');
|
|
846
|
+
}
|
|
847
|
+
// And the space still reaches both by the gatekeeper's name, which resolves to the inner address.
|
|
848
|
+
expect(await probe(tcpProbe(GATEKEEPER_ALIAS, GATEKEEPER_CORRIDOR_PORT))).toBe(`connected:${addresses.inner}`);
|
|
849
|
+
expect(await probe(tcpProbe(GATEKEEPER_ALIAS, GATEKEEPER_WINDOW_PORT))).toBe(`connected:${addresses.inner}`);
|
|
850
|
+
}, 180_000);
|
|
851
|
+
|
|
852
|
+
it('gets no credential from the window for an opened domain, and none of its own reaches the upstream', async () => {
|
|
853
|
+
await channel().addGrant(spec.id, { id: 'open-registry', upstream: upstream.url });
|
|
854
|
+
const answer = await probe(windowProbe('/model/open-registry/-/package/left-pad', { 'x-api-key': 'sk-fake-inside', authorization: 'Bearer sk-fake-inside' }));
|
|
855
|
+
expect(answer).toMatch(/^200 /);
|
|
856
|
+
const seen = JSON.parse(answer.slice(4));
|
|
857
|
+
expect(seen.path).toBe('/v1/-/package/left-pad');
|
|
858
|
+
expect(seen.apiKey).toBe('none');
|
|
859
|
+
expect(seen.authorization).toBe('none');
|
|
860
|
+
expect(seen.headerNames.filter((name) => /key|auth|token/i.test(name))).toEqual([]);
|
|
861
|
+
expect(answer).not.toContain(MODEL_KEY);
|
|
862
|
+
}, 120_000);
|
|
863
|
+
|
|
799
864
|
it('reaches a real upstream over TLS, with no credential of anyone\'s', async () => {
|
|
800
865
|
publicAddress();
|
|
801
866
|
await channel().addGrant(spec.id, { id: 'tls', upstream: 'https://example.com/', header: 'authorization', secret: 'not-a-credential' });
|
|
@@ -877,35 +942,74 @@ export function runEscapeSuite(title, { enabled = true, setup }) {
|
|
|
877
942
|
// ever inside a space. Stage 2 puts none there, and this fails the day something does.
|
|
878
943
|
it('holds no long-lived credential of the user\'s, anywhere the space can read', async () => {
|
|
879
944
|
const search = () => shell(credentialSearch(spec.id));
|
|
945
|
+
const server = createSpaceServerChannel({ exec: place.exec });
|
|
946
|
+
const login = await server.request(spec.id, { method: 'POST', path: '/auth/session', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: await server.readToken(spec.id) }) });
|
|
947
|
+
expect(login.status).toBe(200);
|
|
948
|
+
const cookie = login.headers['set-cookie'][0].split(';')[0];
|
|
949
|
+
// What OpenCode itself holds: every connection of every integration, a stored login or a
|
|
950
|
+
// provider key it found in its own environment under one of its catalog's names.
|
|
951
|
+
const connections = async () => {
|
|
952
|
+
const answer = await server.request(spec.id, { path: '/api/integration', headers: { Cookie: cookie } });
|
|
953
|
+
expect(answer.status).toBe(200);
|
|
954
|
+
const integrations = JSON.parse(answer.body).data;
|
|
955
|
+
expect(integrations.length).toBeGreaterThan(0);
|
|
956
|
+
return integrations.flatMap((integration) => integration.connections.map((connection) => ({ integration: integration.id, ...connection })));
|
|
957
|
+
};
|
|
958
|
+
const database = `${SPACE_HOME}/.local/share/opencode/opencode.db`;
|
|
959
|
+
const storedCredentials = async () => {
|
|
960
|
+
const answer = await inside(['node', '--no-warnings', '-e', readCredentials(database)]);
|
|
961
|
+
expect(answer.code, answer.stderr).toBe(0);
|
|
962
|
+
return JSON.parse(answer.stdout);
|
|
963
|
+
};
|
|
880
964
|
const decoyRecord = `${SPACE_HOME}/.local/share/opencode/auth.json`;
|
|
881
965
|
const decoy = 'sk-decoy00000000000000000000';
|
|
882
966
|
|
|
967
|
+
// The clean search runs first. SQLite keeps a deleted row's bytes in its write-ahead log,
|
|
968
|
+
// so after the control below the database would hold the decoy's text for good.
|
|
969
|
+
const found = (await search()).stdout;
|
|
970
|
+
// Positive control of its own: the search ran and looked in all three places.
|
|
971
|
+
expect(found).toContain('--- files');
|
|
972
|
+
expect(found).toContain('--- environments');
|
|
973
|
+
expect(found).toContain('--- end');
|
|
974
|
+
expect(found).toContain('auth-record: absent');
|
|
975
|
+
expect(found.slice(found.indexOf('--- files'))).toBe('--- files\n--- environments\n--- end\n');
|
|
976
|
+
expect(await connections()).toEqual([]);
|
|
977
|
+
expect(await storedCredentials()).toEqual([]);
|
|
978
|
+
|
|
883
979
|
// Positive control: the same search, with a credential of each shape planted where one
|
|
884
|
-
// would live.
|
|
980
|
+
// would live. The oauth refresh record goes only into OpenCode's database, so a search
|
|
981
|
+
// that skips binary files fails here. The legacy auth.json, which OpenCode 2 imports once,
|
|
982
|
+
// holds nothing and only shows that its path is checked.
|
|
885
983
|
const planted = await shell([
|
|
886
984
|
`mkdir -p "$(dirname ${decoyRecord})"`,
|
|
887
|
-
`printf '{
|
|
985
|
+
`printf '{}' > ${decoyRecord}`,
|
|
888
986
|
`printf -- '-----BEGIN OPENSSH PRIVATE KEY-----' > "$HOME/decoy-key"`,
|
|
889
987
|
`DECOY_TOKEN=${decoy} sleep 30 & echo "decoy-pid:$!"`,
|
|
890
988
|
].join('; '));
|
|
891
989
|
const decoyPid = /decoy-pid:(\d+)/.exec(planted.stdout)?.[1];
|
|
990
|
+
let removal;
|
|
892
991
|
try {
|
|
992
|
+
const row = await inside(['node', '--no-warnings', '-e', plantCredential(database)]);
|
|
993
|
+
expect(row.code, row.stderr).toBe(0);
|
|
994
|
+
// OpenCode reads the row as a login, so this is where OpenCode 2 keeps them.
|
|
995
|
+
expect(await connections()).toEqual([{ integration: 'openai', type: 'credential', id: DECOY_CREDENTIAL_ID, label: 'default', method: 'oauth' }]);
|
|
996
|
+
// The custom provider's key is invisible to that listing, and the table read sees it.
|
|
997
|
+
expect(await storedCredentials()).toEqual([{ integration: 'openai', type: 'oauth' }, { integration: 'acme-llm', type: 'key' }]);
|
|
998
|
+
|
|
893
999
|
const control = (await search()).stdout;
|
|
894
1000
|
expect(control).toContain('auth-record: present');
|
|
895
|
-
expect(control).toMatch(/"refresh"\s*:\s*"/);
|
|
1001
|
+
expect(control).toMatch(/opencode\.db(-wal)?:"refresh"\s*:\s*"/);
|
|
896
1002
|
expect(control).toContain('BEGIN OPENSSH PRIVATE KEY');
|
|
897
1003
|
expect(control).toContain(decoy);
|
|
898
1004
|
} finally {
|
|
1005
|
+
removal = await inside(['node', '--no-warnings', '-e', removeCredential(database)]);
|
|
899
1006
|
await shell(`rm -f ${decoyRecord} "$HOME/decoy-key"; ${decoyPid ? `kill ${decoyPid} 2>/dev/null` : 'true'}; true`);
|
|
900
1007
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
expect(
|
|
905
|
-
expect(
|
|
906
|
-
expect(found).toContain('--- end');
|
|
907
|
-
expect(found).toContain('auth-record: absent');
|
|
908
|
-
expect(found.slice(found.indexOf('--- files'))).toBe('--- files\n--- environments\n--- end\n');
|
|
1008
|
+
// Checked after the finally, so a failed control above is the error that gets reported.
|
|
1009
|
+
expect(removal.code, removal.stderr).toBe(0);
|
|
1010
|
+
expect(removal.stdout).toBe('removed:2\n');
|
|
1011
|
+
expect(await connections()).toEqual([]);
|
|
1012
|
+
expect(await storedCredentials()).toEqual([]);
|
|
909
1013
|
}, 180_000);
|
|
910
1014
|
|
|
911
1015
|
// One space is not enough to say anything about two. This makes a second one and looks
|
|
@@ -96,7 +96,9 @@ export function hardenedContainerEntry({
|
|
|
96
96
|
PortBindings: {},
|
|
97
97
|
},
|
|
98
98
|
Mounts: mountList.map(volumeMountEntry),
|
|
99
|
-
|
|
99
|
+
// Like the real engine once the container runs: an address on its network. The fake gives it
|
|
100
|
+
// from the start, and the place's tests hold it to reading the address after the start.
|
|
101
|
+
NetworkSettings: { Networks: { [network]: { IPAddress: '172.19.0.2', ...(aliases.length > 0 ? { Aliases: aliases } : {}) } } },
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Grants on a real space, made through the journey on a real Docker daemon. Runs only with
|
|
2
|
+
// OPENCHAMBER_TEST_DOCKER=1. A real key goes behind the window of a space made through the
|
|
3
|
+
// journey's own routes; a stand-in provider on the space's outer network answers with the hash
|
|
4
|
+
// of the key it saw and never with the key; and the key is then looked for everywhere the host
|
|
5
|
+
// and the space keep things: the record on disk, the metadata of both containers, the space's
|
|
6
|
+
// environment, processes and files. OpenCode inside is asked for a turn against that provider,
|
|
7
|
+
// which proves that the provider configuration the host wrote sends it through the window.
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import express from 'express';
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { createGlobalMessageStreamHub } from '../../event-stream/global-hub.js';
|
|
19
|
+
import { registerOpenCodeProxy } from '../../opencode/proxy.js';
|
|
20
|
+
import { runCommand } from '../run-command.js';
|
|
21
|
+
import { createSpacesHost } from '../host.js';
|
|
22
|
+
import { SPACE_OPENCODE_CONFIG_PATH } from '../layout.js';
|
|
23
|
+
import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
|
|
24
|
+
|
|
25
|
+
const CREATE_TIMEOUT_MS = 25 * 60_000;
|
|
26
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
27
|
+
const until = async (check, timeoutMs = 10_000) => {
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
while (!(await check()) && Date.now() < deadline) await sleep(250);
|
|
30
|
+
return check();
|
|
31
|
+
};
|
|
32
|
+
const listen = (app) => new Promise((resolve) => {
|
|
33
|
+
const server = http.createServer(app);
|
|
34
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
35
|
+
});
|
|
36
|
+
const sha256 = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
37
|
+
const temporary = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
38
|
+
|
|
39
|
+
describe.skipIf(!LIVE_DOCKER_ENABLED)('grants on a real space: docker (live)', () => {
|
|
40
|
+
// Two real keys, made here. Neither ever enters the space; the stand-in provider sees them.
|
|
41
|
+
const TYPED_KEY = `sk-ant-live-${crypto.randomBytes(24).toString('hex')}`;
|
|
42
|
+
const ENV_KEY = `sk-oai-live-${crypto.randomBytes(24).toString('hex')}`;
|
|
43
|
+
let place;
|
|
44
|
+
let dispose = async () => {};
|
|
45
|
+
let liveHost;
|
|
46
|
+
let dataDir;
|
|
47
|
+
let project;
|
|
48
|
+
let host;
|
|
49
|
+
let hub;
|
|
50
|
+
let hostOpenCode;
|
|
51
|
+
let server;
|
|
52
|
+
let spaceId;
|
|
53
|
+
let upstream = { seen: async () => [], stop: async () => {} };
|
|
54
|
+
let session = null;
|
|
55
|
+
const url = (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`;
|
|
56
|
+
const inside = async (argv) => place.exec(spaceId, argv, { timeoutMs: 60_000 });
|
|
57
|
+
const shell = (script) => inside(['sh', '-c', script]);
|
|
58
|
+
const prefixed = (suffix, init) => fetch(url(`/api/spaces/${spaceId}${suffix}`), init);
|
|
59
|
+
const json = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
60
|
+
const listed = async () => (await host.journey.listSpaces({ access: true })).find((space) => space.id === spaceId);
|
|
61
|
+
const recordText = () => fs.readFileSync(path.join(dataDir, 'spaces', 'records', `${spaceId}.json`), 'utf8');
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
({ place, dispose, host: liveHost } = createLiveDockerPlace());
|
|
65
|
+
dataDir = temporary('openchamber-grants-live-');
|
|
66
|
+
// The registered project the space is made for: a small repository of its own.
|
|
67
|
+
project = temporary('openchamber-grants-project-');
|
|
68
|
+
const git = (...args) => runCommand('git', ['-C', project, ...args]);
|
|
69
|
+
await git('init', '-q', '.');
|
|
70
|
+
fs.writeFileSync(path.join(project, 'README.md'), 'A project for the grants live pass.\n');
|
|
71
|
+
await git('add', '.');
|
|
72
|
+
await git('-c', 'user.email=me@example.invalid', '-c', 'user.name=Me', 'commit', '-q', '-m', 'init');
|
|
73
|
+
|
|
74
|
+
host = createSpacesHost({
|
|
75
|
+
dataDir,
|
|
76
|
+
place,
|
|
77
|
+
listProjectDirectories: async () => [project],
|
|
78
|
+
hostEnvironment: { ...process.env, OPENAI_API_KEY: ENV_KEY },
|
|
79
|
+
logger: { warn: () => {} },
|
|
80
|
+
});
|
|
81
|
+
// The host's own OpenCode: nothing but an event stream that stays open.
|
|
82
|
+
const hostApp = express();
|
|
83
|
+
hostApp.get('/api/session', (_req, res) => res.json({ data: [], cursor: {} }));
|
|
84
|
+
hostApp.get('/api/event', (_req, res) => { res.setHeader('content-type', 'text/event-stream'); res.flushHeaders(); });
|
|
85
|
+
hostOpenCode = await listen(hostApp);
|
|
86
|
+
const hostPort = hostOpenCode.address().port;
|
|
87
|
+
hub = createGlobalMessageStreamHub({ buildOpenCodeUrl: (p) => `http://127.0.0.1:${hostPort}${p}`, getOpenCodeAuthHeaders: () => ({}), deltaCoalesceWindowMs: 0 });
|
|
88
|
+
const app = express();
|
|
89
|
+
app.use(host.middleware);
|
|
90
|
+
registerOpenCodeProxy(app, {
|
|
91
|
+
fs: {}, os: {}, path, OPEN_CODE_READY_GRACE_MS: 0,
|
|
92
|
+
getRuntime: () => ({ openCodePort: hostPort, isOpenCodeReady: true, openCodeNotReadySince: 0, isRestartingOpenCode: false }),
|
|
93
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
94
|
+
buildOpenCodeUrl: (p) => `http://127.0.0.1:${hostPort}${p}`,
|
|
95
|
+
ensureOpenCodeApiPrefix: () => {},
|
|
96
|
+
mergeSpaceSessionList: (payload) => host.mergeSessionList(payload),
|
|
97
|
+
spaceEventHub: hub,
|
|
98
|
+
});
|
|
99
|
+
server = await listen(app);
|
|
100
|
+
await host.startEvents(hub);
|
|
101
|
+
|
|
102
|
+
// The space, through the journey, as the create dialog will make it.
|
|
103
|
+
const answer = await host.journey.createSpace({ projectDirectory: project, name: 'Grants live', start: 'clean', network: { mode: 'allowlist', domains: [] } });
|
|
104
|
+
spaceId = answer.id;
|
|
105
|
+
expect(await until(async () => (await listed())?.state === 'running' && (await listed())?.step === null, CREATE_TIMEOUT_MS)).toBe(true);
|
|
106
|
+
upstream = await liveHost.startWindowUpstream(spaceId);
|
|
107
|
+
}, CREATE_TIMEOUT_MS);
|
|
108
|
+
|
|
109
|
+
afterAll(async () => {
|
|
110
|
+
host?.close();
|
|
111
|
+
hub?.stop();
|
|
112
|
+
for (const running of [server, hostOpenCode]) {
|
|
113
|
+
await new Promise((resolve) => { if (running) { running.closeAllConnections?.(); running.close(() => resolve()); } else resolve(); });
|
|
114
|
+
}
|
|
115
|
+
await upstream.stop();
|
|
116
|
+
await dispose();
|
|
117
|
+
for (const folder of [dataDir, project]) if (folder) fs.rmSync(folder, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('gives a typed key to the gatekeeper and keeps it out of the record, the containers and the space', async () => {
|
|
121
|
+
const granted = await host.journey.grantAccess(spaceId, { kind: 'model', provider: 'anthropic', upstream: upstream.url, secret: { kind: 'typed', value: TYPED_KEY } });
|
|
122
|
+
expect(granted.grant).toMatchObject({ id: 'anthropic', header: 'x-api-key', source: { kind: 'typed' }, url: 'http://gatekeeper:8080/model/anthropic' });
|
|
123
|
+
expect(JSON.stringify(granted)).not.toContain(TYPED_KEY);
|
|
124
|
+
|
|
125
|
+
// The window carries the real key: a request from inside reaches the stand-in with its hash.
|
|
126
|
+
const through = await shell('curl -s -H "x-api-key: space-window" http://gatekeeper:8080/model/anthropic/messages');
|
|
127
|
+
expect(through.code, through.stderr).toBe(0);
|
|
128
|
+
expect(JSON.parse(through.stdout)).toMatchObject({ path: '/v1/messages', apiKey: sha256(TYPED_KEY), authorization: 'none' });
|
|
129
|
+
|
|
130
|
+
// Not on the host's disk.
|
|
131
|
+
expect(recordText()).not.toContain(TYPED_KEY);
|
|
132
|
+
expect(JSON.parse(recordText()).grants).toEqual([{ kind: 'model', id: 'anthropic', provider: 'anthropic', upstream: upstream.url, header: 'x-api-key', source: { kind: 'typed' } }]);
|
|
133
|
+
// Not in what the runtime records about either container.
|
|
134
|
+
expect(await liveHost.spaceMetadata(spaceId)).not.toContain(TYPED_KEY);
|
|
135
|
+
expect(await liveHost.gatekeeperMetadata(spaceId)).not.toContain(TYPED_KEY);
|
|
136
|
+
// Not anywhere the space can look. The space prints what it has; the host searches it.
|
|
137
|
+
const insideTheSpace = await shell(`env; ps auxeww; find /home/space /tmp /spaces/${spaceId} -type f -size -1M -exec cat {} + 2>/dev/null; true`);
|
|
138
|
+
expect(insideTheSpace.stdout.length).toBeGreaterThan(0);
|
|
139
|
+
expect(insideTheSpace.stdout).not.toContain(TYPED_KEY);
|
|
140
|
+
// What the space does have: the configuration that points OpenCode at the window.
|
|
141
|
+
const config = JSON.parse((await inside(['cat', SPACE_OPENCODE_CONFIG_PATH])).stdout);
|
|
142
|
+
expect(config.provider.anthropic.options).toEqual({ baseURL: 'http://gatekeeper:8080/model/anthropic', apiKey: 'space-window' });
|
|
143
|
+
expect((await listed())).toMatchObject({ access: 'granted', needsAccess: [], grants: [expect.objectContaining({ id: 'anthropic' })] });
|
|
144
|
+
}, 120_000);
|
|
145
|
+
|
|
146
|
+
it('sends a turn of OpenCode inside through the window, where the stand-in sees the real key', async () => {
|
|
147
|
+
const directory = (await listed()).directory;
|
|
148
|
+
const created = await prefixed('/session', json({ location: { directory }, title: 'Through the window' }));
|
|
149
|
+
expect(created.status, await created.clone().text()).toBe(200);
|
|
150
|
+
session = (await created.json()).data;
|
|
151
|
+
const model = await prefixed(`/session/${session.id}/model`, { ...json({ model: { id: 'claude-sonnet-4-5', providerID: 'anthropic' } }), headers: { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(directory) } });
|
|
152
|
+
expect(model.status, await model.clone().text()).toBeLessThan(300);
|
|
153
|
+
const prompt = await prefixed(`/session/${session.id}/prompt`, { ...json({ id: `msg_${crypto.randomBytes(8).toString('hex')}`, text: 'Say hello.' }), headers: { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(directory) } });
|
|
154
|
+
expect(prompt.status, await prompt.clone().text()).toBeLessThan(300);
|
|
155
|
+
|
|
156
|
+
// A POST: the curl of the test above was a GET with the same key, and this must be OpenCode's own request.
|
|
157
|
+
const fromOpenCode = (entry) => entry.method === 'POST' && entry.apiKey === sha256(TYPED_KEY) && entry.path.startsWith('/v1/messages');
|
|
158
|
+
expect(await until(async () => (await upstream.seen()).some(fromOpenCode), 120_000)).toBe(true);
|
|
159
|
+
expect((await upstream.seen()).find(fromOpenCode)).toMatchObject({ authorization: 'none' });
|
|
160
|
+
expect(JSON.stringify(await upstream.seen())).not.toContain(TYPED_KEY);
|
|
161
|
+
}, 180_000);
|
|
162
|
+
|
|
163
|
+
it('measures whether OpenCode inside takes a provider granted after its instance started', async () => {
|
|
164
|
+
// A second provider, from the host's environment this time, granted while the instance of
|
|
165
|
+
// the space's project is already running. Whether that instance re-reads its configuration
|
|
166
|
+
// without a restart is what this measures; the outcome is reported, not asserted, because
|
|
167
|
+
// either way the key is out of the space and the window is the only way to the provider.
|
|
168
|
+
const granted = await host.journey.grantAccess(spaceId, { kind: 'model', provider: 'openai', upstream: upstream.url, secret: { kind: 'env', name: 'OPENAI_API_KEY' } });
|
|
169
|
+
expect(granted.grant).toMatchObject({ id: 'openai', header: 'authorization', source: { kind: 'env', name: 'OPENAI_API_KEY' } });
|
|
170
|
+
expect(recordText()).not.toContain(ENV_KEY);
|
|
171
|
+
const directory = (await listed()).directory;
|
|
172
|
+
const headers = { 'content-type': 'application/json', 'x-opencode-directory': encodeURIComponent(directory) };
|
|
173
|
+
await prefixed(`/session/${session.id}/model`, { ...json({ model: { id: 'gpt-4.1', providerID: 'openai' } }), headers });
|
|
174
|
+
await prefixed(`/session/${session.id}/prompt`, { ...json({ id: `msg_${crypto.randomBytes(8).toString('hex')}`, text: 'Say hello again.' }), headers });
|
|
175
|
+
const arrived = await until(async () => (await upstream.seen()).some((seen) => seen.authorization === sha256(`Bearer ${ENV_KEY}`)), 60_000);
|
|
176
|
+
// stderr, because vitest keeps a passing test's console.log to itself.
|
|
177
|
+
process.stderr.write(`[grants live] a provider granted after the instance started ${arrived ? 'was used on the next turn without a restart' : 'was NOT used on the next turn: the instance keeps the configuration it started with'}\n`);
|
|
178
|
+
expect(JSON.stringify(await upstream.seen())).not.toContain(ENV_KEY);
|
|
179
|
+
}, 180_000);
|
|
180
|
+
|
|
181
|
+
it('says the grants again after a stop and a start: the environment key yes, the typed key no', async () => {
|
|
182
|
+
await host.journey.stopSpace(spaceId);
|
|
183
|
+
const started = await host.journey.startSpace(spaceId);
|
|
184
|
+
expect(started).toMatchObject({ state: 'running', networkRestored: true, grantsRestored: ['openai'], needsAccess: ['anthropic'] });
|
|
185
|
+
expect(await listed()).toMatchObject({ access: 'needs_access', needsAccess: ['anthropic'] });
|
|
186
|
+
// The environment key works again at once; the typed one is gone with the gatekeeper's memory.
|
|
187
|
+
const bearer = await shell('curl -s http://gatekeeper:8080/model/openai/responses');
|
|
188
|
+
expect(JSON.parse(bearer.stdout)).toMatchObject({ path: '/v1/responses', authorization: sha256(`Bearer ${ENV_KEY}`) });
|
|
189
|
+
const missing = await shell('curl -s -o /dev/null -w "%{http_code}" http://gatekeeper:8080/model/anthropic/messages');
|
|
190
|
+
expect(missing.stdout).toBe('403');
|
|
191
|
+
// Granting once more brings it back.
|
|
192
|
+
await host.journey.grantAccess(spaceId, { kind: 'model', provider: 'anthropic', upstream: upstream.url, secret: { kind: 'typed', value: TYPED_KEY } });
|
|
193
|
+
expect(await listed()).toMatchObject({ access: 'granted', needsAccess: [] });
|
|
194
|
+
expect(recordText()).not.toContain(TYPED_KEY);
|
|
195
|
+
expect(recordText()).not.toContain(ENV_KEY);
|
|
196
|
+
}, 300_000);
|
|
197
|
+
});
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// one container would pass nothing here.
|
|
6
6
|
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import net from 'node:net';
|
|
8
10
|
|
|
9
11
|
import { SpaceError } from '../errors.js';
|
|
10
12
|
import { ROLE_GATEKEEPER, ROLE_SPACE, requireSpaceId } from '../labels.js';
|
|
@@ -57,6 +59,14 @@ export function createMemoryPlace({ id = 'memory' } = {}) {
|
|
|
57
59
|
// A container has a hostname of its own, as a real one does, so a caller can tell them apart.
|
|
58
60
|
containers.set(`${spaceId}:${target}`, { hostname: crypto.randomBytes(6).toString('hex') });
|
|
59
61
|
}
|
|
62
|
+
// The server inside, for `connect`: a loopback listener of this space's own that answers
|
|
63
|
+
// `/health` the way the real one does, with the space id so a caller can tell spaces apart.
|
|
64
|
+
const server = http.createServer((request, response) => {
|
|
65
|
+
response.setHeader('content-type', 'application/json');
|
|
66
|
+
response.end(JSON.stringify({ status: 'ok', isOpenCodeReady: true, spaceId, path: request.url }));
|
|
67
|
+
});
|
|
68
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
69
|
+
containers.get(`${spaceId}:${ROLE_SPACE}`).server = server;
|
|
60
70
|
},
|
|
61
71
|
list: async () => Array.from(spaces.values(), (space) => ({ ...space })),
|
|
62
72
|
exec: async (spaceId, argv, options = {}) => {
|
|
@@ -84,12 +94,25 @@ export function createMemoryPlace({ id = 'memory' } = {}) {
|
|
|
84
94
|
}
|
|
85
95
|
return [process.execPath, '-e', CONTAINER_PROGRAM, hostname];
|
|
86
96
|
},
|
|
97
|
+
// Added in stage 4a. A channel to the server inside, refused like `execArgv` when the space
|
|
98
|
+
// is stopped or gone: a plain socket to this space's own listener.
|
|
99
|
+
connect: async (spaceId) => {
|
|
100
|
+
const { server } = requireContainer(spaceId);
|
|
101
|
+
if (requireSpace(spaceId).state !== 'running') {
|
|
102
|
+
throw new SpaceError('space_not_running', `Space ${spaceId} is stopped`);
|
|
103
|
+
}
|
|
104
|
+
return net.connect({ host: '127.0.0.1', port: server.address().port });
|
|
105
|
+
},
|
|
87
106
|
stop: async (spaceId) => { requireSpace(spaceId).state = 'exited'; },
|
|
88
107
|
start: async (spaceId) => { requireSpace(spaceId).state = 'running'; },
|
|
89
108
|
remove: async (spaceId) => {
|
|
90
109
|
const removed = [];
|
|
91
110
|
for (const target of TARGETS) {
|
|
92
|
-
|
|
111
|
+
const container = containers.get(`${requireSpaceId(spaceId)}:${target}`);
|
|
112
|
+
if (!container) continue;
|
|
113
|
+
container.server?.close();
|
|
114
|
+
containers.delete(`${spaceId}:${target}`);
|
|
115
|
+
removed.push({ kind: 'container', name: `${spaceId}-${target}` });
|
|
93
116
|
}
|
|
94
117
|
if (spaces.delete(spaceId)) removed.push({ kind: 'space', name: spaceId });
|
|
95
118
|
return { removed, failed: [] };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const REQUIRED_PLACE_METHODS = ['check', 'create', 'list', 'exec', 'execArgv', 'stop', 'start', 'remove', 'verify'];
|
|
1
|
+
export const REQUIRED_PLACE_METHODS = ['check', 'create', 'list', 'exec', 'execArgv', 'connect', 'stop', 'start', 'remove', 'verify'];
|
|
2
2
|
|
|
3
3
|
export function createPlaceRegistry(initialPlaces = []) {
|
|
4
4
|
const places = new Map();
|