@agimon-ai/doompi-sandbox 0.0.1-alpha.101

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/dist/apiContracts.cjs +17 -0
  4. package/dist/apiContracts.cjs.map +1 -0
  5. package/dist/apiContracts.d.mts +53 -0
  6. package/dist/apiContracts.d.mts.map +1 -0
  7. package/dist/apiContracts.mjs +13 -0
  8. package/dist/apiContracts.mjs.map +1 -0
  9. package/dist/brokerHost-BeGtmnK8.mjs +302 -0
  10. package/dist/brokerHost-BeGtmnK8.mjs.map +1 -0
  11. package/dist/brokerHost-BzffXeb2.cjs +340 -0
  12. package/dist/brokerHost-BzffXeb2.cjs.map +1 -0
  13. package/dist/cockpitHarness.cjs +298 -0
  14. package/dist/cockpitHarness.cjs.map +1 -0
  15. package/dist/cockpitHarness.d.mts +26 -0
  16. package/dist/cockpitHarness.d.mts.map +1 -0
  17. package/dist/cockpitHarness.mjs +295 -0
  18. package/dist/cockpitHarness.mjs.map +1 -0
  19. package/dist/extensionService-BnIZjdJN.cjs +25 -0
  20. package/dist/extensionService-BnIZjdJN.cjs.map +1 -0
  21. package/dist/extensionService-DiVpVLn9.mjs +20 -0
  22. package/dist/extensionService-DiVpVLn9.mjs.map +1 -0
  23. package/dist/extensions/mcp.cjs +26 -0
  24. package/dist/extensions/mcp.cjs.map +1 -0
  25. package/dist/extensions/mcp.d.mts +5 -0
  26. package/dist/extensions/mcp.d.mts.map +1 -0
  27. package/dist/extensions/mcp.mjs +21 -0
  28. package/dist/extensions/mcp.mjs.map +1 -0
  29. package/dist/extensions/pi.cjs +85 -0
  30. package/dist/extensions/pi.cjs.map +1 -0
  31. package/dist/extensions/pi.d.mts +6 -0
  32. package/dist/extensions/pi.d.mts.map +1 -0
  33. package/dist/extensions/pi.mjs +80 -0
  34. package/dist/extensions/pi.mjs.map +1 -0
  35. package/dist/extensions/server.cjs +92 -0
  36. package/dist/extensions/server.cjs.map +1 -0
  37. package/dist/extensions/server.d.mts +5 -0
  38. package/dist/extensions/server.d.mts.map +1 -0
  39. package/dist/extensions/server.mjs +87 -0
  40. package/dist/extensions/server.mjs.map +1 -0
  41. package/dist/harness-CEuhOTr2.mjs +716 -0
  42. package/dist/harness-CEuhOTr2.mjs.map +1 -0
  43. package/dist/harness-fK8CSaU1.cjs +785 -0
  44. package/dist/harness-fK8CSaU1.cjs.map +1 -0
  45. package/dist/index-CBt-PlVG.d.mts +22 -0
  46. package/dist/index-CBt-PlVG.d.mts.map +1 -0
  47. package/dist/index.cjs +3 -0
  48. package/dist/index.d.mts +2 -0
  49. package/dist/index.mjs +2 -0
  50. package/dist/sandboxBridge-B3FRZkC2.cjs +131 -0
  51. package/dist/sandboxBridge-B3FRZkC2.cjs.map +1 -0
  52. package/dist/sandboxBridge-D6RAncmy.mjs +72 -0
  53. package/dist/sandboxBridge-D6RAncmy.mjs.map +1 -0
  54. package/dist/sandboxHarness-BIvuRMio.d.mts +38 -0
  55. package/dist/sandboxHarness-BIvuRMio.d.mts.map +1 -0
  56. package/dist/sandboxHarness.cjs +4 -0
  57. package/dist/sandboxHarness.d.mts +38 -0
  58. package/dist/sandboxHarness.d.mts.map +1 -0
  59. package/dist/sandboxHarness.mjs +2 -0
  60. package/dist/sandboxResources-5pvXyBW9.mjs +79 -0
  61. package/dist/sandboxResources-5pvXyBW9.mjs.map +1 -0
  62. package/dist/sandboxResources-Djxj5l1V.cjs +120 -0
  63. package/dist/sandboxResources-Djxj5l1V.cjs.map +1 -0
  64. package/llms.txt +11 -0
  65. package/package.json +132 -0
  66. package/src/prompts/doompi-use-sandbox/SKILL.md +14 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vuong Ngo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # @agimon-ai/doompi-sandbox
2
+
3
+ Run the DoomPi agent, extensions, and tools in a container while keeping the terminal on the host.
4
+
5
+ Part of the [DoomPi distribution](https://www.npmjs.com/package/@agimon-ai/doompi). You can also install it directly in [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 22.19.0 or newer
10
+ - `@earendil-works/pi-coding-agent` 0.85.0
11
+ - One of `docker`, `podman`, `nerdctl`, or `finch` on the host for sandboxed launches
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install npm:@agimon-ai/doompi-sandbox
17
+ ```
18
+
19
+ Pi loads the extension after installation. Container launches use the DoomPi launcher and require
20
+ this package in the selected major mode's layers, as shown below.
21
+
22
+ ## Enable the sandbox
23
+
24
+ This layer is still in development, so `doompi init` does not add it to any mode. Opt in by hand:
25
+ name it as a layer in `.doom/modes.yaml`, then list that layer on the mode you want it in.
26
+
27
+ ```yaml
28
+ layers:
29
+ sandbox:
30
+ packages:
31
+ - '@agimon-ai/doompi-sandbox'
32
+
33
+ majorMode:
34
+ copilot:
35
+ description: General-purpose coding mode.
36
+ layers: [team, ask-user, task, sandbox]
37
+ ```
38
+
39
+ `--sandbox` resolves the provider from the selected mode, so a mode without this layer reports that
40
+ no sandbox harness is available and refuses to launch rather than running unsandboxed.
41
+
42
+ ## Sandboxed launches
43
+
44
+ With this layer in the selected major mode, `doompi --sandbox` moves the whole session into a
45
+ disposable Linux container instead of running Pi on the host:
46
+
47
+ 1. The harness resolves this package's `./sandbox-harness` export and delegates the launch.
48
+ 2. The layer detects the first available engine in the order `docker`, `podman`, `nerdctl`,
49
+ `finch` (override with `DOOMPI_SANDBOX_ENGINE`), and builds a
50
+ `doompi-sandbox:v<version>-<digest>` image when that tag is not cached. The digest covers the
51
+ image definition and bridge source. The image installs DoomPi from the registry rather than
52
+ copying the host's platform-specific packages.
53
+ 3. It starts `docker run --rm` with the repository bind-mounted at its host path, an isolated
54
+ home volume, and a volume shadowing the repository's `.pi` package store. Inside, the
55
+ `doompi` launcher replays the same major mode, domains, profile, and Pi arguments.
56
+
57
+ The terminal stays attached, so the session looks like a host launch while bash, file edits,
58
+ extensions, MCP servers, and skills all execute inside the container. `DOOMPI_SANDBOX=1` marks
59
+ every sandboxed process; nesting is refused.
60
+
61
+ Only an allowlisted environment enters the container: terminal and locale variables, proxy
62
+ settings, and `DOOMPI_PRESET`. Everything else a shell accumulates stays on the host.
63
+
64
+ ## Engine and runtime selection
65
+
66
+ The harness invokes each supported engine with Docker-compatible arguments. The engine and its
67
+ VM, if any, must already be running on the host.
68
+
69
+ `DOOMPI_SANDBOX_RUN_FLAGS` passes extra options straight to the engine, which is how you select a
70
+ different isolation runtime without the layer having to know about it:
71
+
72
+ ```bash
73
+ DOOMPI_SANDBOX_RUN_FLAGS=--runtime=runsc doompi --sandbox
74
+ ```
75
+
76
+ Options must be self-contained (`--flag` or `--flag=value`). A separated value such as
77
+ `--runtime runsc` is refused, because a bare word cannot be told apart from an image name and
78
+ would silently launch a different container.
79
+
80
+ Alternative isolation runtimes such as Kata or Firecracker are not verified here. Check their
81
+ mount and broker-transport support before relying on them.
82
+
83
+ ## Workspace dev containers
84
+
85
+ A repository with a `.devcontainer/devcontainer.json` (or a root `.devcontainer.json`) uses that
86
+ container instead of the built-in image, because a workspace that describes its own container is
87
+ describing the toolchain its agent needs. The Dev Containers CLI brings it up, so the file decides
88
+ the image, features, mounts, run arguments and lifecycle hooks in full.
89
+
90
+ **Workspace devcontainer configuration is trusted executable input, not an isolation policy.**
91
+ It can mount your home directory or the Docker socket and run lifecycle hooks. The launch warns
92
+ about this mode. Set `DOOMPI_SANDBOX_DEVCONTAINER=0` to ignore the file and use the built-in image.
93
+
94
+ What still applies: the environment allowlist and the credential broker. The container receives the
95
+ session token rather than any real key, and reaches the broker over the host gateway.
96
+
97
+ Notes on this mode:
98
+
99
+ - DoomPi is installed into the container on first use with `npm install -g`, since a project's
100
+ container has no reason to carry it. The CLI reuses the container, so that cost is paid once for
101
+ its lifetime. A container without `npm` is reported rather than silently degraded.
102
+ - The session attaches through the engine rather than `devcontainer exec`, which allocates no
103
+ terminal and would break the full-screen TUI.
104
+ - OAuth callback ports cannot be published into a container this layer did not create, so `/login`
105
+ needs the ports declared as `appPort` in the devcontainer configuration.
106
+
107
+ ## Signing in from inside the sandbox
108
+
109
+ Subscription logins are not brokered, so `/login` runs inside the container. Two things make its
110
+ browser callback reachable:
111
+
112
+ - The launch publishes Pi's fixed callback ports back onto host loopback: 1455 (OpenAI Codex),
113
+ 1456 (Radius) and 53692 (Anthropic).
114
+ - `PI_OAUTH_CALLBACK_HOST=0.0.0.0` makes the in-container server bind every interface. A published
115
+ port reaches the container's external interface, never its loopback, so Pi's default bind would
116
+ refuse the connection. The redirect the provider sees is unchanged, still `localhost:<port>`.
117
+
118
+ Credentials land in the per-repository home volume, so a login survives later runs against the
119
+ same repository.
120
+
121
+ Ports already held by another process are skipped rather than failing the launch, and the run says
122
+ so. A second concurrent sandbox therefore starts normally but cannot complete a login. Providers
123
+ that bind an ephemeral callback port instead of a fixed one, OpenRouter among them, cannot be
124
+ published ahead of the flow and are not covered.
125
+
126
+ ## Terminal behavior
127
+
128
+ The container runs Pi's TUI directly with inherited stdio: `-i` is always passed and `-t` is added
129
+ when the host has a terminal. The harness does not proxy or re-render terminal frames.
130
+
131
+ Host integrations are the exception, because the process is not on your host:
132
+
133
+ | Feature | In a sandboxed session |
134
+ | --------------------- | ---------------------------------------------------------------------------- |
135
+ | External editor | Works. `nano` is installed and Pi falls back to it, editing the mounted file |
136
+ | Opening a browser | Not available; Pi prints the URL, which is how OAuth login proceeds |
137
+ | Clipboard integration | Not available; your terminal's own copy and paste still work |
138
+ | Desktop notifications | Not available |
139
+
140
+ A host `EDITOR` or `VISUAL` is deliberately not forwarded. Those commonly name a desktop
141
+ application, which would resolve to a binary the container does not have and would fail instead of
142
+ falling back to the editor that is there.
143
+
144
+ ## Provider credential broker
145
+
146
+ For supported providers with a host API key, the credential broker:
147
+
148
+ 1. Starts a broker on the host and grants the container exactly one route to it.
149
+ 2. Replaces the credential variable with a random per-session token.
150
+ 3. Redirects the provider's base URL at a loopback bridge inside the container, which forwards
151
+ raw bytes to the broker.
152
+
153
+ How the container reaches the broker depends on the engine:
154
+
155
+ | Host | Transport | Why |
156
+ | ----------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------- |
157
+ | Linux | Unix socket, bind-mounted, owner-only | Container and host share a kernel, so no port is needed |
158
+ | macOS, Windows, any VM engine | Loopback TCP through `host.docker.internal` | A container in a virtual machine cannot connect to a mounted host socket (ENOTSUP) |
159
+
160
+ On the TCP path the broker binds `127.0.0.1` on an ephemeral port, so it is never exposed beyond
161
+ the host, and the launch passes `--add-host` so the gateway name resolves on every engine. Another
162
+ local process could reach that port, and the session token is what stops it being useful.
163
+
164
+ The broker validates the token, swaps in the real key, and streams the provider response back.
165
+ Credentials for providers it cannot carry are withheld from the container rather than passed
166
+ through. Turn the whole mechanism off with `DOOMPI_SANDBOX_BROKER=0`.
167
+
168
+ ## In-session command
169
+
170
+ ```text
171
+ /doom-sandbox
172
+ ```
173
+
174
+ Reports whether the current session runs inside the sandbox container or directly on the host.
175
+
176
+ ## Current limits
177
+
178
+ - The broker carries a curated provider list. OAuth subscription logins are not brokered, so they
179
+ are performed inside the sandbox and stored in that container's home volume.
180
+ - A host `*_BASE_URL` override is dropped rather than used as the broker's upstream.
181
+ - Compositions that declare local workspace packages cannot load their platform-specific
182
+ dependencies inside the Linux container; use registry-installed layers for sandboxed work.
183
+ - `dpi --sandbox` runs the harness rather than the synchronized fast path. That path loads Pi
184
+ in-process against synchronized settings, and a fresh container has none to load, so the
185
+ session is composed from the repository the way a first run is.
186
+ - Container network access follows the engine's defaults and is not restricted yet.
187
+ - Mounted repositories are writable. Containers do not protect those files from agent edits,
188
+ and the container engine remains part of the trusted base.
189
+
190
+ ## Public API
191
+
192
+ ```ts
193
+ import { DefaultSandboxExtensionService, activateSandboxExtension } from '@agimon-ai/doompi-sandbox';
194
+ import { launchSandbox } from '@agimon-ai/doompi-sandbox/sandbox-harness';
195
+ ```
196
+
197
+ The Pi host entry is also available at:
198
+
199
+ ```text
200
+ @agimon-ai/doompi-sandbox/extensions/pi
201
+ ```
202
+
203
+ The service layer is host-neutral. `/extensions/pi` registers the in-session command;
204
+ `/sandbox-harness` owns launcher provisioning. The separate `/cockpit-harness` export supports
205
+ the web cockpit's container lifecycle and credential broker.
206
+
207
+ ## Development
208
+
209
+ Run from this package directory in the workspace:
210
+
211
+ ```bash
212
+ pnpm build
213
+ pnpm typecheck
214
+ pnpm test
215
+ pnpm lint
216
+ pnpm exec vibe-lint check .
217
+ npm pack --dry-run
218
+ ```
219
+
220
+ Maintained by [Agimon](https://agimon.ai/about).
221
+
222
+ ## License
223
+
224
+ MIT
@@ -0,0 +1,17 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ //#region src/schemas/apiContracts.ts
6
+ /** This facet contributes agent tools, commands or lifecycle only, with no separate HTTP or socket surface. */
7
+ const apiContracts = (0, require("@agimon-ai/doompi-core/apiContracts").defineApiContract)({
8
+ version: 1,
9
+ http: [],
10
+ sockets: [],
11
+ dynamic: []
12
+ });
13
+ //#endregion
14
+ exports.apiContracts = apiContracts;
15
+ exports.default = apiContracts;
16
+
17
+ //# sourceMappingURL=apiContracts.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiContracts.cjs","names":["defineApiContract"],"sources":["../src/schemas/apiContracts.ts"],"sourcesContent":["import { defineApiContract } from '@agimon-ai/doompi-core/apiContracts';\n\n/** This facet contributes agent tools, commands or lifecycle only, with no separate HTTP or socket surface. */\nexport const apiContracts = defineApiContract({ version: 1, http: [], sockets: [], dynamic: [] });\nexport default apiContracts;\n"],"mappings":";;;;;;AAGA,MAAa,gBAAA,gDAAeA,CAAAA,CAAAA,kBAAAA,CAAkB;CAAE,SAAS;CAAG,MAAM,CAAC;CAAG,SAAS,CAAC;CAAG,SAAS,CAAC;AAAE,CAAC"}
@@ -0,0 +1,53 @@
1
+ //#region src/schemas/apiContracts.d.ts
2
+ /** This facet contributes agent tools, commands or lifecycle only, with no separate HTTP or socket surface. */
3
+ export declare const apiContracts: {
4
+ version: 1;
5
+ protocols?: Record<string, string> | undefined;
6
+ schemas?: Record<string, boolean | object> | undefined;
7
+ http: {
8
+ id: string;
9
+ scope: "global" | "session" | "workspace";
10
+ basePath?: string | undefined;
11
+ path: string;
12
+ method: "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT";
13
+ description: string;
14
+ availability?: string | undefined;
15
+ authentication: "device" | "none" | "owner";
16
+ parameters?: {
17
+ name: string;
18
+ in: "header" | "path" | "query";
19
+ required: boolean;
20
+ schema: boolean | object;
21
+ }[] | undefined;
22
+ body?: {
23
+ contentType: string;
24
+ contentTypes?: string[] | undefined;
25
+ required: boolean;
26
+ schema: boolean | object;
27
+ } | undefined;
28
+ responses: Record<string, {
29
+ description: string;
30
+ contentType?: string | undefined;
31
+ schema?: boolean | object | undefined;
32
+ events?: Record<string, boolean | object> | undefined;
33
+ }>;
34
+ }[];
35
+ sockets: {
36
+ id: string;
37
+ scope: "global" | "session" | "workspace";
38
+ description: string;
39
+ availability?: string | undefined;
40
+ service: string;
41
+ member: string;
42
+ direction: "client-to-server" | "server-to-client";
43
+ kind: "channel" | "method" | "state";
44
+ input: boolean | object;
45
+ output?: boolean | object | undefined;
46
+ errors?: boolean | object | undefined;
47
+ }[];
48
+ dynamic: string[];
49
+ gaps?: string[] | undefined;
50
+ };
51
+ //#endregion
52
+ export { apiContracts as default };
53
+ //# sourceMappingURL=apiContracts.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiContracts.d.mts","names":[],"sources":["../src/schemas/apiContracts.ts"],"mappings":";;qBAGa"}
@@ -0,0 +1,13 @@
1
+ import { defineApiContract } from "@agimon-ai/doompi-core/apiContracts";
2
+ //#region src/schemas/apiContracts.ts
3
+ /** This facet contributes agent tools, commands or lifecycle only, with no separate HTTP or socket surface. */
4
+ const apiContracts = defineApiContract({
5
+ version: 1,
6
+ http: [],
7
+ sockets: [],
8
+ dynamic: []
9
+ });
10
+ //#endregion
11
+ export { apiContracts, apiContracts as default };
12
+
13
+ //# sourceMappingURL=apiContracts.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiContracts.mjs","names":[],"sources":["../src/schemas/apiContracts.ts"],"sourcesContent":["import { defineApiContract } from '@agimon-ai/doompi-core/apiContracts';\n\n/** This facet contributes agent tools, commands or lifecycle only, with no separate HTTP or socket surface. */\nexport const apiContracts = defineApiContract({ version: 1, http: [], sockets: [], dynamic: [] });\nexport default apiContracts;\n"],"mappings":";;;AAGA,MAAa,eAAe,kBAAkB;CAAE,SAAS;CAAG,MAAM,CAAC;CAAG,SAAS,CAAC;CAAG,SAAS,CAAC;AAAE,CAAC"}
@@ -0,0 +1,302 @@
1
+ import path from "node:path";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import http from "node:http";
6
+ import https from "node:https";
7
+ //#region src/services/brokerRoutes/index.ts
8
+ /**
9
+ * Providers the broker can terminate, mirroring Pi's own provider data.
10
+ *
11
+ * Curated rather than derived: Pi keeps base URLs in package-internal JSON that
12
+ * carries no stability promise. A provider missing here is simply not brokered,
13
+ * and its credential stays on the host.
14
+ */
15
+ const ROUTES = [
16
+ {
17
+ provider: "anthropic",
18
+ upstream: "https://api.anthropic.com",
19
+ hostKeyEnv: ["ANTHROPIC_API_KEY"]
20
+ },
21
+ {
22
+ provider: "openai",
23
+ upstream: "https://api.openai.com/v1",
24
+ hostKeyEnv: ["OPENAI_API_KEY"]
25
+ },
26
+ {
27
+ provider: "google",
28
+ upstream: "https://generativelanguage.googleapis.com/v1beta",
29
+ hostKeyEnv: ["GEMINI_API_KEY"]
30
+ },
31
+ {
32
+ provider: "groq",
33
+ upstream: "https://api.groq.com/openai/v1",
34
+ hostKeyEnv: ["GROQ_API_KEY"]
35
+ },
36
+ {
37
+ provider: "xai",
38
+ upstream: "https://api.x.ai/v1",
39
+ hostKeyEnv: ["XAI_API_KEY"]
40
+ },
41
+ {
42
+ provider: "openrouter",
43
+ upstream: "https://openrouter.ai/api/v1",
44
+ hostKeyEnv: ["OPENROUTER_API_KEY"]
45
+ },
46
+ {
47
+ provider: "deepseek",
48
+ upstream: "https://api.deepseek.com",
49
+ hostKeyEnv: ["DEEPSEEK_API_KEY"]
50
+ },
51
+ {
52
+ provider: "mistral",
53
+ upstream: "https://api.mistral.ai",
54
+ hostKeyEnv: ["MISTRAL_API_KEY"]
55
+ },
56
+ {
57
+ provider: "together",
58
+ upstream: "https://api.together.ai/v1",
59
+ hostKeyEnv: ["TOGETHER_API_KEY"]
60
+ },
61
+ {
62
+ provider: "cerebras",
63
+ upstream: "https://api.cerebras.ai/v1",
64
+ hostKeyEnv: ["CEREBRAS_API_KEY"]
65
+ },
66
+ {
67
+ provider: "moonshotai",
68
+ upstream: "https://api.moonshot.ai/v1",
69
+ hostKeyEnv: ["MOONSHOT_API_KEY"]
70
+ },
71
+ {
72
+ provider: "zai",
73
+ upstream: "https://api.z.ai/api/coding/paas/v4",
74
+ hostKeyEnv: ["ZAI_API_KEY"]
75
+ }
76
+ ];
77
+ /** Selects the providers this host can actually broker for a session. */
78
+ function resolveBrokeredCredentials(environment) {
79
+ const resolved = [];
80
+ for (const route of ROUTES) for (const envName of route.hostKeyEnv) {
81
+ const value = environment[envName]?.trim();
82
+ if (value) {
83
+ resolved.push({
84
+ route,
85
+ envName,
86
+ value
87
+ });
88
+ break;
89
+ }
90
+ }
91
+ return resolved;
92
+ }
93
+ //#endregion
94
+ //#region src/services/brokerServer/index.ts
95
+ /** Headers a provider SDK may carry its credential in. */
96
+ const CREDENTIAL_HEADERS = [
97
+ "x-api-key",
98
+ "authorization",
99
+ "x-goog-api-key"
100
+ ];
101
+ /** Query parameter some Google clients use instead of a header. */
102
+ const CREDENTIAL_QUERY = "key";
103
+ const BEARER_PREFIX = "bearer ";
104
+ /** Connection-scoped headers that must not cross to the upstream request. */
105
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
106
+ "connection",
107
+ "keep-alive",
108
+ "proxy-authenticate",
109
+ "proxy-authorization",
110
+ "te",
111
+ "trailer",
112
+ "transfer-encoding",
113
+ "upgrade",
114
+ "host",
115
+ "content-length"
116
+ ]);
117
+ const PATH_PATTERN = /^\/([^/?]+)(.*)$/;
118
+ function matchesToken(candidate, token) {
119
+ const left = Buffer.from(candidate);
120
+ const right = Buffer.from(token);
121
+ return left.length === right.length && timingSafeEqual(left, right);
122
+ }
123
+ function credentialValue(headerValue) {
124
+ return headerValue.toLowerCase().startsWith(BEARER_PREFIX) ? headerValue.slice(7) : headerValue;
125
+ }
126
+ function withCredential(headerValue, realKey) {
127
+ return headerValue.toLowerCase().startsWith(BEARER_PREFIX) ? `Bearer ${realKey}` : realKey;
128
+ }
129
+ /**
130
+ * Rewrites the request's credential, proving the caller holds the session token.
131
+ *
132
+ * Which header carries the key differs per provider SDK, so every candidate is
133
+ * checked rather than assumed. Returns undefined when nothing presented the
134
+ * token, which is what makes an unauthenticated caller indistinguishable from a
135
+ * misrouted one.
136
+ */
137
+ function swapCredential(headers, search, token, realKey) {
138
+ const forwarded = {};
139
+ let authenticated = false;
140
+ for (const [name, value] of Object.entries(headers)) {
141
+ if (value === void 0 || HOP_BY_HOP.has(name)) continue;
142
+ const single = Array.isArray(value) ? value[0] : value;
143
+ if (CREDENTIAL_HEADERS.includes(name) && single !== void 0) {
144
+ if (!matchesToken(credentialValue(single), token)) return void 0;
145
+ forwarded[name] = withCredential(single, realKey);
146
+ authenticated = true;
147
+ continue;
148
+ }
149
+ forwarded[name] = value;
150
+ }
151
+ const queryKey = search.get(CREDENTIAL_QUERY);
152
+ if (queryKey !== null) {
153
+ if (!matchesToken(queryKey, token)) return void 0;
154
+ search.set(CREDENTIAL_QUERY, realKey);
155
+ authenticated = true;
156
+ }
157
+ return authenticated ? forwarded : void 0;
158
+ }
159
+ function reject(response, status, message) {
160
+ response.writeHead(status, { "content-type": "application/json" });
161
+ response.end(JSON.stringify({ error: {
162
+ type: "doompi_broker",
163
+ message
164
+ } }));
165
+ }
166
+ /**
167
+ * Terminates provider calls from a sandboxed session on the host.
168
+ *
169
+ * The container never holds a real credential: it presents the session token,
170
+ * and only a request that proves possession of it is forwarded upstream with
171
+ * the host's key attached. Bodies stream in both directions so token-by-token
172
+ * responses are not buffered.
173
+ */
174
+ function createBrokerServer(options) {
175
+ const requestUpstream = options.requestUpstream ?? https.request;
176
+ return http.createServer((request, response) => {
177
+ const match = PATH_PATTERN.exec(request.url ?? "");
178
+ const provider = match?.[1];
179
+ const credential = provider ? options.credentials.get(provider) : void 0;
180
+ if (!match || !credential) {
181
+ options.onDenied?.(`unroutable provider path ${request.url ?? ""}`);
182
+ reject(response, 404, "Unknown provider for this sandbox session.");
183
+ return;
184
+ }
185
+ const target = new URL(`${credential.route.upstream}${match[2] || ""}`);
186
+ const headers = swapCredential(request.headers, target.searchParams, options.token, credential.value);
187
+ if (!headers) {
188
+ options.onDenied?.(`missing or invalid session token for ${credential.route.provider}`);
189
+ reject(response, 401, "This sandbox session did not present its broker token.");
190
+ return;
191
+ }
192
+ const upstream = requestUpstream(target, {
193
+ method: request.method,
194
+ headers: {
195
+ ...headers,
196
+ host: target.host
197
+ }
198
+ }, (upstreamResponse) => {
199
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
200
+ upstreamResponse.pipe(response);
201
+ });
202
+ upstream.on("error", (error) => {
203
+ options.onDenied?.(`upstream ${credential.route.provider} failed: ${error.message}`);
204
+ if (!response.headersSent) reject(response, 502, "The provider could not be reached from the host.");
205
+ else response.destroy();
206
+ });
207
+ request.on("aborted", () => upstream.destroy());
208
+ request.pipe(upstream);
209
+ });
210
+ }
211
+ //#endregion
212
+ //#region src/services/brokerHost/index.ts
213
+ const SOCKET_DIRECTORY_PREFIX = "doompi-broker-";
214
+ const SOCKET_FILE_NAME = "broker.sock";
215
+ const OWNER_ONLY_DIRECTORY = 448;
216
+ const TOKEN_BYTES = 32;
217
+ const LOOPBACK = "127.0.0.1";
218
+ const LINUX_PLATFORM = "linux";
219
+ const EPHEMERAL_PORT = 0;
220
+ /**
221
+ * Starts the host-side provider broker for one sandboxed session.
222
+ *
223
+ * Answers undefined when the host holds no brokerable credential, which keeps
224
+ * a session that authenticates some other way on the unbrokered path instead
225
+ * of failing it.
226
+ */
227
+ async function startBroker(options) {
228
+ const resolved = resolveBrokeredCredentials(options.environment);
229
+ if (resolved.length === 0) return void 0;
230
+ const token = randomBytes(TOKEN_BYTES).toString("base64url");
231
+ const server = createBrokerServer({
232
+ credentials: new Map(resolved.map((credential) => [credential.route.provider, credential])),
233
+ token,
234
+ onDenied: options.onDenied
235
+ });
236
+ const { endpoint, dispose } = (options.platform ?? process.platform) === LINUX_PLATFORM && options.forceLoopback !== true ? await listenOnSocket(server, options.socketDirectory) : await listenOnLoopback(server);
237
+ return {
238
+ endpoint,
239
+ token,
240
+ providers: resolved.map((credential) => credential.route.provider),
241
+ withheldEnv: resolved.map((credential) => credential.envName),
242
+ stop: () => stopBroker(server, dispose)
243
+ };
244
+ }
245
+ /**
246
+ * Native Linux shares a kernel with the container, so a bind-mounted socket
247
+ * needs no port and is reachable as an ordinary file.
248
+ */
249
+ async function listenOnSocket(server, fixedDirectory) {
250
+ const socketDirectory = fixedDirectory ?? fs.mkdtempSync(path.join(os.tmpdir(), SOCKET_DIRECTORY_PREFIX));
251
+ fs.mkdirSync(socketDirectory, { recursive: true });
252
+ fs.chmodSync(socketDirectory, OWNER_ONLY_DIRECTORY);
253
+ const socketPath = path.join(socketDirectory, SOCKET_FILE_NAME);
254
+ fs.rmSync(socketPath, { force: true });
255
+ await new Promise((resolve, reject) => {
256
+ server.once("error", reject);
257
+ server.listen(socketPath, () => resolve());
258
+ });
259
+ return {
260
+ endpoint: {
261
+ transport: "unix",
262
+ socketDirectory
263
+ },
264
+ dispose: () => fixedDirectory ? fs.rmSync(socketPath, { force: true }) : fs.rmSync(socketDirectory, {
265
+ recursive: true,
266
+ force: true
267
+ })
268
+ };
269
+ }
270
+ /**
271
+ * Everywhere else the container runs in its own virtual machine, which cannot
272
+ * connect to a host unix socket even when the file is shared through: the
273
+ * connect fails with ENOTSUP. Loopback keeps the broker off the network while
274
+ * still being reachable through the engine's host gateway, and the session
275
+ * token is what stops another local process from using it.
276
+ */
277
+ async function listenOnLoopback(server) {
278
+ await new Promise((resolve, reject) => {
279
+ server.once("error", reject);
280
+ server.listen(EPHEMERAL_PORT, LOOPBACK, () => resolve());
281
+ });
282
+ const address = server.address();
283
+ if (address === null || typeof address === "string") throw new Error("The broker did not report a listening port.");
284
+ return {
285
+ endpoint: {
286
+ transport: "tcp",
287
+ port: address.port
288
+ },
289
+ dispose: () => void 0
290
+ };
291
+ }
292
+ async function stopBroker(server, dispose) {
293
+ await new Promise((resolve) => {
294
+ server.close(() => resolve());
295
+ server.closeAllConnections();
296
+ });
297
+ dispose();
298
+ }
299
+ //#endregion
300
+ export { startBroker as t };
301
+
302
+ //# sourceMappingURL=brokerHost-BeGtmnK8.mjs.map