open-managed-agents 0.1.0
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/LICENSE +93 -0
- package/README.md +248 -0
- package/bin/oma.mjs +499 -0
- package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
- package/dist/scripts/alpha-smoke-models.mjs +92 -0
- package/dist/scripts/alpha-smoke.mjs +581 -0
- package/dist/scripts/oma-doctor.js +230 -0
- package/dist/scripts/oma-models.js +299 -0
- package/dist/scripts/oma-workspaces.js +133 -0
- package/dist/src/control-plane/admin/auth.js +43 -0
- package/dist/src/control-plane/admin/routes.js +76 -0
- package/dist/src/control-plane/admin/service.js +102 -0
- package/dist/src/control-plane/admission.js +90 -0
- package/dist/src/control-plane/agents/routes.js +63 -0
- package/dist/src/control-plane/agents/service.js +687 -0
- package/dist/src/control-plane/agents/store.js +391 -0
- package/dist/src/control-plane/agents/types.js +1 -0
- package/dist/src/control-plane/api-constants.js +3 -0
- package/dist/src/control-plane/app.js +907 -0
- package/dist/src/control-plane/console/auth.js +68 -0
- package/dist/src/control-plane/console/static.js +112 -0
- package/dist/src/control-plane/deployment-runtime-config.js +328 -0
- package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
- package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
- package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
- package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
- package/dist/src/control-plane/deployment-storage.js +354 -0
- package/dist/src/control-plane/egress/image.js +48 -0
- package/dist/src/control-plane/egress/policy.js +571 -0
- package/dist/src/control-plane/egress/presets.js +77 -0
- package/dist/src/control-plane/egress/proxy.js +72 -0
- package/dist/src/control-plane/egress/ssrf.js +106 -0
- package/dist/src/control-plane/egress/utils/debug.js +14 -0
- package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
- package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
- package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
- package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
- package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
- package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
- package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
- package/dist/src/control-plane/environments/routes.js +54 -0
- package/dist/src/control-plane/environments/service.js +112 -0
- package/dist/src/control-plane/environments/store.js +132 -0
- package/dist/src/control-plane/environments/types.js +1 -0
- package/dist/src/control-plane/errors.js +81 -0
- package/dist/src/control-plane/events/broadcaster.js +217 -0
- package/dist/src/control-plane/events/constants.js +2 -0
- package/dist/src/control-plane/events/persist.js +39 -0
- package/dist/src/control-plane/events/request.js +190 -0
- package/dist/src/control-plane/events/routes.js +159 -0
- package/dist/src/control-plane/events/runtime-helpers.js +134 -0
- package/dist/src/control-plane/events/service.js +1985 -0
- package/dist/src/control-plane/events/session-guards.js +29 -0
- package/dist/src/control-plane/events/sse.js +6 -0
- package/dist/src/control-plane/events/store.js +772 -0
- package/dist/src/control-plane/events/tool-persistence.js +102 -0
- package/dist/src/control-plane/events/types.js +21 -0
- package/dist/src/control-plane/files/routes.js +121 -0
- package/dist/src/control-plane/files/service.js +65 -0
- package/dist/src/control-plane/files/store-common.js +87 -0
- package/dist/src/control-plane/files/store-local.js +488 -0
- package/dist/src/control-plane/files/store-memory.js +270 -0
- package/dist/src/control-plane/files/store.js +2 -0
- package/dist/src/control-plane/files/types.js +7 -0
- package/dist/src/control-plane/http.js +25 -0
- package/dist/src/control-plane/ids.js +43 -0
- package/dist/src/control-plane/logging.js +263 -0
- package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
- package/dist/src/control-plane/models/catalog.js +78 -0
- package/dist/src/control-plane/models/config-security.js +376 -0
- package/dist/src/control-plane/models/deployment-config.js +93 -0
- package/dist/src/control-plane/models/routes.js +32 -0
- package/dist/src/control-plane/models/service.js +138 -0
- package/dist/src/control-plane/observability/instruments.js +65 -0
- package/dist/src/control-plane/observability/metrics.js +125 -0
- package/dist/src/control-plane/observability/routes.js +95 -0
- package/dist/src/control-plane/openapi/document.js +282 -0
- package/dist/src/control-plane/openapi/routes.js +78 -0
- package/dist/src/control-plane/request-idempotency.js +85 -0
- package/dist/src/control-plane/secrets/envelope.js +132 -0
- package/dist/src/control-plane/secrets/master-key.js +44 -0
- package/dist/src/control-plane/secrets/routes.js +20 -0
- package/dist/src/control-plane/secrets/service.js +71 -0
- package/dist/src/control-plane/secrets/store.js +212 -0
- package/dist/src/control-plane/secrets/types.js +3 -0
- package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
- package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
- package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
- package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
- package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
- package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
- package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
- package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
- package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
- package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
- package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
- package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
- package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
- package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
- package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
- package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
- package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
- package/dist/src/control-plane/sessions/pi/translator.js +100 -0
- package/dist/src/control-plane/sessions/request.js +164 -0
- package/dist/src/control-plane/sessions/resources.js +63 -0
- package/dist/src/control-plane/sessions/routes.js +88 -0
- package/dist/src/control-plane/sessions/serialize.js +24 -0
- package/dist/src/control-plane/sessions/service.js +733 -0
- package/dist/src/control-plane/sessions/store.js +538 -0
- package/dist/src/control-plane/sessions/types.js +1 -0
- package/dist/src/control-plane/skills/archive.js +169 -0
- package/dist/src/control-plane/skills/routes.js +41 -0
- package/dist/src/control-plane/skills/service.js +29 -0
- package/dist/src/control-plane/skills/store.js +233 -0
- package/dist/src/control-plane/skills/types.js +5 -0
- package/dist/src/control-plane/sqlite-transaction.js +15 -0
- package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
- package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
- package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
- package/dist/src/control-plane/vaults/routes.js +66 -0
- package/dist/src/control-plane/vaults/service.js +553 -0
- package/dist/src/control-plane/vaults/store.js +691 -0
- package/dist/src/control-plane/vaults/types.js +1 -0
- package/dist/src/control-plane/wake-loop.js +65 -0
- package/dist/src/control-plane/wiring.js +92 -0
- package/dist/src/control-plane/workspace.js +4 -0
- package/dist/src/control-plane/workspaces/store.js +158 -0
- package/dist/src/egress-proxy-main.js +103 -0
- package/dist/src/main.js +124 -0
- package/dist/src/types/agents.js +1 -0
- package/dist/src/types/common.js +1 -0
- package/dist/src/types/environments.js +1 -0
- package/dist/src/types/events.js +121 -0
- package/dist/src/types/files.js +1 -0
- package/dist/src/types/json.js +19 -0
- package/dist/src/types/sessions.js +1 -0
- package/dist/ui/managed-agents-console/README.md +52 -0
- package/dist/ui/managed-agents-console/docs/agents.md +23 -0
- package/dist/ui/managed-agents-console/docs/console.md +23 -0
- package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
- package/dist/ui/managed-agents-console/docs/environments.md +27 -0
- package/dist/ui/managed-agents-console/docs/events.md +19 -0
- package/dist/ui/managed-agents-console/docs/files.md +17 -0
- package/dist/ui/managed-agents-console/docs/github.md +11 -0
- package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
- package/dist/ui/managed-agents-console/docs/memory.md +11 -0
- package/dist/ui/managed-agents-console/docs/migration.md +17 -0
- package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
- package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
- package/dist/ui/managed-agents-console/docs/overview.md +41 -0
- package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
- package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
- package/dist/ui/managed-agents-console/docs/reference.md +27 -0
- package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
- package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
- package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
- package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
- package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
- package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
- package/dist/ui/managed-agents-console/docs/skills.md +15 -0
- package/dist/ui/managed-agents-console/docs/tools.md +23 -0
- package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
- package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
- package/dist/ui/managed-agents-console/index.html +47 -0
- package/dist/ui/managed-agents-console/serve.mjs +116 -0
- package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
- package/dist/ui/managed-agents-console/src/api.js +1043 -0
- package/dist/ui/managed-agents-console/src/app.jsx +633 -0
- package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
- package/dist/ui/managed-agents-console/src/console.css +733 -0
- package/dist/ui/managed-agents-console/src/data.js +188 -0
- package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
- package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
- package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
- package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
- package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
- package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
- package/dist/ui/managed-agents-console/src/sse.js +185 -0
- package/dist/ui/managed-agents-console/src/states.jsx +90 -0
- package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
- package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
- package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
- package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
- package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
- package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
- package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
- package/dist/ui/openapi-docs/VENDOR.md +27 -0
- package/dist/ui/openapi-docs/index.html +16 -0
- package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
- package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
- package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
- package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, open, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { posix } from "node:path";
|
|
6
|
+
import { join as joinHostPath } from "node:path";
|
|
7
|
+
import { CMA_GLOB_READY_MARKER, CmaGlobReadinessFilter, CmaGlobStreamCollector, cmaGlobToRipgrepGlob, compileCmaGlob, } from "./cma-glob.js";
|
|
8
|
+
import { CMA_GREP_READY_MARKER, CmaGrepStreamCollector, } from "./cma-grep.js";
|
|
9
|
+
import { matchGlob } from "./glob.js";
|
|
10
|
+
import { createEgressSidecar, egressProxyUrl, reapEgressSidecars, } from "./docker-egress.js";
|
|
11
|
+
import { createSandboxInvocationStats, createSandboxToolDefinitions, recordSandboxInvocation, } from "./provider.js";
|
|
12
|
+
import { MAX_SESSION_OUTPUT_BYTES, MAX_SESSION_OUTPUT_FILE_BYTES, MAX_SESSION_OUTPUT_FILES, } from "../../../files/types.js";
|
|
13
|
+
import { DEFAULT_OMA_SANDBOX_IMAGE } from "./image.js";
|
|
14
|
+
export const DEFAULT_DOCKER_SANDBOX_IMAGE = DEFAULT_OMA_SANDBOX_IMAGE;
|
|
15
|
+
const DEFAULT_WORKSPACE = "/workspace";
|
|
16
|
+
const DEFAULT_UPLOADS_PATH = "/mnt/session/uploads";
|
|
17
|
+
const DEFAULT_OUTPUTS_PATH = "/mnt/session/outputs";
|
|
18
|
+
const DEFAULT_SKILLS_PATH = "/workspace/skills";
|
|
19
|
+
const DEFAULT_MEMORY = "1g";
|
|
20
|
+
const DEFAULT_CPUS = "1";
|
|
21
|
+
const DEFAULT_PIDS_LIMIT = "128";
|
|
22
|
+
const DEFAULT_TMPFS_SIZE = "256m";
|
|
23
|
+
const DEFAULT_OUTPUTS_TMPFS_SIZE = "100m";
|
|
24
|
+
const DEFAULT_OPERATION_TIMEOUT_MS = 10_000;
|
|
25
|
+
export const DEFAULT_DOCKER_STARTUP_TIMEOUT_MS = 5 * 60_000;
|
|
26
|
+
const SANDBOX_LABEL_KEY = "open-managed-agents.sandbox";
|
|
27
|
+
const SANDBOX_LABEL_VALUE = "docker-local";
|
|
28
|
+
const OWNER_LABEL_KEY = "open-managed-agents.owner";
|
|
29
|
+
const OWNER_LABEL_VALUE = "open-managed-agents";
|
|
30
|
+
const BASH_DISPATCH_PREFIX = "__OMA_DISPATCHED__:";
|
|
31
|
+
const BASH_TERMINAL_PREFIX = "__OMA_TERMINAL__:";
|
|
32
|
+
export function createDockerSandboxProviderFactory(opts = {}) {
|
|
33
|
+
const { egress: egressConfig, ...baseOpts } = opts;
|
|
34
|
+
let swept = false;
|
|
35
|
+
let sweepPromise;
|
|
36
|
+
return async (workspaceId, sessionId, context) => {
|
|
37
|
+
if (!swept && opts.reapStaleContainersOlderThanMs !== undefined) {
|
|
38
|
+
const olderThanMs = opts.reapStaleContainersOlderThanMs;
|
|
39
|
+
sweepPromise ??= reapDockerSandboxContainers({
|
|
40
|
+
dockerCommand: opts.dockerCommand,
|
|
41
|
+
olderThanMs,
|
|
42
|
+
}).then((count) => {
|
|
43
|
+
if (count > 0)
|
|
44
|
+
opts.onReaped?.(count);
|
|
45
|
+
// Same startup sweep reaps egress sidecars a crash orphaned:
|
|
46
|
+
// containers, their --internal networks, and stale temp roots.
|
|
47
|
+
reapEgressSidecars({ dockerCommand: opts.dockerCommand, olderThanMs });
|
|
48
|
+
swept = true;
|
|
49
|
+
}, (error) => {
|
|
50
|
+
sweepPromise = undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
await sweepPromise;
|
|
54
|
+
}
|
|
55
|
+
const egress = egressConfig
|
|
56
|
+
? await createSessionEgress(workspaceId, sessionId, egressConfig, baseOpts, context)
|
|
57
|
+
: undefined;
|
|
58
|
+
try {
|
|
59
|
+
return await createDockerSandboxProvider(workspaceId, sessionId, {
|
|
60
|
+
...baseOpts,
|
|
61
|
+
...(egress === undefined ? {} : { egress }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// createDockerSandboxProvider disposes on its own run failure, but a
|
|
66
|
+
// throw before that point (option validation) would leak the sidecar.
|
|
67
|
+
// dispose() is idempotent, so the overlap is harmless.
|
|
68
|
+
egress?.dispose();
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the session's egress bundle and, when the environment grants
|
|
75
|
+
* egress, stand up the per-session sidecar (plan 0117e-3). Returns undefined
|
|
76
|
+
* for a no-egress environment — the sandbox stays at --network none.
|
|
77
|
+
*/
|
|
78
|
+
async function createSessionEgress(workspaceId, sessionId, egressConfig, baseOpts, context) {
|
|
79
|
+
const resolved = await egressConfig.resolveEgressBundle(workspaceId, sessionId, context);
|
|
80
|
+
if (resolved === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
const sidecar = await createEgressSidecar({
|
|
83
|
+
dockerCommand: baseOpts.dockerCommand ?? "docker",
|
|
84
|
+
sessionId,
|
|
85
|
+
bundle: resolved.bundle,
|
|
86
|
+
sidecarImage: egressConfig.sidecarImage,
|
|
87
|
+
...(egressConfig.sidecarRepoMount === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { sidecarRepoMount: egressConfig.sidecarRepoMount }),
|
|
90
|
+
operationTimeoutMs: baseOpts.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
wiring: {
|
|
94
|
+
networkName: sidecar.networkName,
|
|
95
|
+
caCertDirHostPath: sidecar.sharedDirHostPath,
|
|
96
|
+
proxyUrl: egressProxyUrl(sidecar),
|
|
97
|
+
sandboxEnv: resolved.sandboxEnv,
|
|
98
|
+
},
|
|
99
|
+
dispose: sidecar.dispose,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export async function createDockerSandboxProvider(workspaceId, sessionId, opts = {}) {
|
|
103
|
+
const resolved = resolveDockerOptions(opts);
|
|
104
|
+
const containerName = dockerContainerName(resolved.containerNamePrefix, workspaceId, sessionId);
|
|
105
|
+
try {
|
|
106
|
+
await dockerChecked(resolved.dockerCommand, buildDockerRunArgs({
|
|
107
|
+
containerName,
|
|
108
|
+
workspacePath: resolved.workspacePath,
|
|
109
|
+
uploadsPath: resolved.uploadsPath,
|
|
110
|
+
outputsPath: resolved.outputsPath,
|
|
111
|
+
image: resolved.image,
|
|
112
|
+
memory: resolved.memory,
|
|
113
|
+
cpus: resolved.cpus,
|
|
114
|
+
pidsLimit: resolved.pidsLimit,
|
|
115
|
+
tmpfsSize: resolved.tmpfsSize,
|
|
116
|
+
outputsTmpfsSize: resolved.outputsTmpfsSize,
|
|
117
|
+
egress: opts.egress?.wiring,
|
|
118
|
+
labels: {
|
|
119
|
+
[SANDBOX_LABEL_KEY]: SANDBOX_LABEL_VALUE,
|
|
120
|
+
[OWNER_LABEL_KEY]: OWNER_LABEL_VALUE,
|
|
121
|
+
"open-managed-agents.workspace-id": workspaceId,
|
|
122
|
+
"open-managed-agents.session-id": sessionId,
|
|
123
|
+
"open-managed-agents.created-at": new Date().toISOString(),
|
|
124
|
+
...resolved.extraLabels,
|
|
125
|
+
},
|
|
126
|
+
}), { timeoutMs: resolved.startupTimeoutMs });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// Sandbox creation failed after the sidecar was already stood up. The
|
|
130
|
+
// provider whose dispose() tears the sidecar down is never returned, so
|
|
131
|
+
// dispose here — else the sidecar container and its resolved-secret bundle
|
|
132
|
+
// on disk leak.
|
|
133
|
+
opts.egress?.dispose();
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const invocations = createSandboxInvocationStats();
|
|
137
|
+
const disposed = { value: false };
|
|
138
|
+
const activeDockerExecPids = new Set();
|
|
139
|
+
let disposeAttempted = false;
|
|
140
|
+
let poisoned = false;
|
|
141
|
+
let poisonPromise;
|
|
142
|
+
const disposeInfrastructure = () => {
|
|
143
|
+
if (disposeAttempted)
|
|
144
|
+
return;
|
|
145
|
+
disposeAttempted = true;
|
|
146
|
+
disposed.value = true;
|
|
147
|
+
for (const pid of activeDockerExecPids)
|
|
148
|
+
killProcessGroup(pid);
|
|
149
|
+
forceRemoveDockerContainer(resolved.dockerCommand, containerName);
|
|
150
|
+
opts.egress?.dispose();
|
|
151
|
+
};
|
|
152
|
+
const poisonInfrastructure = () => {
|
|
153
|
+
poisoned = true;
|
|
154
|
+
disposed.value = true;
|
|
155
|
+
if (poisonPromise)
|
|
156
|
+
return poisonPromise;
|
|
157
|
+
poisonPromise = (async () => {
|
|
158
|
+
for (const pid of activeDockerExecPids)
|
|
159
|
+
killProcessGroup(pid);
|
|
160
|
+
await removeDockerContainerChecked(resolved.dockerCommand, containerName, resolved.operationTimeoutMs);
|
|
161
|
+
disposeAttempted = true;
|
|
162
|
+
opts.egress?.dispose();
|
|
163
|
+
})();
|
|
164
|
+
return poisonPromise;
|
|
165
|
+
};
|
|
166
|
+
const dockerShell = (command, execOpts = {}) => dockerChecked(resolved.dockerCommand, buildDockerExecShellArgs(containerName, command.script, command.args, {
|
|
167
|
+
interactive: command.interactive ?? command.input !== undefined,
|
|
168
|
+
timeoutSeconds: execOpts.guestTimeout === false
|
|
169
|
+
? undefined
|
|
170
|
+
: (execOpts.timeoutMs ?? resolved.operationTimeoutMs) / 1000,
|
|
171
|
+
workdir: resolved.workspacePath,
|
|
172
|
+
}), {
|
|
173
|
+
...execOpts,
|
|
174
|
+
input: execOpts.input ?? command.input,
|
|
175
|
+
activePids: activeDockerExecPids,
|
|
176
|
+
timeoutMs: execOpts.timeoutMs ?? resolved.operationTimeoutMs,
|
|
177
|
+
});
|
|
178
|
+
const dockerExists = async (absolutePath) => {
|
|
179
|
+
const command = buildDockerExistsCommand(absolutePath);
|
|
180
|
+
const result = await dockerExec(resolved.dockerCommand, buildDockerExecShellArgs(containerName, command.script, command.args, {
|
|
181
|
+
timeoutSeconds: resolved.operationTimeoutMs / 1000,
|
|
182
|
+
workdir: resolved.workspacePath,
|
|
183
|
+
}), {
|
|
184
|
+
activePids: activeDockerExecPids,
|
|
185
|
+
timeoutMs: resolved.operationTimeoutMs,
|
|
186
|
+
});
|
|
187
|
+
if (result.exitCode === 0)
|
|
188
|
+
return true;
|
|
189
|
+
throwIfUnexpectedBoundedExit(result, "docker exists", new Set([1]));
|
|
190
|
+
return false;
|
|
191
|
+
};
|
|
192
|
+
await dockerShell(buildDockerCmaGrepPreflightCommand(), {
|
|
193
|
+
timeoutMs: 3_000,
|
|
194
|
+
guestTimeout: false,
|
|
195
|
+
});
|
|
196
|
+
const materializeFileResources = async (mounts) => {
|
|
197
|
+
if (mounts.length === 0)
|
|
198
|
+
return;
|
|
199
|
+
recordSandboxNotDisposed(disposed);
|
|
200
|
+
for (const kind of ["upload", "skill"]) {
|
|
201
|
+
const selected = mounts.filter((mount) => mount.kind === kind);
|
|
202
|
+
if (selected.length === 0)
|
|
203
|
+
continue;
|
|
204
|
+
const destination = kind === "upload" ? resolved.uploadsPath : DEFAULT_SKILLS_PATH;
|
|
205
|
+
const tempRoot = await mkdtemp(joinHostPath(tmpdir(), "oma-session-mounts-"));
|
|
206
|
+
try {
|
|
207
|
+
if (kind === "skill") {
|
|
208
|
+
await dockerChecked(resolved.dockerCommand, ["exec", "--user", "65534:65534", containerName, "chmod", "711", resolved.workspacePath], { timeoutMs: resolved.operationTimeoutMs });
|
|
209
|
+
}
|
|
210
|
+
for (const mount of selected) {
|
|
211
|
+
const relativePath = assertInsideMountRoot(mount.mountPath, destination);
|
|
212
|
+
await writeMountFile(tempRoot, relativePath, mount);
|
|
213
|
+
}
|
|
214
|
+
// Docker exec timeout only owns the local Docker CLI process; the
|
|
215
|
+
// caller must dispose the container on materialization failure so any
|
|
216
|
+
// in-container tar/chown work cannot leak a half-mounted sandbox.
|
|
217
|
+
await dockerChecked(resolved.dockerCommand, buildDockerExtractIntoContainerArgs(containerName, destination), {
|
|
218
|
+
input: createTarArchive(tempRoot),
|
|
219
|
+
timeoutMs: resolved.operationTimeoutMs,
|
|
220
|
+
});
|
|
221
|
+
await dockerChecked(resolved.dockerCommand, kind === "upload" ? buildDockerNormalizeUploadsArgs(containerName, destination) : buildDockerNormalizeSkillsArgs(containerName, destination), { timeoutMs: resolved.operationTimeoutMs });
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
if (kind === "skill") {
|
|
225
|
+
await dockerChecked(resolved.dockerCommand, ["exec", "--user", "65534:65534", containerName, "chmod", "700", resolved.workspacePath], { timeoutMs: resolved.operationTimeoutMs });
|
|
226
|
+
}
|
|
227
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const collectOutputFiles = async () => {
|
|
232
|
+
recordSandboxNotDisposed(disposed);
|
|
233
|
+
const listing = await dockerShell(buildDockerOutputListingCommand(resolved.outputsPath, {
|
|
234
|
+
maxFiles: resolved.maxOutputFiles,
|
|
235
|
+
maxFileBytes: resolved.maxOutputFileBytes,
|
|
236
|
+
maxBytes: resolved.maxOutputBytes,
|
|
237
|
+
}));
|
|
238
|
+
const records = parseOutputListing(listing.stdout);
|
|
239
|
+
return records.map((record) => {
|
|
240
|
+
const absolutePath = assertInsideDockerOutputPath(posix.join(resolved.outputsPath, record.relativePath), resolved.outputsPath);
|
|
241
|
+
return {
|
|
242
|
+
relativePath: record.relativePath,
|
|
243
|
+
filename: posix.basename(record.relativePath),
|
|
244
|
+
mimeType: mimeTypeForFilename(record.relativePath),
|
|
245
|
+
sizeBytes: record.sizeBytes,
|
|
246
|
+
sha256: record.sha256,
|
|
247
|
+
bytes: dockerOutputBytes(() => dockerShell(buildDockerReadFileCommand(absolutePath), {
|
|
248
|
+
maxStdoutBytes: resolved.maxOutputFileBytes,
|
|
249
|
+
})),
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
const readOps = {
|
|
254
|
+
access: async (absolutePath) => {
|
|
255
|
+
recordSandboxInvocation(invocations, disposed, "read");
|
|
256
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
257
|
+
await dockerShell(buildDockerFileAccessCommand(path, "read"));
|
|
258
|
+
},
|
|
259
|
+
readFile: async (absolutePath) => {
|
|
260
|
+
recordSandboxInvocation(invocations, disposed, "read");
|
|
261
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
262
|
+
return (await dockerShell(buildDockerReadFileCommand(path))).stdout;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
const writeOps = {
|
|
266
|
+
mkdir: async (dir) => {
|
|
267
|
+
recordSandboxInvocation(invocations, disposed, "write");
|
|
268
|
+
const path = assertInsideDockerWorkspace(dir, resolved.workspacePath);
|
|
269
|
+
await dockerShell(buildDockerMkdirCommand(path));
|
|
270
|
+
},
|
|
271
|
+
writeFile: async (absolutePath, content) => {
|
|
272
|
+
recordSandboxInvocation(invocations, disposed, "write");
|
|
273
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
274
|
+
await dockerShell(buildDockerWriteFileCommand(path, content));
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
const editOps = {
|
|
278
|
+
access: async (absolutePath) => {
|
|
279
|
+
recordSandboxInvocation(invocations, disposed, "edit");
|
|
280
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
281
|
+
await dockerShell(buildDockerFileAccessCommand(path, "edit"));
|
|
282
|
+
},
|
|
283
|
+
readFile: async (absolutePath) => {
|
|
284
|
+
recordSandboxInvocation(invocations, disposed, "edit");
|
|
285
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
286
|
+
return (await dockerShell(buildDockerReadFileCommand(path))).stdout;
|
|
287
|
+
},
|
|
288
|
+
writeFile: async (absolutePath, content) => {
|
|
289
|
+
recordSandboxInvocation(invocations, disposed, "edit");
|
|
290
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
291
|
+
await dockerShell(buildDockerWriteFileCommand(path, content));
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
const findOps = {
|
|
295
|
+
exists: async (absolutePath) => {
|
|
296
|
+
recordSandboxInvocation(invocations, disposed, "find");
|
|
297
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
298
|
+
return dockerExists(path);
|
|
299
|
+
},
|
|
300
|
+
glob: async (pattern, cwd, options) => {
|
|
301
|
+
recordSandboxInvocation(invocations, disposed, "find");
|
|
302
|
+
const root = assertInsideDockerWorkspace(cwd, resolved.workspacePath);
|
|
303
|
+
const result = await dockerShell(buildDockerGlobEnumerationCommand(root, options.ignore));
|
|
304
|
+
const files = result.stdout
|
|
305
|
+
.toString("utf8")
|
|
306
|
+
.split("\n")
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
return matchGlob(files, pattern, {
|
|
309
|
+
ignore: options.ignore,
|
|
310
|
+
limit: options.limit,
|
|
311
|
+
}).map((rel) => posix.join(root, rel));
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
const globOps = {
|
|
315
|
+
glob: async ({ pattern, cwd, signal, maxMatches, maxRawBytes, maxOutputBytes, outputBase, timeoutMs }) => {
|
|
316
|
+
recordSandboxInvocation(invocations, disposed, "glob");
|
|
317
|
+
const root = assertInsideDockerWorkspace(cwd, resolved.workspacePath);
|
|
318
|
+
if (signal.aborted)
|
|
319
|
+
throw new Error("Operation aborted");
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
const ownershipToken = `oma-glob-${randomUUID()}`;
|
|
322
|
+
const protocolBytes = Buffer.byteLength(CMA_GLOB_READY_MARKER, "utf8") + 1;
|
|
323
|
+
const abortFromCaller = () => controller.abort();
|
|
324
|
+
signal.addEventListener("abort", abortFromCaller, { once: true });
|
|
325
|
+
let streamError;
|
|
326
|
+
const readiness = new CmaGlobReadinessFilter(CMA_GLOB_READY_MARKER);
|
|
327
|
+
const collector = new CmaGlobStreamCollector(compileCmaGlob(pattern), {
|
|
328
|
+
root,
|
|
329
|
+
maxMatches,
|
|
330
|
+
maxRawBytes,
|
|
331
|
+
maxOutputBytes,
|
|
332
|
+
join: posix.join,
|
|
333
|
+
formatForOutput: outputBase === undefined
|
|
334
|
+
? undefined
|
|
335
|
+
: (absolutePath) => posix.relative(outputBase, absolutePath),
|
|
336
|
+
onLimit: () => controller.abort(),
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
await dockerShell(buildDockerCmaGlobEnumerationCommand(root, ownershipToken), {
|
|
340
|
+
signal: controller.signal,
|
|
341
|
+
timeoutMs,
|
|
342
|
+
guestTimeout: false,
|
|
343
|
+
// Transport framing is bounded separately; the collector applies the
|
|
344
|
+
// caller-visible raw filename limit after removing readiness bytes.
|
|
345
|
+
// Node may deliver readiness plus more than the public raw allowance
|
|
346
|
+
// in one pipe chunk. Leave one bounded chunk of transport slack so
|
|
347
|
+
// the readiness filter and collector, rather than the transport,
|
|
348
|
+
// classify that data. The collector still enforces maxRawBytes.
|
|
349
|
+
maxStdoutBytes: maxRawBytes + protocolBytes + 64 * 1024,
|
|
350
|
+
maxCombinedBytes: maxRawBytes + protocolBytes + 128 * 1024,
|
|
351
|
+
onStdout: (chunk) => {
|
|
352
|
+
try {
|
|
353
|
+
const filenames = readiness.push(chunk);
|
|
354
|
+
if (filenames)
|
|
355
|
+
collector.push(filenames);
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
streamError = error;
|
|
359
|
+
controller.abort();
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
if (streamError)
|
|
364
|
+
throw streamError;
|
|
365
|
+
if (signal.aborted)
|
|
366
|
+
throw new Error("Operation aborted");
|
|
367
|
+
readiness.assertReady();
|
|
368
|
+
collector.finish();
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (streamError)
|
|
372
|
+
throw streamError;
|
|
373
|
+
if (signal.aborted)
|
|
374
|
+
throw new Error("Operation aborted");
|
|
375
|
+
if (!collector.limitReached)
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
try {
|
|
380
|
+
if (!readiness.ready) {
|
|
381
|
+
// Any terminal path before a positive dispatch acknowledgement is
|
|
382
|
+
// ambiguous. Verified container removal is the only fail-closed
|
|
383
|
+
// guarantee that delayed guest work cannot appear afterward.
|
|
384
|
+
await poisonInfrastructure();
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
try {
|
|
388
|
+
await dockerShell(buildDockerCmaGlobCleanupCommand(ownershipToken), {
|
|
389
|
+
timeoutMs: 3_000,
|
|
390
|
+
guestTimeout: false,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
catch (cleanupError) {
|
|
394
|
+
// Readiness proves dispatch, not successful termination. If the
|
|
395
|
+
// token-scoped cleanup cannot prove the guest process is gone,
|
|
396
|
+
// destroy the isolation boundary before returning the error.
|
|
397
|
+
await poisonInfrastructure();
|
|
398
|
+
throw cleanupError;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
signal.removeEventListener("abort", abortFromCaller);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (signal.aborted)
|
|
407
|
+
throw new Error("Operation aborted");
|
|
408
|
+
return collector.matches;
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
const grepOps = {
|
|
412
|
+
grep: async ({ pattern, cwd, signal, glob, headLimit, maxRawBytes, maxOutputBytes, outputBase, timeoutMs }) => {
|
|
413
|
+
recordSandboxInvocation(invocations, disposed, "grep");
|
|
414
|
+
const root = assertInsideDockerSearchRoot(cwd, [
|
|
415
|
+
resolved.workspacePath,
|
|
416
|
+
resolved.uploadsPath,
|
|
417
|
+
DEFAULT_SKILLS_PATH,
|
|
418
|
+
]);
|
|
419
|
+
if (signal.aborted)
|
|
420
|
+
throw new Error("Operation aborted");
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const protocolBytes = Buffer.byteLength(CMA_GREP_READY_MARKER, "utf8") + 1;
|
|
423
|
+
const abortFromCaller = () => controller.abort();
|
|
424
|
+
signal.addEventListener("abort", abortFromCaller, { once: true });
|
|
425
|
+
let streamError;
|
|
426
|
+
const ownershipToken = `oma-grep-${randomUUID()}`;
|
|
427
|
+
const readiness = new CmaGlobReadinessFilter(CMA_GREP_READY_MARKER);
|
|
428
|
+
const collector = new CmaGrepStreamCollector({
|
|
429
|
+
root,
|
|
430
|
+
maxMatches: headLimit,
|
|
431
|
+
maxRawBytes,
|
|
432
|
+
maxOutputBytes,
|
|
433
|
+
join: posix.join,
|
|
434
|
+
formatForOutput: outputBase === undefined
|
|
435
|
+
? undefined
|
|
436
|
+
: (absolutePath) => posix.relative(outputBase, absolutePath),
|
|
437
|
+
onLimit: () => controller.abort(),
|
|
438
|
+
});
|
|
439
|
+
try {
|
|
440
|
+
try {
|
|
441
|
+
await dockerShell(buildDockerCmaGrepSearchCommand(root, ownershipToken, pattern, glob), {
|
|
442
|
+
signal: controller.signal,
|
|
443
|
+
timeoutMs,
|
|
444
|
+
guestTimeout: false,
|
|
445
|
+
maxStdoutBytes: maxRawBytes + protocolBytes + 64 * 1024,
|
|
446
|
+
maxCombinedBytes: maxRawBytes + protocolBytes + 128 * 1024,
|
|
447
|
+
onStdout: (chunk) => {
|
|
448
|
+
try {
|
|
449
|
+
const filenames = readiness.push(chunk);
|
|
450
|
+
if (filenames)
|
|
451
|
+
collector.push(filenames);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
streamError = error;
|
|
455
|
+
controller.abort();
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
if (streamError)
|
|
460
|
+
throw streamError;
|
|
461
|
+
if (signal.aborted)
|
|
462
|
+
throw new Error("Operation aborted");
|
|
463
|
+
readiness.assertReady();
|
|
464
|
+
collector.finish();
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (streamError)
|
|
468
|
+
throw streamError;
|
|
469
|
+
if (signal.aborted)
|
|
470
|
+
throw new Error("Operation aborted");
|
|
471
|
+
if (!collector.limitReached)
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
if (!readiness.ready) {
|
|
476
|
+
await poisonInfrastructure();
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
try {
|
|
480
|
+
await dockerShell(buildDockerCmaGrepCleanupCommand(ownershipToken), {
|
|
481
|
+
timeoutMs: 3_000,
|
|
482
|
+
guestTimeout: false,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch (cleanupError) {
|
|
486
|
+
await poisonInfrastructure();
|
|
487
|
+
throw cleanupError;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (signal.aborted)
|
|
492
|
+
throw new Error("Operation aborted");
|
|
493
|
+
return collector.matches;
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
signal.removeEventListener("abort", abortFromCaller);
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
const lsOps = {
|
|
501
|
+
exists: async (absolutePath) => {
|
|
502
|
+
recordSandboxInvocation(invocations, disposed, "ls");
|
|
503
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
504
|
+
return dockerExists(path);
|
|
505
|
+
},
|
|
506
|
+
stat: async (absolutePath) => {
|
|
507
|
+
recordSandboxInvocation(invocations, disposed, "ls");
|
|
508
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
509
|
+
const result = await dockerShell(buildDockerStatCommand(path));
|
|
510
|
+
const kind = result.stdout.toString("utf8");
|
|
511
|
+
return { isDirectory: () => kind === "directory" };
|
|
512
|
+
},
|
|
513
|
+
readdir: async (absolutePath) => {
|
|
514
|
+
recordSandboxInvocation(invocations, disposed, "ls");
|
|
515
|
+
const path = assertInsideDockerWorkspace(absolutePath, resolved.workspacePath);
|
|
516
|
+
const result = await dockerShell(buildDockerReaddirCommand(path));
|
|
517
|
+
return result.stdout.toString("utf8").split("\n").filter(Boolean);
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
const bashOps = {
|
|
521
|
+
exec: async (command, cwd, options) => {
|
|
522
|
+
recordSandboxInvocation(invocations, disposed, "bash");
|
|
523
|
+
const path = assertInsideDockerWorkspace(cwd, resolved.workspacePath);
|
|
524
|
+
const timeoutSeconds = options.timeout !== undefined && options.timeout > 0
|
|
525
|
+
? options.timeout
|
|
526
|
+
: resolved.operationTimeoutMs / 1000;
|
|
527
|
+
const execId = randomExecId();
|
|
528
|
+
const dispatchToken = randomExecId();
|
|
529
|
+
const pidFile = posix.join(resolved.workspacePath, `.oma-exec-${execId}.pid`);
|
|
530
|
+
const shellCommand = buildDockerBashCommand(command, timeoutSeconds, pidFile, dispatchToken);
|
|
531
|
+
const dispatchFilter = createBashDispatchFilter(dispatchToken);
|
|
532
|
+
try {
|
|
533
|
+
const result = await dockerExec(resolved.dockerCommand, buildDockerExecShellArgs(containerName, shellCommand.script, shellCommand.args, {
|
|
534
|
+
workdir: path,
|
|
535
|
+
env: filterDockerEnv(options.env ?? {}, resolved.envAllowlist),
|
|
536
|
+
}), {
|
|
537
|
+
activePids: activeDockerExecPids,
|
|
538
|
+
onAbort: () => {
|
|
539
|
+
killInContainerProcessGroup(resolved.dockerCommand, containerName, pidFile);
|
|
540
|
+
},
|
|
541
|
+
onStderr: (chunk) => {
|
|
542
|
+
const forwarded = dispatchFilter.stderr(chunk);
|
|
543
|
+
if (forwarded.length > 0)
|
|
544
|
+
options.onData(forwarded);
|
|
545
|
+
},
|
|
546
|
+
onStdout: (chunk) => {
|
|
547
|
+
const forwarded = dispatchFilter.stdout(chunk);
|
|
548
|
+
if (forwarded.length > 0)
|
|
549
|
+
options.onData(forwarded);
|
|
550
|
+
},
|
|
551
|
+
signal: options.signal,
|
|
552
|
+
timeoutMs: Math.ceil(timeoutSeconds * 1000) + 2_000,
|
|
553
|
+
});
|
|
554
|
+
if (!dispatchFilter.dispatchSeen()) {
|
|
555
|
+
throw new Error("docker bash failed before command dispatch");
|
|
556
|
+
}
|
|
557
|
+
const terminal = dispatchFilter.terminalRecord();
|
|
558
|
+
if (terminal === undefined) {
|
|
559
|
+
throw new Error("docker bash failed before command completion");
|
|
560
|
+
}
|
|
561
|
+
if (result.exitCode !==
|
|
562
|
+
(terminal.kind === "timeout" ? 137 : terminal.exitCode)) {
|
|
563
|
+
throw new Error("docker bash exit disagreed with command completion");
|
|
564
|
+
}
|
|
565
|
+
if (terminal.kind === "timeout") {
|
|
566
|
+
throw new DockerTimeoutError();
|
|
567
|
+
}
|
|
568
|
+
return { exitCode: terminal.exitCode };
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (error instanceof DockerTimeoutError) {
|
|
572
|
+
throw new Error(`timeout:${timeoutSeconds}`);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
const operations = {
|
|
579
|
+
bash: bashOps,
|
|
580
|
+
read: readOps,
|
|
581
|
+
write: writeOps,
|
|
582
|
+
edit: editOps,
|
|
583
|
+
find: findOps,
|
|
584
|
+
glob: globOps,
|
|
585
|
+
grep: grepOps,
|
|
586
|
+
ls: lsOps,
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
cwd: resolved.workspacePath,
|
|
590
|
+
collectOutputFiles,
|
|
591
|
+
invocations,
|
|
592
|
+
materializeFileResources,
|
|
593
|
+
operations,
|
|
594
|
+
toolNames: new Set(["bash", "read", "write", "edit", "glob", "grep", "ls"]),
|
|
595
|
+
tools: createSandboxToolDefinitions(resolved.workspacePath, operations, invocations, disposed),
|
|
596
|
+
isPoisoned: () => poisoned,
|
|
597
|
+
dispose: disposeInfrastructure,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const SANDBOX_CA_MOUNT = "/etc/oma";
|
|
601
|
+
const SANDBOX_CA_CERT_PATH = "/etc/oma/ca.crt";
|
|
602
|
+
export function buildDockerRunArgs(opts) {
|
|
603
|
+
assertTmpfsMemoryHeadroom(opts.memory, opts.tmpfsSize, opts.outputsTmpfsSize ?? opts.tmpfsSize, opts.tmpfsSize);
|
|
604
|
+
const labels = Object.entries(opts.labels ?? {}).flatMap(([key, value]) => [
|
|
605
|
+
"--label",
|
|
606
|
+
`${key}=${value}`,
|
|
607
|
+
]);
|
|
608
|
+
const uploadsPath = opts.uploadsPath ?? DEFAULT_UPLOADS_PATH;
|
|
609
|
+
const outputsPath = opts.outputsPath ?? DEFAULT_OUTPUTS_PATH;
|
|
610
|
+
const outputsTmpfsSize = opts.outputsTmpfsSize ?? DEFAULT_OUTPUTS_TMPFS_SIZE;
|
|
611
|
+
// Default deny (ADR 0016 §2): no egress wiring -> --network none, no proxy.
|
|
612
|
+
const network = opts.egress ? opts.egress.networkName : "none";
|
|
613
|
+
const egressArgs = opts.egress ? buildEgressRunArgs(opts.egress) : [];
|
|
614
|
+
return [
|
|
615
|
+
"run",
|
|
616
|
+
"-d",
|
|
617
|
+
"--name",
|
|
618
|
+
opts.containerName,
|
|
619
|
+
...labels,
|
|
620
|
+
"--network",
|
|
621
|
+
network,
|
|
622
|
+
...egressArgs,
|
|
623
|
+
"--cpus",
|
|
624
|
+
opts.cpus,
|
|
625
|
+
"--memory",
|
|
626
|
+
opts.memory,
|
|
627
|
+
"--pids-limit",
|
|
628
|
+
opts.pidsLimit,
|
|
629
|
+
"--cap-drop",
|
|
630
|
+
"ALL",
|
|
631
|
+
"--security-opt",
|
|
632
|
+
"no-new-privileges",
|
|
633
|
+
"--read-only",
|
|
634
|
+
"--tmpfs",
|
|
635
|
+
`${opts.workspacePath}:rw,exec,nosuid,nodev,uid=65534,gid=65534,mode=700,size=${opts.tmpfsSize}`,
|
|
636
|
+
"--tmpfs",
|
|
637
|
+
`${uploadsPath}:rw,nosuid,nodev,noexec,mode=755,size=${opts.tmpfsSize}`,
|
|
638
|
+
"--tmpfs",
|
|
639
|
+
`${outputsPath}:rw,nosuid,nodev,noexec,uid=65534,gid=65534,mode=700,size=${outputsTmpfsSize}`,
|
|
640
|
+
"--tmpfs",
|
|
641
|
+
`${DEFAULT_SKILLS_PATH}:rw,exec,nosuid,nodev,uid=0,gid=0,mode=755,size=${opts.tmpfsSize}`,
|
|
642
|
+
"--workdir",
|
|
643
|
+
opts.workspacePath,
|
|
644
|
+
"--user",
|
|
645
|
+
"65534:65534",
|
|
646
|
+
opts.image,
|
|
647
|
+
"tail",
|
|
648
|
+
"-f",
|
|
649
|
+
"/dev/null",
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
// Trust bundle + proxy env for an egress-granted sandbox. The trust vars point
|
|
653
|
+
// at the sidecar's CA so the proxy can terminate TLS; the proxy vars route the
|
|
654
|
+
// sandbox's HTTP clients through it. Trust/proxy vars win over sentinels so a
|
|
655
|
+
// user-named sentinel cannot shadow the boundary wiring.
|
|
656
|
+
function buildEgressRunArgs(egress) {
|
|
657
|
+
const env = {
|
|
658
|
+
...egress.sandboxEnv,
|
|
659
|
+
SSL_CERT_FILE: SANDBOX_CA_CERT_PATH,
|
|
660
|
+
NODE_EXTRA_CA_CERTS: SANDBOX_CA_CERT_PATH,
|
|
661
|
+
GIT_SSL_CAINFO: SANDBOX_CA_CERT_PATH,
|
|
662
|
+
CURL_CA_BUNDLE: SANDBOX_CA_CERT_PATH,
|
|
663
|
+
REQUESTS_CA_BUNDLE: SANDBOX_CA_CERT_PATH,
|
|
664
|
+
// Git/libcurl does not reliably negotiate the sidecar's Basic proxy auth
|
|
665
|
+
// from the URL alone and otherwise reports "Proxy CONNECT aborted". Pin
|
|
666
|
+
// only the proxy auth method; the credential remains the per-session URL.
|
|
667
|
+
GIT_CONFIG_COUNT: "1",
|
|
668
|
+
GIT_CONFIG_KEY_0: "http.proxyAuthMethod",
|
|
669
|
+
GIT_CONFIG_VALUE_0: "basic",
|
|
670
|
+
HTTPS_PROXY: egress.proxyUrl,
|
|
671
|
+
HTTP_PROXY: egress.proxyUrl,
|
|
672
|
+
https_proxy: egress.proxyUrl,
|
|
673
|
+
http_proxy: egress.proxyUrl,
|
|
674
|
+
};
|
|
675
|
+
return [
|
|
676
|
+
"-v",
|
|
677
|
+
`${egress.caCertDirHostPath}:${SANDBOX_CA_MOUNT}:ro`,
|
|
678
|
+
...Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
|
|
679
|
+
];
|
|
680
|
+
}
|
|
681
|
+
export function buildDockerExtractIntoContainerArgs(containerName, destinationDirectory) {
|
|
682
|
+
return [
|
|
683
|
+
"exec",
|
|
684
|
+
"-i",
|
|
685
|
+
"--user",
|
|
686
|
+
"0:0",
|
|
687
|
+
containerName,
|
|
688
|
+
"tar",
|
|
689
|
+
"--no-same-owner",
|
|
690
|
+
"-C",
|
|
691
|
+
destinationDirectory,
|
|
692
|
+
"-xf",
|
|
693
|
+
"-",
|
|
694
|
+
];
|
|
695
|
+
}
|
|
696
|
+
export function buildDockerNormalizeUploadsArgs(containerName, uploadsPath) {
|
|
697
|
+
return [
|
|
698
|
+
"exec",
|
|
699
|
+
"--user",
|
|
700
|
+
"0:0",
|
|
701
|
+
containerName,
|
|
702
|
+
"sh",
|
|
703
|
+
"-c",
|
|
704
|
+
"chown -R 0:0 \"$1\" && find \"$1\" -type d -exec chmod 755 {} + && find \"$1\" -type f -exec chmod 644 {} +",
|
|
705
|
+
"sh",
|
|
706
|
+
uploadsPath,
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
export function buildDockerNormalizeSkillsArgs(containerName, skillsPath) {
|
|
710
|
+
return ["exec", "--user", "0:0", containerName, "sh", "-c", "chown -R 0:0 \"$1\" && find \"$1\" -type d -exec chmod 755 {} + && find \"$1\" -type f -exec chmod 644 {} + && find \"$1\" -path '*/scripts/*' -type f -exec chmod 755 {} +", "sh", skillsPath];
|
|
711
|
+
}
|
|
712
|
+
export function buildDockerExecShellArgs(containerName, script, args, opts = {}) {
|
|
713
|
+
const out = ["exec"];
|
|
714
|
+
if (opts.interactive)
|
|
715
|
+
out.push("-i");
|
|
716
|
+
if (opts.workdir)
|
|
717
|
+
out.push("--workdir", opts.workdir);
|
|
718
|
+
for (const [key, value] of Object.entries(opts.env ?? {})) {
|
|
719
|
+
if (value !== undefined)
|
|
720
|
+
out.push("--env", `${key}=${value}`);
|
|
721
|
+
}
|
|
722
|
+
if (opts.timeoutSeconds !== undefined && opts.timeoutSeconds > 0) {
|
|
723
|
+
out.push(containerName, "bash", "-lc", "timeout -s KILL \"$1\" bash -lc \"$2\" bash \"${@:3}\"", "bash", String(opts.timeoutSeconds), script, ...args);
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
out.push(containerName, "bash", "-lc", script, "bash", ...args);
|
|
727
|
+
}
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
export function buildDockerBashCommand(command, timeoutSeconds, pidFile, dispatchToken = "") {
|
|
731
|
+
return {
|
|
732
|
+
script: [
|
|
733
|
+
"pidfile=\"$1\"",
|
|
734
|
+
"timeout_secs=\"$2\"",
|
|
735
|
+
"command=\"$3\"",
|
|
736
|
+
"dispatch_token=\"$4\"",
|
|
737
|
+
"printf '%s\\n' \"__OMA_DISPATCHED__:${dispatch_token}\"",
|
|
738
|
+
"terminal_exit() { printf '%s\\n' \"__OMA_TERMINAL__:${dispatch_token}:exit:$1\"; }",
|
|
739
|
+
"terminal_timeout() { printf '%s\\n' \"__OMA_TERMINAL__:${dispatch_token}:timeout:137\"; }",
|
|
740
|
+
"timer_file=\"${pidfile}.timer\"",
|
|
741
|
+
"rm -f \"$timer_file\"",
|
|
742
|
+
"setsid bash -lc \"$command\" &",
|
|
743
|
+
"pid=$!",
|
|
744
|
+
"printf '%s' \"$pid\" > \"$pidfile\"",
|
|
745
|
+
"setsid sleep \"$timeout_secs\" &",
|
|
746
|
+
"timer=$!",
|
|
747
|
+
"printf '%s' \"$timer\" > \"$timer_file\"",
|
|
748
|
+
"wait -n -p completed \"$pid\" \"$timer\"",
|
|
749
|
+
"status=$?",
|
|
750
|
+
"if [ \"${completed:-}\" = \"$timer\" ]; then",
|
|
751
|
+
" kill -KILL \"-$pid\" 2>/dev/null || kill -KILL \"$pid\" 2>/dev/null || true",
|
|
752
|
+
" wait \"$pid\" 2>/dev/null || true",
|
|
753
|
+
" rm -f \"$pidfile\" \"$timer_file\"",
|
|
754
|
+
" terminal_timeout",
|
|
755
|
+
" exit 137",
|
|
756
|
+
"fi",
|
|
757
|
+
"kill \"$timer\" 2>/dev/null || true",
|
|
758
|
+
"wait \"$timer\" 2>/dev/null || true",
|
|
759
|
+
"rm -f \"$pidfile\" \"$timer_file\"",
|
|
760
|
+
"terminal_exit \"$status\"",
|
|
761
|
+
"exit \"$status\"",
|
|
762
|
+
].join("\n"),
|
|
763
|
+
args: [pidFile, String(timeoutSeconds), command, dispatchToken],
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
export function buildDockerFileAccessCommand(absolutePath, mode) {
|
|
767
|
+
return {
|
|
768
|
+
script: mode === "read"
|
|
769
|
+
? "test -r \"$1\" -a -f \"$1\""
|
|
770
|
+
: "test -r \"$1\" -a -w \"$1\" -a -f \"$1\"",
|
|
771
|
+
args: [absolutePath],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
export function buildDockerReadFileCommand(absolutePath) {
|
|
775
|
+
return { script: "cat \"$1\"", args: [absolutePath] };
|
|
776
|
+
}
|
|
777
|
+
export function buildDockerWriteFileCommand(absolutePath, content) {
|
|
778
|
+
return {
|
|
779
|
+
script: "cat > \"$1\"",
|
|
780
|
+
args: [absolutePath],
|
|
781
|
+
input: content,
|
|
782
|
+
interactive: true,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
export function buildDockerMkdirCommand(dir) {
|
|
786
|
+
return { script: "mkdir -p \"$1\"", args: [dir] };
|
|
787
|
+
}
|
|
788
|
+
export function buildDockerExistsCommand(absolutePath) {
|
|
789
|
+
return { script: "test -e \"$1\"", args: [absolutePath] };
|
|
790
|
+
}
|
|
791
|
+
export function buildDockerStatCommand(absolutePath) {
|
|
792
|
+
return {
|
|
793
|
+
script: "if [ -d \"$1\" ]; then printf directory; elif [ -e \"$1\" ]; then printf file; else exit 1; fi",
|
|
794
|
+
args: [absolutePath],
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
export function buildDockerReaddirCommand(absolutePath) {
|
|
798
|
+
return { script: "ls -1A \"$1\"", args: [absolutePath] };
|
|
799
|
+
}
|
|
800
|
+
export function buildDockerCmaGlobCleanupCommand(ownershipToken) {
|
|
801
|
+
return {
|
|
802
|
+
script: [
|
|
803
|
+
"pids=''",
|
|
804
|
+
"for f in /proc/[0-9]*/environ; do",
|
|
805
|
+
" pid=${f#/proc/}; pid=${pid%/environ}",
|
|
806
|
+
" if tr '\\0' '\\n' < \"$f\" 2>/dev/null | grep -Fqx \"OMA_GLOB_OWNER=$1\"; then",
|
|
807
|
+
" start=$(awk '{print $22}' \"/proc/$pid/stat\" 2>/dev/null || true)",
|
|
808
|
+
" case \"$start\" in ''|*[!0-9]*) :;; *) pids=\"$pids $pid:$start\";; esac",
|
|
809
|
+
" fi",
|
|
810
|
+
"done",
|
|
811
|
+
"for entry in $pids; do",
|
|
812
|
+
" pid=${entry%%:*}; start=${entry#*:}",
|
|
813
|
+
" current=$(awk '{print $22}' \"/proc/$pid/stat\" 2>/dev/null || true)",
|
|
814
|
+
" if [ \"$current\" = \"$start\" ] && tr '\\0' '\\n' < \"/proc/$pid/environ\" 2>/dev/null | grep -Fqx \"OMA_GLOB_OWNER=$1\"; then",
|
|
815
|
+
" kill -KILL \"-$pid\" 2>/dev/null || kill -KILL \"$pid\" 2>/dev/null || true",
|
|
816
|
+
" fi",
|
|
817
|
+
"done",
|
|
818
|
+
"attempt=0",
|
|
819
|
+
"while [ -n \"$pids\" ] && [ \"$attempt\" -lt 40 ]; do",
|
|
820
|
+
" remaining=''",
|
|
821
|
+
" for entry in $pids; do",
|
|
822
|
+
" pid=${entry%%:*}; start=${entry#*:}",
|
|
823
|
+
" current=$(awk '{print $22}' \"/proc/$pid/stat\" 2>/dev/null || true)",
|
|
824
|
+
" if [ \"$current\" = \"$start\" ] && tr '\\0' '\\n' < \"/proc/$pid/environ\" 2>/dev/null | grep -Fqx \"OMA_GLOB_OWNER=$1\"; then remaining=\"$remaining $entry\"; fi",
|
|
825
|
+
" done",
|
|
826
|
+
" pids=$remaining",
|
|
827
|
+
" [ -z \"$pids\" ] && break",
|
|
828
|
+
" attempt=$((attempt + 1)); sleep 0.05",
|
|
829
|
+
"done",
|
|
830
|
+
"[ -z \"$pids\" ]",
|
|
831
|
+
].join("\n"),
|
|
832
|
+
args: [ownershipToken],
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
export function buildDockerCmaGrepCleanupCommand(ownershipToken) {
|
|
836
|
+
return {
|
|
837
|
+
script: [
|
|
838
|
+
"pids=''",
|
|
839
|
+
"for f in /proc/[0-9]*/environ; do",
|
|
840
|
+
" pid=${f#/proc/}; pid=${pid%/environ}",
|
|
841
|
+
" if tr '\\0' '\\n' < \"$f\" 2>/dev/null | grep -Fqx \"OMA_GREP_OWNER=$1\"; then",
|
|
842
|
+
" start=$(awk '{print $22}' \"/proc/$pid/stat\" 2>/dev/null || true)",
|
|
843
|
+
" case \"$start\" in ''|*[!0-9]*) :;; *) pids=\"$pids $pid:$start\";; esac",
|
|
844
|
+
" fi",
|
|
845
|
+
"done",
|
|
846
|
+
"for entry in $pids; do",
|
|
847
|
+
" pid=${entry%%:*}; start=${entry#*:}",
|
|
848
|
+
" current=$(awk '{print $22}' \"/proc/$pid/stat\" 2>/dev/null || true)",
|
|
849
|
+
" if [ \"$current\" = \"$start\" ] && tr '\\0' '\\n' < \"/proc/$pid/environ\" 2>/dev/null | grep -Fqx \"OMA_GREP_OWNER=$1\"; then",
|
|
850
|
+
" kill -KILL \"-$pid\" 2>/dev/null || kill -KILL \"$pid\" 2>/dev/null || true",
|
|
851
|
+
" fi",
|
|
852
|
+
"done",
|
|
853
|
+
"attempt=0",
|
|
854
|
+
"while [ -n \"$pids\" ] && [ \"$attempt\" -lt 40 ]; do",
|
|
855
|
+
" remaining=''",
|
|
856
|
+
" for entry in $pids; do",
|
|
857
|
+
" pid=${entry%%:*}; start=${entry#*:}",
|
|
858
|
+
" current=$(awk '{print $22}' \"/proc/$pid/stat\" 2>/dev/null || true)",
|
|
859
|
+
" if [ \"$current\" = \"$start\" ] && tr '\\0' '\\n' < \"/proc/$pid/environ\" 2>/dev/null | grep -Fqx \"OMA_GREP_OWNER=$1\"; then remaining=\"$remaining $entry\"; fi",
|
|
860
|
+
" done",
|
|
861
|
+
" pids=$remaining",
|
|
862
|
+
" [ -z \"$pids\" ] && break",
|
|
863
|
+
" attempt=$((attempt + 1)); sleep 0.05",
|
|
864
|
+
"done",
|
|
865
|
+
"[ -z \"$pids\" ]",
|
|
866
|
+
].join("\n"),
|
|
867
|
+
args: [ownershipToken],
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
export function buildDockerCmaGrepPreflightCommand() {
|
|
871
|
+
return {
|
|
872
|
+
script: [
|
|
873
|
+
"set -eu",
|
|
874
|
+
"dir=\".oma-grep-preflight-$$\"",
|
|
875
|
+
"mkdir \"$dir\"",
|
|
876
|
+
"trap 'rm -rf \"$dir\"' EXIT",
|
|
877
|
+
"printf 'needle\\n' > \"$dir/text.txt\"",
|
|
878
|
+
"printf 'other\\n' > \"$dir/no-match.txt\"",
|
|
879
|
+
"printf '\\0needle\\0tail' > \"$dir/binary.bin\"",
|
|
880
|
+
"LC_ALL=C rg --no-config --files-with-matches --null -- 'needle' \"$dir/text.txt\" | grep -Fq \"$dir/text.txt\"",
|
|
881
|
+
"set +e",
|
|
882
|
+
"LC_ALL=C rg --no-config --files-with-matches --null -- 'needle' \"$dir/no-match.txt\" >/dev/null 2>&1",
|
|
883
|
+
"code=$?",
|
|
884
|
+
"set -e",
|
|
885
|
+
"[ \"$code\" -eq 1 ]",
|
|
886
|
+
"set +e",
|
|
887
|
+
"LC_ALL=C rg --no-config --files-with-matches --null -- '[' \"$dir/text.txt\" >/dev/null 2>&1",
|
|
888
|
+
"code=$?",
|
|
889
|
+
"set -e",
|
|
890
|
+
"[ \"$code\" -ne 0 ] && [ \"$code\" -ne 1 ]",
|
|
891
|
+
"last=$(LC_ALL=C rg --no-config --files-with-matches --null -- 'needle' \"$dir/text.txt\" | tail -c 1 | od -An -tu1)",
|
|
892
|
+
"[ \"$(printf '%s' \"$last\" | tr -d ' ')\" = 0 ]",
|
|
893
|
+
"LC_ALL=C rg --no-config -qaU -- '\\x00' \"$dir/binary.bin\"",
|
|
894
|
+
"set +e",
|
|
895
|
+
"LC_ALL=C rg --no-config -qaU -- '\\x00' \"$dir/text.txt\"",
|
|
896
|
+
"code=$?",
|
|
897
|
+
"set -e",
|
|
898
|
+
"[ \"$code\" -eq 1 ]",
|
|
899
|
+
].join("\n"),
|
|
900
|
+
args: [],
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
export function buildDockerCmaGrepSearchCommand(root, ownershipToken, pattern, glob) {
|
|
904
|
+
const ripgrepGlob = glob === undefined ? "" : cmaGlobToRipgrepGlob(glob);
|
|
905
|
+
return {
|
|
906
|
+
script: [
|
|
907
|
+
"set -eu",
|
|
908
|
+
"exec setsid env \"OMA_GREP_OWNER=$2\" bash -c '",
|
|
909
|
+
"set -euo pipefail",
|
|
910
|
+
"printf \"%s\\0\" __OMA_GREP_READY__",
|
|
911
|
+
"emit_text_matches() {",
|
|
912
|
+
" while IFS= read -r -d \"\" path; do",
|
|
913
|
+
" set +e",
|
|
914
|
+
" LC_ALL=C rg --no-config -qaU -- \"\\x00\" \"$path\"",
|
|
915
|
+
" binary_code=$?",
|
|
916
|
+
" set -e",
|
|
917
|
+
" if [ \"$binary_code\" -eq 1 ]; then printf \"%s\\0\" \"$path\"; elif [ \"$binary_code\" -ne 0 ]; then exit \"$binary_code\"; fi",
|
|
918
|
+
" done",
|
|
919
|
+
"}",
|
|
920
|
+
"set +e",
|
|
921
|
+
"if [ -n \"$3\" ]; then",
|
|
922
|
+
" LC_ALL=C rg --no-config --hidden --no-ignore --color never --files-with-matches --null --glob \"$3\" -- \"$2\" \"$1\" | emit_text_matches",
|
|
923
|
+
"else",
|
|
924
|
+
" LC_ALL=C rg --no-config --hidden --no-ignore --color never --files-with-matches --null -- \"$2\" \"$1\" | emit_text_matches",
|
|
925
|
+
"fi",
|
|
926
|
+
"code=$?",
|
|
927
|
+
"[ \"$code\" -eq 0 ] || [ \"$code\" -eq 1 ]",
|
|
928
|
+
"' oma-rg \"$1\" \"$3\" \"$4\"",
|
|
929
|
+
].join("\n"),
|
|
930
|
+
args: [root, ownershipToken, pattern, ripgrepGlob],
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
export function buildDockerCmaGlobEnumerationCommand(root, ownershipToken) {
|
|
934
|
+
return {
|
|
935
|
+
script: [
|
|
936
|
+
"set -eu",
|
|
937
|
+
"setsid env \"OMA_GLOB_OWNER=$2\" sh -c 'set -eu; printf \"%s\\0\" __OMA_GLOB_READY__; cd \"$1\"; exec find . -type f -print0' \"$2\" \"$1\" &",
|
|
938
|
+
"child=$!",
|
|
939
|
+
"wait \"$child\"",
|
|
940
|
+
].join("\n"),
|
|
941
|
+
args: [root, ownershipToken],
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
export function buildDockerGlobEnumerationCommand(root, ignore = []) {
|
|
945
|
+
const prunedDirectoryNames = directoryNamesPrunedByIgnoreGlobs(ignore);
|
|
946
|
+
return {
|
|
947
|
+
script: [
|
|
948
|
+
"cd \"$1\"",
|
|
949
|
+
"shift",
|
|
950
|
+
"if [ \"$#\" -eq 0 ]; then",
|
|
951
|
+
" find . -type f | sed 's#^./##' | sort",
|
|
952
|
+
"else",
|
|
953
|
+
" find . \\( -type d \\( \"$@\" \\) -prune \\) -o -type f -print | sed 's#^./##' | sort",
|
|
954
|
+
"fi",
|
|
955
|
+
].join("\n"),
|
|
956
|
+
args: [
|
|
957
|
+
root,
|
|
958
|
+
...prunedDirectoryNames.flatMap((name, index) => index === 0 ? ["-name", name] : ["-o", "-name", name]),
|
|
959
|
+
],
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
export function buildDockerOutputListingCommand(outputRoot, limits = {
|
|
963
|
+
maxFiles: MAX_SESSION_OUTPUT_FILES,
|
|
964
|
+
maxFileBytes: MAX_SESSION_OUTPUT_FILE_BYTES,
|
|
965
|
+
maxBytes: MAX_SESSION_OUTPUT_BYTES,
|
|
966
|
+
}) {
|
|
967
|
+
return {
|
|
968
|
+
script: [
|
|
969
|
+
"root=\"$1\"",
|
|
970
|
+
"max_files=\"$2\"",
|
|
971
|
+
"max_file_bytes=\"$3\"",
|
|
972
|
+
"max_bytes=\"$4\"",
|
|
973
|
+
"if [ ! -d \"$root\" ]; then exit 0; fi",
|
|
974
|
+
"cd \"$root\"",
|
|
975
|
+
"count=0",
|
|
976
|
+
"total=0",
|
|
977
|
+
"find . -type f -print0 | sort -z | while IFS= read -r -d '' file; do",
|
|
978
|
+
" rel=\"${file#./}\"",
|
|
979
|
+
" size=$(wc -c < \"$file\")",
|
|
980
|
+
" count=$((count + 1))",
|
|
981
|
+
" if [ \"$count\" -gt \"$max_files\" ]; then",
|
|
982
|
+
" printf 'session output file count exceeds %s\\n' \"$max_files\" >&2",
|
|
983
|
+
" exit 42",
|
|
984
|
+
" fi",
|
|
985
|
+
" if [ \"$size\" -gt \"$max_file_bytes\" ]; then",
|
|
986
|
+
" printf 'session output file exceeds %s bytes: %s\\n' \"$max_file_bytes\" \"$rel\" >&2",
|
|
987
|
+
" exit 42",
|
|
988
|
+
" fi",
|
|
989
|
+
" total=$((total + size))",
|
|
990
|
+
" if [ \"$total\" -gt \"$max_bytes\" ]; then",
|
|
991
|
+
" printf 'session output bytes exceed %s\\n' \"$max_bytes\" >&2",
|
|
992
|
+
" exit 42",
|
|
993
|
+
" fi",
|
|
994
|
+
" sha=$(sha256sum \"$file\" | awk '{print $1}')",
|
|
995
|
+
" printf '%s\\0%s\\0%s\\0' \"$rel\" \"$size\" \"$sha\"",
|
|
996
|
+
"done",
|
|
997
|
+
].join("\n"),
|
|
998
|
+
args: [
|
|
999
|
+
outputRoot,
|
|
1000
|
+
String(limits.maxFiles),
|
|
1001
|
+
String(limits.maxFileBytes),
|
|
1002
|
+
String(limits.maxBytes),
|
|
1003
|
+
],
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
export function directoryNamesPrunedByIgnoreGlobs(ignore) {
|
|
1007
|
+
const names = new Set();
|
|
1008
|
+
for (const pattern of ignore) {
|
|
1009
|
+
const normalized = pattern.replaceAll("\\", "/");
|
|
1010
|
+
const match = /(?:^|\/)([^/*?[\]{}!]+)\/\*\*$/.exec(normalized);
|
|
1011
|
+
if (match?.[1])
|
|
1012
|
+
names.add(match[1]);
|
|
1013
|
+
}
|
|
1014
|
+
return [...names].sort();
|
|
1015
|
+
}
|
|
1016
|
+
export function assertInsideDockerWorkspace(absolutePath, workspacePath = DEFAULT_WORKSPACE) {
|
|
1017
|
+
if (!posix.isAbsolute(absolutePath)) {
|
|
1018
|
+
throw new Error(`Sandbox path must be absolute: ${absolutePath}`);
|
|
1019
|
+
}
|
|
1020
|
+
const root = posix.resolve(workspacePath);
|
|
1021
|
+
const path = posix.resolve(absolutePath);
|
|
1022
|
+
const rel = posix.relative(root, path);
|
|
1023
|
+
if (rel === "" || (!rel.startsWith("..") && !posix.isAbsolute(rel))) {
|
|
1024
|
+
return path;
|
|
1025
|
+
}
|
|
1026
|
+
throw new Error(`Sandbox path escapes workspace: ${absolutePath}`);
|
|
1027
|
+
}
|
|
1028
|
+
export function assertInsideDockerSearchRoot(absolutePath, roots) {
|
|
1029
|
+
if (!posix.isAbsolute(absolutePath)) {
|
|
1030
|
+
throw new Error(`Sandbox search path must be absolute: ${absolutePath}`);
|
|
1031
|
+
}
|
|
1032
|
+
const path = posix.resolve(absolutePath);
|
|
1033
|
+
for (const rootPath of roots) {
|
|
1034
|
+
const root = posix.resolve(rootPath);
|
|
1035
|
+
const rel = posix.relative(root, path);
|
|
1036
|
+
if (rel === "" || (!rel.startsWith("..") && !posix.isAbsolute(rel))) {
|
|
1037
|
+
return path;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
throw new Error(`Sandbox search path escapes approved roots: ${absolutePath}`);
|
|
1041
|
+
}
|
|
1042
|
+
export function assertInsideUploadsPath(absolutePath, uploadsPath = DEFAULT_UPLOADS_PATH) {
|
|
1043
|
+
if (!posix.isAbsolute(absolutePath)) {
|
|
1044
|
+
throw new Error(`Session file mount path must be absolute: ${absolutePath}`);
|
|
1045
|
+
}
|
|
1046
|
+
const root = posix.resolve(uploadsPath);
|
|
1047
|
+
const path = posix.resolve(absolutePath);
|
|
1048
|
+
const rel = posix.relative(root, path);
|
|
1049
|
+
if (rel !== "" && !rel.startsWith("..") && !posix.isAbsolute(rel)) {
|
|
1050
|
+
return rel;
|
|
1051
|
+
}
|
|
1052
|
+
throw new Error(`Session file mount path escapes uploads root: ${absolutePath}`);
|
|
1053
|
+
}
|
|
1054
|
+
function assertInsideMountRoot(absolutePath, rootPath) {
|
|
1055
|
+
if (!posix.isAbsolute(absolutePath))
|
|
1056
|
+
throw new Error(`Session file mount path must be absolute: ${absolutePath}`);
|
|
1057
|
+
const root = posix.resolve(rootPath);
|
|
1058
|
+
const rel = posix.relative(root, posix.resolve(absolutePath));
|
|
1059
|
+
if (rel !== "" && !rel.startsWith("..") && !posix.isAbsolute(rel))
|
|
1060
|
+
return rel;
|
|
1061
|
+
throw new Error(`Session file mount path escapes ${rootPath}: ${absolutePath}`);
|
|
1062
|
+
}
|
|
1063
|
+
export function assertInsideDockerOutputPath(absolutePath, outputsPath = DEFAULT_OUTPUTS_PATH) {
|
|
1064
|
+
if (!posix.isAbsolute(absolutePath)) {
|
|
1065
|
+
throw new Error(`Session output path must be absolute: ${absolutePath}`);
|
|
1066
|
+
}
|
|
1067
|
+
const root = posix.resolve(outputsPath);
|
|
1068
|
+
const path = posix.resolve(absolutePath);
|
|
1069
|
+
const rel = posix.relative(root, path);
|
|
1070
|
+
if (rel !== "" && !rel.startsWith("..") && !posix.isAbsolute(rel)) {
|
|
1071
|
+
return path;
|
|
1072
|
+
}
|
|
1073
|
+
throw new Error(`Session output path escapes outputs root: ${absolutePath}`);
|
|
1074
|
+
}
|
|
1075
|
+
function parseOutputListing(stdout) {
|
|
1076
|
+
if (stdout.length === 0)
|
|
1077
|
+
return [];
|
|
1078
|
+
const fields = stdout.toString("utf8").split("\0");
|
|
1079
|
+
if (fields.at(-1) === "")
|
|
1080
|
+
fields.pop();
|
|
1081
|
+
if (fields.length % 3 !== 0) {
|
|
1082
|
+
throw new Error("Docker output listing returned malformed records");
|
|
1083
|
+
}
|
|
1084
|
+
const out = [];
|
|
1085
|
+
for (let index = 0; index < fields.length; index += 3) {
|
|
1086
|
+
const relativePath = fields[index];
|
|
1087
|
+
const sizeBytes = Number(fields[index + 1]);
|
|
1088
|
+
const sha256 = fields[index + 2];
|
|
1089
|
+
if (relativePath.length === 0 ||
|
|
1090
|
+
posix.isAbsolute(relativePath) ||
|
|
1091
|
+
relativePath.split("/").includes("..")) {
|
|
1092
|
+
throw new Error(`Unsafe session output path: ${relativePath}`);
|
|
1093
|
+
}
|
|
1094
|
+
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0) {
|
|
1095
|
+
throw new Error(`Invalid session output size: ${relativePath}`);
|
|
1096
|
+
}
|
|
1097
|
+
if (!/^[0-9a-f]{64}$/i.test(sha256)) {
|
|
1098
|
+
throw new Error(`Invalid session output checksum: ${relativePath}`);
|
|
1099
|
+
}
|
|
1100
|
+
out.push({ relativePath, sizeBytes, sha256 });
|
|
1101
|
+
}
|
|
1102
|
+
return out;
|
|
1103
|
+
}
|
|
1104
|
+
function mimeTypeForFilename(filename) {
|
|
1105
|
+
const lower = filename.toLowerCase();
|
|
1106
|
+
if (lower.endsWith(".txt"))
|
|
1107
|
+
return "text/plain";
|
|
1108
|
+
if (lower.endsWith(".json"))
|
|
1109
|
+
return "application/json";
|
|
1110
|
+
if (lower.endsWith(".csv"))
|
|
1111
|
+
return "text/csv";
|
|
1112
|
+
if (lower.endsWith(".html") || lower.endsWith(".htm"))
|
|
1113
|
+
return "text/html";
|
|
1114
|
+
if (lower.endsWith(".md"))
|
|
1115
|
+
return "text/markdown";
|
|
1116
|
+
if (lower.endsWith(".pptx")) {
|
|
1117
|
+
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
1118
|
+
}
|
|
1119
|
+
if (lower.endsWith(".png"))
|
|
1120
|
+
return "image/png";
|
|
1121
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
|
|
1122
|
+
return "image/jpeg";
|
|
1123
|
+
if (lower.endsWith(".pdf"))
|
|
1124
|
+
return "application/pdf";
|
|
1125
|
+
return "application/octet-stream";
|
|
1126
|
+
}
|
|
1127
|
+
async function* dockerOutputBytes(read) {
|
|
1128
|
+
yield (await read()).stdout;
|
|
1129
|
+
}
|
|
1130
|
+
async function writeMountFile(tempRoot, relativePath, mount) {
|
|
1131
|
+
const segments = relativePath.split("/");
|
|
1132
|
+
const filePath = joinHostPath(tempRoot, ...segments);
|
|
1133
|
+
await mkdir(joinHostPath(tempRoot, ...segments.slice(0, -1)), {
|
|
1134
|
+
recursive: true,
|
|
1135
|
+
mode: 0o755,
|
|
1136
|
+
});
|
|
1137
|
+
const handle = await open(filePath, "w", 0o644);
|
|
1138
|
+
const hash = createHash("sha256");
|
|
1139
|
+
let size = 0;
|
|
1140
|
+
try {
|
|
1141
|
+
for await (const chunk of chunksForMount(mount.bytes)) {
|
|
1142
|
+
const buffer = Buffer.from(chunk);
|
|
1143
|
+
size += buffer.byteLength;
|
|
1144
|
+
hash.update(buffer);
|
|
1145
|
+
await handle.write(buffer);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
finally {
|
|
1149
|
+
await handle.close();
|
|
1150
|
+
}
|
|
1151
|
+
const sha256 = hash.digest("hex");
|
|
1152
|
+
if (size !== mount.sizeBytes) {
|
|
1153
|
+
throw new Error(`Session file mount ${mount.snapshotFileId} size mismatch: expected ${mount.sizeBytes}, got ${size}`);
|
|
1154
|
+
}
|
|
1155
|
+
if (sha256 !== mount.sha256) {
|
|
1156
|
+
throw new Error(`Session file mount ${mount.snapshotFileId} failed integrity validation`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async function* chunksForMount(bytes) {
|
|
1160
|
+
if (bytes instanceof Uint8Array) {
|
|
1161
|
+
yield bytes;
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
yield* bytes;
|
|
1165
|
+
}
|
|
1166
|
+
function createTarArchive(sourceDirectory) {
|
|
1167
|
+
const result = spawnSync("tar", ["-C", sourceDirectory, "-cf", "-", "."], {
|
|
1168
|
+
encoding: "buffer",
|
|
1169
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1170
|
+
});
|
|
1171
|
+
if (result.status !== 0) {
|
|
1172
|
+
throw new Error(`tar archive failed with ${result.status}: ${result.stderr.toString("utf8")}`);
|
|
1173
|
+
}
|
|
1174
|
+
return result.stdout;
|
|
1175
|
+
}
|
|
1176
|
+
export function filterDockerEnv(source, allowlist) {
|
|
1177
|
+
const out = {};
|
|
1178
|
+
for (const key of allowlist) {
|
|
1179
|
+
if (source[key] !== undefined)
|
|
1180
|
+
out[key] = source[key];
|
|
1181
|
+
}
|
|
1182
|
+
return out;
|
|
1183
|
+
}
|
|
1184
|
+
export async function reapDockerSandboxContainers(opts) {
|
|
1185
|
+
const dockerCommand = opts.dockerCommand ?? "docker";
|
|
1186
|
+
const listed = await dockerChecked(dockerCommand, [
|
|
1187
|
+
"ps",
|
|
1188
|
+
"-aq",
|
|
1189
|
+
"--filter",
|
|
1190
|
+
`label=${SANDBOX_LABEL_KEY}=${SANDBOX_LABEL_VALUE}`,
|
|
1191
|
+
"--filter",
|
|
1192
|
+
`label=${OWNER_LABEL_KEY}=${OWNER_LABEL_VALUE}`,
|
|
1193
|
+
...(opts.labelFilters?.flatMap((label) => [
|
|
1194
|
+
"--filter",
|
|
1195
|
+
`label=${label}`,
|
|
1196
|
+
]) ?? []),
|
|
1197
|
+
]);
|
|
1198
|
+
const ids = listed.stdout.toString("utf8").split("\n").filter(Boolean);
|
|
1199
|
+
if (ids.length === 0)
|
|
1200
|
+
return 0;
|
|
1201
|
+
const now = opts.now?.() ?? Date.now();
|
|
1202
|
+
const expired = [];
|
|
1203
|
+
for (const id of ids) {
|
|
1204
|
+
const inspected = await dockerChecked(dockerCommand, [
|
|
1205
|
+
"inspect",
|
|
1206
|
+
id,
|
|
1207
|
+
"--format",
|
|
1208
|
+
"{{json .Created}}",
|
|
1209
|
+
]);
|
|
1210
|
+
const created = JSON.parse(inspected.stdout.toString("utf8"));
|
|
1211
|
+
if (now - new Date(created).getTime() >= opts.olderThanMs) {
|
|
1212
|
+
expired.push(id);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (expired.length === 0)
|
|
1216
|
+
return 0;
|
|
1217
|
+
await dockerChecked(dockerCommand, ["rm", "-f", ...expired]);
|
|
1218
|
+
return expired.length;
|
|
1219
|
+
}
|
|
1220
|
+
function resolveDockerOptions(opts) {
|
|
1221
|
+
return {
|
|
1222
|
+
image: opts.image ?? DEFAULT_DOCKER_SANDBOX_IMAGE,
|
|
1223
|
+
dockerCommand: opts.dockerCommand ?? "docker",
|
|
1224
|
+
workspacePath: opts.workspacePath ?? DEFAULT_WORKSPACE,
|
|
1225
|
+
uploadsPath: DEFAULT_UPLOADS_PATH,
|
|
1226
|
+
outputsPath: DEFAULT_OUTPUTS_PATH,
|
|
1227
|
+
envAllowlist: new Set(opts.envAllowlist ?? []),
|
|
1228
|
+
operationTimeoutMs: opts.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS,
|
|
1229
|
+
startupTimeoutMs: opts.startupTimeoutMs ?? DEFAULT_DOCKER_STARTUP_TIMEOUT_MS,
|
|
1230
|
+
containerNamePrefix: opts.containerNamePrefix ?? "oma-sandbox",
|
|
1231
|
+
memory: opts.memory ?? DEFAULT_MEMORY,
|
|
1232
|
+
cpus: opts.cpus ?? DEFAULT_CPUS,
|
|
1233
|
+
pidsLimit: opts.pidsLimit ?? DEFAULT_PIDS_LIMIT,
|
|
1234
|
+
tmpfsSize: opts.tmpfsSize ?? DEFAULT_TMPFS_SIZE,
|
|
1235
|
+
outputsTmpfsSize: opts.outputsTmpfsSize ?? DEFAULT_OUTPUTS_TMPFS_SIZE,
|
|
1236
|
+
maxOutputFiles: opts.maxOutputFiles ?? MAX_SESSION_OUTPUT_FILES,
|
|
1237
|
+
maxOutputFileBytes: opts.maxOutputFileBytes ?? MAX_SESSION_OUTPUT_FILE_BYTES,
|
|
1238
|
+
maxOutputBytes: opts.maxOutputBytes ?? MAX_SESSION_OUTPUT_BYTES,
|
|
1239
|
+
extraLabels: opts.extraLabels ?? {},
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
function assertTmpfsMemoryHeadroom(memory, tmpfsSize, outputsTmpfsSize, skillsTmpfsSize) {
|
|
1243
|
+
const memoryBytes = parseDockerByteSize(memory, "memory");
|
|
1244
|
+
const tmpfsBytes = parseDockerByteSize(tmpfsSize, "tmpfsSize");
|
|
1245
|
+
const outputTmpfsBytes = parseDockerByteSize(outputsTmpfsSize, "outputsTmpfsSize");
|
|
1246
|
+
const skillsTmpfsBytes = parseDockerByteSize(skillsTmpfsSize, "skillsTmpfsSize");
|
|
1247
|
+
if (tmpfsBytes * 2 + outputTmpfsBytes + skillsTmpfsBytes < memoryBytes)
|
|
1248
|
+
return;
|
|
1249
|
+
throw new Error("Docker sandbox memory must exceed workspace tmpfs plus uploads tmpfs plus outputs tmpfs plus skills tmpfs");
|
|
1250
|
+
}
|
|
1251
|
+
function parseDockerByteSize(value, label) {
|
|
1252
|
+
const match = /^([1-9][0-9]*)([bkmg])?$/i.exec(value);
|
|
1253
|
+
if (!match) {
|
|
1254
|
+
throw new Error(`Docker sandbox ${label} must use bytes or a k/m/g suffix: ${value}`);
|
|
1255
|
+
}
|
|
1256
|
+
const amount = Number(match[1]);
|
|
1257
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
1258
|
+
const multiplier = unit === "g"
|
|
1259
|
+
? 1024 * 1024 * 1024
|
|
1260
|
+
: unit === "m"
|
|
1261
|
+
? 1024 * 1024
|
|
1262
|
+
: unit === "k"
|
|
1263
|
+
? 1024
|
|
1264
|
+
: 1;
|
|
1265
|
+
return amount * multiplier;
|
|
1266
|
+
}
|
|
1267
|
+
function dockerContainerName(prefix, workspaceId, sessionId) {
|
|
1268
|
+
const suffix = Math.random().toString(36).slice(2, 8);
|
|
1269
|
+
return [
|
|
1270
|
+
sanitizeDockerNamePart(prefix),
|
|
1271
|
+
sanitizeDockerNamePart(workspaceId),
|
|
1272
|
+
sanitizeDockerNamePart(sessionId),
|
|
1273
|
+
Date.now().toString(36),
|
|
1274
|
+
suffix,
|
|
1275
|
+
].join("-");
|
|
1276
|
+
}
|
|
1277
|
+
function sanitizeDockerNamePart(value) {
|
|
1278
|
+
return value
|
|
1279
|
+
.toLowerCase()
|
|
1280
|
+
.replace(/[^a-z0-9_.-]+/g, "-")
|
|
1281
|
+
.replace(/^-+|-+$/g, "")
|
|
1282
|
+
.slice(0, 48) || "x";
|
|
1283
|
+
}
|
|
1284
|
+
function randomExecId() {
|
|
1285
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1286
|
+
}
|
|
1287
|
+
function recordSandboxNotDisposed(disposed) {
|
|
1288
|
+
if (disposed.value)
|
|
1289
|
+
throw new Error("Sandbox provider is disposed");
|
|
1290
|
+
}
|
|
1291
|
+
function bashDispatchSentinel(token) {
|
|
1292
|
+
return Buffer.from(`${BASH_DISPATCH_PREFIX}${token}\n`);
|
|
1293
|
+
}
|
|
1294
|
+
function bashTerminalPrefix(token) {
|
|
1295
|
+
return Buffer.from(`${BASH_TERMINAL_PREFIX}${token}:`);
|
|
1296
|
+
}
|
|
1297
|
+
export function createBashDispatchFilter(token) {
|
|
1298
|
+
const dispatchSentinel = bashDispatchSentinel(token);
|
|
1299
|
+
const terminalPrefix = bashTerminalPrefix(token);
|
|
1300
|
+
let dispatchSeen = false;
|
|
1301
|
+
let terminalRecord;
|
|
1302
|
+
let terminalCandidate = Buffer.alloc(0);
|
|
1303
|
+
let pendingStdout = Buffer.alloc(0);
|
|
1304
|
+
let pendingStderr = Buffer.alloc(0);
|
|
1305
|
+
const filterAfterDispatch = (chunk) => {
|
|
1306
|
+
pendingStdout = Buffer.concat([pendingStdout, chunk]);
|
|
1307
|
+
const index = pendingStdout.indexOf(terminalPrefix);
|
|
1308
|
+
if (index >= 0) {
|
|
1309
|
+
const lineEnd = pendingStdout.indexOf("\n", index);
|
|
1310
|
+
if (lineEnd < 0) {
|
|
1311
|
+
const out = pendingStdout.subarray(0, index);
|
|
1312
|
+
pendingStdout = pendingStdout.subarray(index);
|
|
1313
|
+
return out;
|
|
1314
|
+
}
|
|
1315
|
+
const line = pendingStdout.subarray(index, lineEnd).toString("utf8");
|
|
1316
|
+
terminalRecord = parseBashTerminalRecord(line, token);
|
|
1317
|
+
const out = Buffer.concat([
|
|
1318
|
+
pendingStdout.subarray(0, index),
|
|
1319
|
+
pendingStdout.subarray(lineEnd + 1),
|
|
1320
|
+
]);
|
|
1321
|
+
pendingStdout = Buffer.alloc(0);
|
|
1322
|
+
return out;
|
|
1323
|
+
}
|
|
1324
|
+
if (terminalCandidate.length > 0) {
|
|
1325
|
+
const combined = Buffer.concat([terminalCandidate, pendingStdout]);
|
|
1326
|
+
if (isPrefixOf(combined, terminalPrefix)) {
|
|
1327
|
+
terminalCandidate = combined;
|
|
1328
|
+
pendingStdout = Buffer.alloc(0);
|
|
1329
|
+
return Buffer.alloc(0);
|
|
1330
|
+
}
|
|
1331
|
+
const out = combined;
|
|
1332
|
+
terminalCandidate = Buffer.alloc(0);
|
|
1333
|
+
pendingStdout = Buffer.alloc(0);
|
|
1334
|
+
return out;
|
|
1335
|
+
}
|
|
1336
|
+
const candidateStart = findTerminalPrefixCandidateStart(pendingStdout, terminalPrefix);
|
|
1337
|
+
if (candidateStart < 0) {
|
|
1338
|
+
const out = pendingStdout;
|
|
1339
|
+
pendingStdout = Buffer.alloc(0);
|
|
1340
|
+
return out;
|
|
1341
|
+
}
|
|
1342
|
+
const candidate = pendingStdout.subarray(candidateStart);
|
|
1343
|
+
if (isPrefixOf(candidate, terminalPrefix)) {
|
|
1344
|
+
const out = pendingStdout.subarray(0, candidateStart);
|
|
1345
|
+
terminalCandidate = candidate;
|
|
1346
|
+
pendingStdout = Buffer.alloc(0);
|
|
1347
|
+
return out;
|
|
1348
|
+
}
|
|
1349
|
+
const out = pendingStdout;
|
|
1350
|
+
pendingStdout = Buffer.alloc(0);
|
|
1351
|
+
return out;
|
|
1352
|
+
};
|
|
1353
|
+
return {
|
|
1354
|
+
dispatchSeen: () => dispatchSeen,
|
|
1355
|
+
terminalRecord: () => terminalRecord,
|
|
1356
|
+
stderr: (chunk) => {
|
|
1357
|
+
if (dispatchSeen)
|
|
1358
|
+
return chunk;
|
|
1359
|
+
pendingStderr = Buffer.concat([pendingStderr, chunk]);
|
|
1360
|
+
return Buffer.alloc(0);
|
|
1361
|
+
},
|
|
1362
|
+
stdout: (chunk) => {
|
|
1363
|
+
if (dispatchSeen)
|
|
1364
|
+
return filterAfterDispatch(chunk);
|
|
1365
|
+
pendingStdout = Buffer.concat([pendingStdout, chunk]);
|
|
1366
|
+
const index = pendingStdout.indexOf(dispatchSentinel);
|
|
1367
|
+
if (index < 0)
|
|
1368
|
+
return Buffer.alloc(0);
|
|
1369
|
+
dispatchSeen = true;
|
|
1370
|
+
const afterDispatch = Buffer.concat([
|
|
1371
|
+
pendingStdout.subarray(0, index),
|
|
1372
|
+
pendingStderr,
|
|
1373
|
+
pendingStdout.subarray(index + dispatchSentinel.length),
|
|
1374
|
+
]);
|
|
1375
|
+
pendingStdout = Buffer.alloc(0);
|
|
1376
|
+
pendingStderr = Buffer.alloc(0);
|
|
1377
|
+
return filterAfterDispatch(afterDispatch);
|
|
1378
|
+
},
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
function parseBashTerminalRecord(line, token) {
|
|
1382
|
+
const prefix = `${BASH_TERMINAL_PREFIX}${token}:`;
|
|
1383
|
+
if (!line.startsWith(prefix))
|
|
1384
|
+
return undefined;
|
|
1385
|
+
const payload = line.slice(prefix.length);
|
|
1386
|
+
const [kind, exitCodeText, extra] = payload.split(":");
|
|
1387
|
+
if (extra !== undefined || !/^(0|[1-9][0-9]*)$/.test(exitCodeText ?? "")) {
|
|
1388
|
+
return undefined;
|
|
1389
|
+
}
|
|
1390
|
+
const exitCode = Number(exitCodeText);
|
|
1391
|
+
if (kind === "exit")
|
|
1392
|
+
return { kind, exitCode };
|
|
1393
|
+
if (kind === "timeout")
|
|
1394
|
+
return { kind, exitCode };
|
|
1395
|
+
return undefined;
|
|
1396
|
+
}
|
|
1397
|
+
function findTerminalPrefixCandidateStart(buffer, terminalPrefix) {
|
|
1398
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
1399
|
+
if (buffer[index] === terminalPrefix[0])
|
|
1400
|
+
return index;
|
|
1401
|
+
}
|
|
1402
|
+
return -1;
|
|
1403
|
+
}
|
|
1404
|
+
function isPrefixOf(candidate, value) {
|
|
1405
|
+
if (candidate.length > value.length)
|
|
1406
|
+
return false;
|
|
1407
|
+
return value.subarray(0, candidate.length).equals(candidate);
|
|
1408
|
+
}
|
|
1409
|
+
function filterDockerEnvObject(env) {
|
|
1410
|
+
const out = {};
|
|
1411
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
1412
|
+
if (value !== undefined)
|
|
1413
|
+
out[key] = value;
|
|
1414
|
+
}
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1417
|
+
async function dockerChecked(dockerCommand, args, opts = {}) {
|
|
1418
|
+
const result = await dockerExec(dockerCommand, args, opts);
|
|
1419
|
+
if (result.exitCode !== 0) {
|
|
1420
|
+
throw new Error(`docker ${args.join(" ")} failed with ${result.exitCode}: ${result.stderr.toString("utf8") || result.stdout.toString("utf8")}`);
|
|
1421
|
+
}
|
|
1422
|
+
return result;
|
|
1423
|
+
}
|
|
1424
|
+
function throwIfUnexpectedBoundedExit(result, context, normalNonZeroExitCodes) {
|
|
1425
|
+
if (result.exitCode !== null &&
|
|
1426
|
+
normalNonZeroExitCodes.has(result.exitCode) &&
|
|
1427
|
+
result.stderr.length === 0) {
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
throw new Error(`${context} failed with ${result.exitCode}: ${result.stderr.toString("utf8") || result.stdout.toString("utf8")}`);
|
|
1431
|
+
}
|
|
1432
|
+
async function dockerExec(dockerCommand, args, opts = {}) {
|
|
1433
|
+
return new Promise((resolve, reject) => {
|
|
1434
|
+
const child = spawn(dockerCommand, args, {
|
|
1435
|
+
detached: true,
|
|
1436
|
+
env: filterDockerEnvObject(process.env),
|
|
1437
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1438
|
+
});
|
|
1439
|
+
if (child.pid !== undefined)
|
|
1440
|
+
opts.activePids?.add(child.pid);
|
|
1441
|
+
const stdout = [];
|
|
1442
|
+
const stderr = [];
|
|
1443
|
+
let stdoutBytes = 0;
|
|
1444
|
+
let combinedBytes = 0;
|
|
1445
|
+
let settled = false;
|
|
1446
|
+
let terminalError;
|
|
1447
|
+
let timer;
|
|
1448
|
+
const settle = (fn) => {
|
|
1449
|
+
if (settled)
|
|
1450
|
+
return;
|
|
1451
|
+
settled = true;
|
|
1452
|
+
if (timer)
|
|
1453
|
+
clearTimeout(timer);
|
|
1454
|
+
if (child.pid !== undefined)
|
|
1455
|
+
opts.activePids?.delete(child.pid);
|
|
1456
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1457
|
+
fn();
|
|
1458
|
+
};
|
|
1459
|
+
const kill = () => {
|
|
1460
|
+
if (child.pid === undefined)
|
|
1461
|
+
return;
|
|
1462
|
+
killProcessGroup(child.pid);
|
|
1463
|
+
if (!child.killed)
|
|
1464
|
+
child.kill();
|
|
1465
|
+
};
|
|
1466
|
+
const terminate = (error) => {
|
|
1467
|
+
if (terminalError || settled)
|
|
1468
|
+
return;
|
|
1469
|
+
terminalError = error;
|
|
1470
|
+
kill();
|
|
1471
|
+
};
|
|
1472
|
+
const onAbort = () => {
|
|
1473
|
+
opts.onAbort?.();
|
|
1474
|
+
terminate(new Error("aborted"));
|
|
1475
|
+
};
|
|
1476
|
+
const chargeCombinedOutput = (chunk) => {
|
|
1477
|
+
combinedBytes += chunk.byteLength;
|
|
1478
|
+
if (opts.maxCombinedBytes !== undefined && combinedBytes > opts.maxCombinedBytes) {
|
|
1479
|
+
terminate(new Error(`docker output exceeded ${opts.maxCombinedBytes} bytes`));
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
return true;
|
|
1483
|
+
};
|
|
1484
|
+
child.stdout.on("data", (chunk) => {
|
|
1485
|
+
if (!chargeCombinedOutput(chunk))
|
|
1486
|
+
return;
|
|
1487
|
+
const maxStdoutBytes = opts.maxStdoutBytes;
|
|
1488
|
+
const nextStdoutBytes = stdoutBytes + chunk.byteLength;
|
|
1489
|
+
if (maxStdoutBytes !== undefined &&
|
|
1490
|
+
nextStdoutBytes > maxStdoutBytes) {
|
|
1491
|
+
terminate(new Error(`docker stdout exceeded ${maxStdoutBytes} bytes`));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
stdoutBytes = nextStdoutBytes;
|
|
1495
|
+
stdout.push(chunk);
|
|
1496
|
+
opts.onStdout?.(chunk);
|
|
1497
|
+
opts.onData?.(chunk);
|
|
1498
|
+
});
|
|
1499
|
+
child.stderr.on("data", (chunk) => {
|
|
1500
|
+
if (!chargeCombinedOutput(chunk))
|
|
1501
|
+
return;
|
|
1502
|
+
stderr.push(chunk);
|
|
1503
|
+
opts.onStderr?.(chunk);
|
|
1504
|
+
opts.onData?.(chunk);
|
|
1505
|
+
});
|
|
1506
|
+
child.on("error", (error) => settle(() => reject(error)));
|
|
1507
|
+
child.on("close", (exitCode) => settle(() => {
|
|
1508
|
+
if (terminalError)
|
|
1509
|
+
reject(terminalError);
|
|
1510
|
+
else
|
|
1511
|
+
resolve({
|
|
1512
|
+
stdout: Buffer.concat(stdout),
|
|
1513
|
+
stderr: Buffer.concat(stderr),
|
|
1514
|
+
exitCode,
|
|
1515
|
+
});
|
|
1516
|
+
}));
|
|
1517
|
+
if (opts.signal?.aborted)
|
|
1518
|
+
onAbort();
|
|
1519
|
+
else
|
|
1520
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1521
|
+
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
|
|
1522
|
+
timer = setTimeout(() => {
|
|
1523
|
+
opts.onAbort?.();
|
|
1524
|
+
terminate(new DockerTimeoutError());
|
|
1525
|
+
}, opts.timeoutMs);
|
|
1526
|
+
}
|
|
1527
|
+
if (opts.input !== undefined)
|
|
1528
|
+
child.stdin.end(opts.input);
|
|
1529
|
+
else
|
|
1530
|
+
child.stdin.end();
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
function killProcessGroup(pid) {
|
|
1534
|
+
try {
|
|
1535
|
+
process.kill(-pid);
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
// The Docker CLI process may already have exited.
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
class DockerTimeoutError extends Error {
|
|
1542
|
+
constructor() {
|
|
1543
|
+
super("Docker operation timed out");
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function killInContainerProcessGroup(dockerCommand, containerName, pidFile) {
|
|
1547
|
+
spawnSync(dockerCommand, buildDockerExecShellArgs(containerName, [
|
|
1548
|
+
"pidfile=\"$1\"",
|
|
1549
|
+
"timerfile=\"${pidfile}.timer\"",
|
|
1550
|
+
"for _ in $(seq 1 50); do",
|
|
1551
|
+
" [ -f \"$pidfile\" ] && break",
|
|
1552
|
+
" sleep 0.01",
|
|
1553
|
+
"done",
|
|
1554
|
+
"if [ -f \"$timerfile\" ]; then",
|
|
1555
|
+
" timer=$(cat \"$timerfile\")",
|
|
1556
|
+
" kill -KILL \"$timer\" 2>/dev/null || true",
|
|
1557
|
+
"fi",
|
|
1558
|
+
"if [ -f \"$pidfile\" ]; then",
|
|
1559
|
+
" pid=$(cat \"$pidfile\")",
|
|
1560
|
+
" kill -KILL \"-$pid\" 2>/dev/null || kill -KILL \"$pid\" 2>/dev/null || true",
|
|
1561
|
+
" for _ in $(seq 1 50); do",
|
|
1562
|
+
" kill -0 \"$pid\" 2>/dev/null || break",
|
|
1563
|
+
" sleep 0.02",
|
|
1564
|
+
" done",
|
|
1565
|
+
" rm -f \"$pidfile\" \"$timerfile\"",
|
|
1566
|
+
"fi",
|
|
1567
|
+
].join("\n"), [pidFile]), { stdio: "ignore" });
|
|
1568
|
+
}
|
|
1569
|
+
function forceRemoveDockerContainer(dockerCommand, containerName) {
|
|
1570
|
+
spawnSync(dockerCommand, ["rm", "-f", containerName], { stdio: "ignore" });
|
|
1571
|
+
}
|
|
1572
|
+
async function removeDockerContainerChecked(dockerCommand, containerName, timeoutMs) {
|
|
1573
|
+
let lastError;
|
|
1574
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1575
|
+
try {
|
|
1576
|
+
const result = await dockerExec(dockerCommand, ["rm", "-f", containerName], { timeoutMs });
|
|
1577
|
+
if (result.exitCode === 0)
|
|
1578
|
+
return;
|
|
1579
|
+
const detail = Buffer.concat([result.stderr, result.stdout]).toString("utf8");
|
|
1580
|
+
if (/no such (?:container|object)/i.test(detail))
|
|
1581
|
+
return;
|
|
1582
|
+
lastError = new Error(`docker rm -f ${containerName} failed with ${result.exitCode}: ${detail}`);
|
|
1583
|
+
}
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
lastError = error;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
throw new Error(`Failed to remove poisoned Docker sandbox ${containerName}`, { cause: lastError });
|
|
1589
|
+
}
|