@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.2
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 +201 -0
- package/NOTICE +1 -0
- package/README.md +124 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/ZCode-LICENSE +202 -0
- package/dist/index.mjs +4871 -0
- package/dist/native-command-plan.LICENSE.txt +221 -0
- package/dist/native-command-plan.json +16 -0
- package/dist/native-command-plan.mjs +8 -0
- package/dist/rpc-entry.mjs +5682 -0
- package/package.json +169 -4
- package/src/native/a2a-receipt-source.mjs +19 -0
- package/src/native/a2a-tools.mjs +517 -0
- package/src/native/activity-status.mjs +30 -0
- package/src/native/artifact-codec.mjs +481 -0
- package/src/native/artifact-store.mjs +1162 -0
- package/src/native/assistant-stream.mjs +76 -0
- package/src/native/background-command-port.mjs +95 -0
- package/src/native/browser-action-observation.mjs +43 -0
- package/src/native/browser-availability.mjs +22 -0
- package/src/native/browser-canvas-metrics.mjs +71 -0
- package/src/native/browser-dom.mjs +193 -0
- package/src/native/browser-playwright.mjs +1249 -0
- package/src/native/browser-vision.mjs +369 -0
- package/src/native/browser-workspace-policy.mjs +22 -0
- package/src/native/browser-workspace-protocol.mjs +95 -0
- package/src/native/browser-workspace-server.mjs +164 -0
- package/src/native/browser-workspace.mjs +355 -0
- package/src/native/captured-source.mjs +3 -0
- package/src/native/child-budget-allocation.mjs +87 -0
- package/src/native/child-capabilities.mjs +35 -0
- package/src/native/child-command-resource.mjs +20 -0
- package/src/native/child-history.mjs +188 -0
- package/src/native/child-merge.mjs +98 -0
- package/src/native/child-model-surface.mjs +19 -0
- package/src/native/child-observation.mjs +100 -0
- package/src/native/child-owner.mjs +620 -0
- package/src/native/child-publication.mjs +76 -0
- package/src/native/child-readers.mjs +366 -0
- package/src/native/child-tools.mjs +46 -0
- package/src/native/child-workspace-binding.mjs +29 -0
- package/src/native/child-workspace-tools.mjs +1 -0
- package/src/native/child-workspaces.mjs +655 -0
- package/src/native/children.mjs +365 -0
- package/src/native/command-plan-worker.mjs +14 -0
- package/src/native/command-plan.mjs +31 -0
- package/src/native/command-progress.mjs +64 -0
- package/src/native/command-shell.mjs +9 -0
- package/src/native/command-workspace-observation.mjs +36 -0
- package/src/native/component-tools.mjs +280 -0
- package/src/native/context-counter.mjs +133 -0
- package/src/native/context-markers.mjs +51 -0
- package/src/native/context-profile.mjs +72 -0
- package/src/native/context-status.mjs +27 -0
- package/src/native/conversation-command-control.mjs +133 -0
- package/src/native/delivery-timeline.mjs +39 -0
- package/src/native/dsh-background-ownership.mjs +102 -0
- package/src/native/dsh-managed-ownership.mjs +23 -0
- package/src/native/dsh-turn-contract.mjs +3 -0
- package/src/native/dsh-turn-ownership.mjs +54 -0
- package/src/native/dsh-turn-runtime.mjs +498 -0
- package/src/native/e2b-background-supervisor.mjs +347 -0
- package/src/native/e2b-background.mjs +368 -0
- package/src/native/e2b-command-state.mjs +40 -0
- package/src/native/e2b-command-tools.mjs +486 -0
- package/src/native/e2b-sandbox-owner.mjs +227 -0
- package/src/native/hook-executor.mjs +249 -0
- package/src/native/instruction-assembly.mjs +60 -0
- package/src/native/instruction-context.mjs +54 -0
- package/src/native/local-background-supervisor.mjs +108 -0
- package/src/native/local-background.mjs +207 -0
- package/src/native/local-command-evidence.mjs +57 -0
- package/src/native/local-command-policy-worker.mjs +197 -0
- package/src/native/local-command-policy.mjs +46 -0
- package/src/native/local-command-state.mjs +177 -0
- package/src/native/local-command-tools.mjs +302 -0
- package/src/native/local-execution-profile.mjs +36 -0
- package/src/native/local-output-policy.mjs +92 -0
- package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
- package/src/native/local-process-bin/darwin-x64/launcher +0 -0
- package/src/native/local-process-bin/linux-arm64/launcher +0 -0
- package/src/native/local-process-bin/linux-x64/launcher +0 -0
- package/src/native/local-process-launcher.c +183 -0
- package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
- package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
- package/src/native/local-process-licenses/NOTICE +21 -0
- package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
- package/src/native/local-process-manifest.json +32 -0
- package/src/native/local-process.mjs +246 -0
- package/src/native/local-resource-profile.mjs +32 -0
- package/src/native/memory-candidate.mjs +121 -0
- package/src/native/memory-extraction-run.mjs +60 -0
- package/src/native/memory-extraction-tools.mjs +116 -0
- package/src/native/memory-recall.mjs +65 -0
- package/src/native/memory-store.mjs +334 -0
- package/src/native/memory-tools.mjs +329 -0
- package/src/native/model-anthropic-cache.mjs +49 -0
- package/src/native/model-anthropic-tool-search.mjs +85 -0
- package/src/native/model-anthropic.mjs +753 -0
- package/src/native/model-chat-usage.mjs +24 -0
- package/src/native/model-inline-reasoning.mjs +111 -0
- package/src/native/model-media.mjs +174 -0
- package/src/native/model-normalize.mjs +228 -0
- package/src/native/model-openai-chat.mjs +561 -0
- package/src/native/model-port.mjs +361 -0
- package/src/native/model-provider-compat.mjs +129 -0
- package/src/native/model-replay.mjs +153 -0
- package/src/native/model-request.mjs +15 -0
- package/src/native/model-responses.mjs +1205 -0
- package/src/native/model-sse.mjs +171 -0
- package/src/native/model-surface-host.mjs +27 -0
- package/src/native/model-tool-search-policy.mjs +16 -0
- package/src/native/model-transport-observation.mjs +27 -0
- package/src/native/owned-child.mjs +114 -0
- package/src/native/owned-command.mjs +106 -0
- package/src/native/python-syntax-worker.mjs +60 -0
- package/src/native/python-syntax.mjs +50 -0
- package/src/native/read-interruption.mjs +16 -0
- package/src/native/result-stream.mjs +63 -0
- package/src/native/ruff-ast.mjs +95 -0
- package/src/native/run-observation.mjs +128 -0
- package/src/native/session-delivery-journal.mjs +96 -0
- package/src/native/session-delivery.mjs +434 -0
- package/src/native/shell-analysis.mjs +50 -0
- package/src/native/skill-disclosure.mjs +404 -0
- package/src/native/skill-events.mjs +164 -0
- package/src/native/skill-tools.mjs +813 -0
- package/src/native/source-page.mjs +73 -0
- package/src/native/sqlite-session-inbox.mjs +42 -0
- package/src/native/sqlite-store.mjs +617 -0
- package/src/native/tool-authority.mjs +99 -0
- package/src/native/tool-guidance.mjs +12 -0
- package/src/native/tool-observation.mjs +75 -0
- package/src/native/tool-states.mjs +14 -0
- package/src/native/verification-authority.mjs +37 -0
- package/src/native/web-tools.mjs +643 -0
- package/src/native/workspace-checkpoints.mjs +312 -0
- package/src/native/workspace-context.mjs +61 -0
- package/src/native/workspace-display.mjs +39 -0
- package/src/native/workspace-edit-match.mjs +19 -0
- package/src/native/workspace-edit-worker.mjs +15 -0
- package/src/native/workspace-editing.mjs +447 -0
- package/src/native/workspace-image.mjs +72 -0
- package/src/native/workspace-instructions.mjs +107 -0
- package/src/native/workspace-mutation-lock.mjs +74 -0
- package/src/native/workspace-patch-worker.mjs +17 -0
- package/src/native/workspace-patch.mjs +17 -0
- package/src/native/workspace-paths.mjs +225 -0
- package/src/native/workspace-state.mjs +272 -0
- package/src/native/workspace-subject-observer.mjs +84 -0
- package/src/native/workspace-tools.mjs +1740 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Host-owned durable background commands for the E2B sandbox backend (Node port).
|
|
2
|
+
//
|
|
3
|
+
// Reference: ksadk/sandbox/e2b_background.py @355059d. Mirrors the Node local-background
|
|
4
|
+
// contract (owner/scope/session/workspace isolation, UTF-8 byte cursors, terminal
|
|
5
|
+
// states, unknown+evidenceIncomplete) for a REMOTE sandbox whose command outlives
|
|
6
|
+
// any single client. A detached supervisor (a fork child, not a Python subprocess)
|
|
7
|
+
// holds a cross-process lock and appends output to host files; a new host process
|
|
8
|
+
// reads the same files and checks the lock for liveness.
|
|
9
|
+
//
|
|
10
|
+
// Production wires the installed E2B SDK via a strictly injected transport interface.
|
|
11
|
+
// The reference's unverified remote-replay promises
|
|
12
|
+
// are NOT claimed here: a reconnect gap is conservatively unknown+evidenceIncomplete.
|
|
13
|
+
//
|
|
14
|
+
// Authority is synchronous === true; cleanup awaits a resolved === true.
|
|
15
|
+
// No new launch/stop/model/ask on read. The started backend decides the backend.
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { existsSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { copy } from '@kingsoftcloud/agentkit-harness-core';
|
|
22
|
+
import { LOCAL_BACKGROUND_CONTRACT, TERMINAL, LocalCommandError, check, createPrivate,
|
|
23
|
+
digest, lockUntil, privateDirectory, readAll, readWindow, syncDirectory,
|
|
24
|
+
tryLock, unknownRecord, validId, writeRecord, delay, readE2BRecord, atomicPrivate } from './e2b-command-state.mjs';
|
|
25
|
+
|
|
26
|
+
export const E2B_BACKGROUND_CONTRACT = 'agentkit.e2b-background-command/v1';
|
|
27
|
+
export const RECOVERY_CLAIM_TTL_MS = 30000;
|
|
28
|
+
// Public deterministic locators let the Host persist a task before SDK entry.
|
|
29
|
+
export const e2bCommandOwner = (workspaceRoot, sessionId) => digest([realpathSync(workspaceRoot), 'e2b', sessionId]);
|
|
30
|
+
export function e2bCommandId(owner, operationKey) {
|
|
31
|
+
check(typeof operationKey === 'string' && !!operationKey.trim(), 'invalid_background_operation_key');
|
|
32
|
+
return digest([owner, operationKey]);
|
|
33
|
+
}
|
|
34
|
+
const supervisor = new URL('./e2b-background-supervisor.mjs', import.meta.url).href;
|
|
35
|
+
const validSandboxId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
36
|
+
// A strict confirmed-true: the value must be exactly true, not a truthy Promise.
|
|
37
|
+
const isConfirmed = value => {
|
|
38
|
+
if (value && typeof value.then === 'function') Promise.resolve(value).catch(() => {});
|
|
39
|
+
return value === true;
|
|
40
|
+
};
|
|
41
|
+
const freeze = value => {
|
|
42
|
+
if (value && typeof value === 'object') { Object.freeze(value); for (const child of Object.values(value)) freeze(child); }
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const commandRequest = ({ command, cwd = null, env = null, timeoutSeconds }) => {
|
|
47
|
+
check(typeof command === 'string' && !!command.trim(), 'invalid_background_command');
|
|
48
|
+
check(cwd === null || typeof cwd === 'string', 'invalid_background_command');
|
|
49
|
+
check(env === null || (typeof env === 'object' && !Array.isArray(env)
|
|
50
|
+
&& Object.entries(env).every(([k, v]) => typeof k === 'string' && typeof v === 'string')), 'invalid_background_command');
|
|
51
|
+
check(Number.isSafeInteger(timeoutSeconds) && timeoutSeconds >= 0, 'invalid_background_command');
|
|
52
|
+
return { command, cwd, env, timeoutSeconds };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** One host-owned handle to a remote E2B background command. commandId is the only
|
|
56
|
+
* identity exposed to the model/native layer; the remote pid stays internal. Output
|
|
57
|
+
* is read from host files via UTF-8 cursors. Liveness is cross-process: the detached
|
|
58
|
+
* supervisor holds supervisor.sqlite for its life. */
|
|
59
|
+
export class E2BBackgroundCommand {
|
|
60
|
+
constructor(store, directory, { commandId }) {
|
|
61
|
+
check(store instanceof E2BCommandStore, 'invalid_e2b_background_store');
|
|
62
|
+
this._store = store; this.directory = directory; this.commandId = commandId;
|
|
63
|
+
readE2BRecord(directory, store.owner, commandId); // Refuse a handle from another session/root.
|
|
64
|
+
Object.freeze(this);
|
|
65
|
+
}
|
|
66
|
+
_record() { return readE2BRecord(this.directory, this._store.owner, this.commandId); }
|
|
67
|
+
get timeoutSeconds() { return Math.floor(this._record().timeoutMs / 1000); }
|
|
68
|
+
get processId() {
|
|
69
|
+
const pid = this._record().supervisorPid;
|
|
70
|
+
check(Number.isSafeInteger(pid) && pid > 0, 'background_supervisor_identity_unavailable');
|
|
71
|
+
return pid; // Diagnostic locator only; never used to signal.
|
|
72
|
+
}
|
|
73
|
+
readOutput({ stdoutCursor = 0, stderrCursor = 0, maxBytes = 65536 } = {}) {
|
|
74
|
+
check(Number.isSafeInteger(maxBytes) && maxBytes >= 4 && maxBytes <= 65536, 'invalid_background_output_window');
|
|
75
|
+
// tryLock returns a release fn iff the lock was FREE (acquired) = no live
|
|
76
|
+
// supervisor; null iff a live supervisor holds it. Probe and release immediately.
|
|
77
|
+
const probe = tryLock(join(this.directory, 'supervisor.sqlite'));
|
|
78
|
+
let record;
|
|
79
|
+
try {
|
|
80
|
+
record = this._record();
|
|
81
|
+
if (probe && !TERMINAL.has(record.status)) {
|
|
82
|
+
if (record.status !== 'starting' || record.supervisorPid !== undefined || Date.now() - record.createdAt > 5000)
|
|
83
|
+
record = { ...record, status: 'unknown', exitCode: null, evidenceIncomplete: true, outcomeReason: 'supervisor_lost' };
|
|
84
|
+
}
|
|
85
|
+
} finally { if (probe) probe(); }
|
|
86
|
+
const stdout = readWindow(join(this.directory, 'stdout'), stdoutCursor, maxBytes);
|
|
87
|
+
const stderr = readWindow(join(this.directory, 'stderr'), stderrCursor, maxBytes);
|
|
88
|
+
return { status: record.status, stdout: stdout.text, stderr: stderr.text,
|
|
89
|
+
stdoutCursor: stdout.cursor, stderrCursor: stderr.cursor,
|
|
90
|
+
exitCode: TERMINAL.has(record.status) ? record.exitCode : null,
|
|
91
|
+
cancelRequested: existsSync(join(this.directory, 'cancel')),
|
|
92
|
+
outputMayBeTruncated: stdout.hasMore || stderr.hasMore,
|
|
93
|
+
evidenceIncomplete: record.evidenceIncomplete === true, observedExitCode: record.observedExitCode ?? null,
|
|
94
|
+
outcomeReason: record.outcomeReason ?? '' };
|
|
95
|
+
}
|
|
96
|
+
requestCancel() {
|
|
97
|
+
const output = this.readOutput();
|
|
98
|
+
if (!TERMINAL.has(output.status)) createPrivate(join(this.directory, 'cancel'));
|
|
99
|
+
return this.readOutput();
|
|
100
|
+
}
|
|
101
|
+
async wait({ timeoutMs } = {}) {
|
|
102
|
+
const started = performance.now();
|
|
103
|
+
let output;
|
|
104
|
+
while (!TERMINAL.has((output = this.readOutput()).status)) {
|
|
105
|
+
if (Number.isSafeInteger(timeoutMs) && performance.now() - started >= timeoutMs)
|
|
106
|
+
throw new LocalCommandError('background_command_still_running');
|
|
107
|
+
await delay(50);
|
|
108
|
+
}
|
|
109
|
+
check(output.status !== 'unknown', 'background_command_outcome_unknown');
|
|
110
|
+
return { stdout: readAll(join(this.directory, 'stdout')), stderr: readAll(join(this.directory, 'stderr')),
|
|
111
|
+
exitCode: output.exitCode };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Host-owned command records for one session, backed by detached supervisors that
|
|
116
|
+
* drive an injected remote transport. The frozen sandbox.json (sandboxId + owner)
|
|
117
|
+
* is the authority for read/stop/recover; a foreign-owned sandbox is refused.
|
|
118
|
+
*
|
|
119
|
+
* Authority contract: `authority` is a STRICT synchronous predicate returning
|
|
120
|
+
* exactly `true` (not a truthy Promise) to confirm authority over an existing
|
|
121
|
+
* sandbox. `killSandbox` may be asynchronous; cleanup requires its resolved
|
|
122
|
+
* value to equal `true`. `authority === null` means NO authority is proven:
|
|
123
|
+
* recover/close refuse with zero side effects.
|
|
124
|
+
*
|
|
125
|
+
* `launchAuthorized` (default true) is the outer host's declaration that this store
|
|
126
|
+
* may launch a first command (the first launch has no existing sandbox to prove
|
|
127
|
+
* authority over; the outer host authorizes it). Without it, start() refuses. */
|
|
128
|
+
export class E2BCommandStore {
|
|
129
|
+
constructor({ stateRoot, workspaceRoot, sessionId, transport = null, transportModule = null,
|
|
130
|
+
transportConfig = null, authority = null, killSandbox = null, launchAuthorized = true,
|
|
131
|
+
sandboxId = null }) {
|
|
132
|
+
check(process.platform === 'darwin' || process.platform === 'linux', 'unsupported_e2b_background_platform');
|
|
133
|
+
check(typeof stateRoot === 'string' && !!stateRoot && typeof workspaceRoot === 'string' && !!workspaceRoot
|
|
134
|
+
&& typeof sessionId === 'string' && !!sessionId.trim(), 'invalid_background_command_scope');
|
|
135
|
+
check((transport && typeof transport.start === 'function' && typeof transport.reconnect === 'function'
|
|
136
|
+
&& typeof transport.kill === 'function') || typeof transportModule === 'string', 'invalid_e2b_background_transport');
|
|
137
|
+
check(authority === null || typeof authority === 'function', 'invalid_e2b_background_authority');
|
|
138
|
+
check(killSandbox === null || typeof killSandbox === 'function', 'invalid_e2b_background_kill_sandbox');
|
|
139
|
+
check(sandboxId === null || validSandboxId(sandboxId), 'invalid_e2b_background_sandbox_id');
|
|
140
|
+
check(typeof launchAuthorized === 'boolean', 'invalid_e2b_launch_authority');
|
|
141
|
+
// Resolve stateRoot through the filesystem. The workspace-containment check
|
|
142
|
+
// runs BEFORE any directory creation so a stateRoot inside the workspace is
|
|
143
|
+
// rejected with zero side effects. resolve() does not follow symlinks;
|
|
144
|
+
// privateDirectory validates ancestors (no symlink) and creates the root.
|
|
145
|
+
const resolved = resolve(stateRoot === '~' ? homedir() : stateRoot.startsWith('~/') ? join(homedir(), stateRoot.slice(2)) : stateRoot);
|
|
146
|
+
const workspace = realpathSync(workspaceRoot);
|
|
147
|
+
// Pre-creation check on the resolved path: reject root === workspace or root
|
|
148
|
+
// inside workspace (a stateRoot inside the workspace is never created).
|
|
149
|
+
check(resolved !== workspace && !resolved.startsWith(workspace + '/'), 'background_state_inside_workspace');
|
|
150
|
+
privateDirectory(dirname(resolved), false);
|
|
151
|
+
privateDirectory(resolved, true); // create the root (0o700, no symlink ancestors)
|
|
152
|
+
const root = realpathSync(resolved);
|
|
153
|
+
// Post-creation realpath check: a symlink in the resolved path that landed
|
|
154
|
+
// inside the workspace is still rejected.
|
|
155
|
+
check(root !== workspace && !root.startsWith(workspace + '/'), 'background_state_inside_workspace');
|
|
156
|
+
this.owner = e2bCommandOwner(workspace, sessionId);
|
|
157
|
+
this.directory = join(root, 'commands', this.owner);
|
|
158
|
+
this._sandboxRoot = join(root, 'sandboxes', this.owner);
|
|
159
|
+
this.sessionId = sessionId;
|
|
160
|
+
this._transport = transport; this._transportModule = transportModule; this._transportConfig = freeze(copy(transportConfig));
|
|
161
|
+
this._authority = authority; this._killSandbox = killSandbox;
|
|
162
|
+
this._launchAuthorized = !!launchAuthorized;
|
|
163
|
+
this._boundSandboxId = sandboxId; // proven by owner.ensure before store creation
|
|
164
|
+
privateDirectory(this.directory, true); privateDirectory(this._sandboxRoot, true);
|
|
165
|
+
if (sandboxId !== null) {
|
|
166
|
+
const release = tryLock(join(this._sandboxRoot, 'binding.sqlite'));
|
|
167
|
+
check(release, 'e2b_background_binding_busy');
|
|
168
|
+
try {
|
|
169
|
+
if (this._hasSandboxState()) {
|
|
170
|
+
const frozen = this._frozenSandbox();
|
|
171
|
+
check(frozen?.owner === this.owner && frozen.sandboxId === sandboxId, 'e2b_background_binding_changed');
|
|
172
|
+
} else {
|
|
173
|
+
atomicPrivate(this._sandboxFile(), JSON.stringify({ sandboxId, owner: this.owner, createdAt: Date.now() }) + '\n');
|
|
174
|
+
}
|
|
175
|
+
} finally { release(); }
|
|
176
|
+
}
|
|
177
|
+
Object.freeze(this);
|
|
178
|
+
}
|
|
179
|
+
commandId(operationKey) {
|
|
180
|
+
return e2bCommandId(this.owner, operationKey);
|
|
181
|
+
}
|
|
182
|
+
connect(commandId) {
|
|
183
|
+
check(validId(commandId), 'invalid_background_command_identity');
|
|
184
|
+
const directory = join(this.directory, commandId);
|
|
185
|
+
check(existsSync(join(directory, 'record.json')), 'background_command_not_owned');
|
|
186
|
+
return new E2BBackgroundCommand(this, directory, { commandId });
|
|
187
|
+
}
|
|
188
|
+
_sandboxFile() { return join(this._sandboxRoot, 'sandbox.json'); }
|
|
189
|
+
_hasSandboxState() { return existsSync(this._sandboxFile()); }
|
|
190
|
+
_frozenSandbox() {
|
|
191
|
+
if (!this._hasSandboxState()) return null;
|
|
192
|
+
try { const record = JSON.parse(readAll(this._sandboxFile())); return record && typeof record === 'object' ? record : null; }
|
|
193
|
+
catch { return null; }
|
|
194
|
+
}
|
|
195
|
+
_authorityProven() {
|
|
196
|
+
// The frozen sandbox.json owner must match this session's owner key, AND a
|
|
197
|
+
// host-supplied authority predicate must return EXACTLY true (strict, not a
|
|
198
|
+
// truthy Promise). authority === null → not proven → refuse. This is the gate
|
|
199
|
+
// for operations that TOUCH an existing sandbox (recover/close).
|
|
200
|
+
const frozen = this._frozenSandbox();
|
|
201
|
+
if (frozen === null) return false;
|
|
202
|
+
if (frozen.owner !== this.owner || !validSandboxId(frozen.sandboxId)
|
|
203
|
+
|| this._boundSandboxId !== null && frozen.sandboxId !== this._boundSandboxId) return false;
|
|
204
|
+
const sealed = digest(frozen);
|
|
205
|
+
try {
|
|
206
|
+
return this._authority !== null && isConfirmed(this._authority(frozen))
|
|
207
|
+
&& digest(frozen) === sealed && digest(this._frozenSandbox()) === sealed;
|
|
208
|
+
} catch { return false; }
|
|
209
|
+
}
|
|
210
|
+
_assertAuthority() {
|
|
211
|
+
// A genuine first launch (no sandbox.json) is allowed only if the outer host
|
|
212
|
+
// declared launchAuthorized (the first launch has no existing sandbox to prove
|
|
213
|
+
// authority over). An existing sandbox requires proven authority.
|
|
214
|
+
if (!this._hasSandboxState()) {
|
|
215
|
+
check(this._launchAuthorized, 'e2b_background_launch_not_authorized');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
check(this._authorityProven(), 'e2b_background_authority_unprovable');
|
|
219
|
+
}
|
|
220
|
+
recordSandboxIdForTask(taskId) {
|
|
221
|
+
if (!validId(taskId)) return null;
|
|
222
|
+
const directory = join(this.directory, taskId);
|
|
223
|
+
if (!existsSync(join(directory, 'record.json'))) return null;
|
|
224
|
+
try { const rec = readE2BRecord(directory, this.owner, taskId); return rec.sandboxId && validSandboxId(rec.sandboxId) ? rec.sandboxId : null; }
|
|
225
|
+
catch { return null; }
|
|
226
|
+
}
|
|
227
|
+
connectOrRecover(commandId) {
|
|
228
|
+
const handle = this.connect(commandId);
|
|
229
|
+
const record = handle._record();
|
|
230
|
+
if (TERMINAL.has(record.status)) return handle; // terminal → no authority needed
|
|
231
|
+
if (!record.pid || !record.sandboxId) return handle;
|
|
232
|
+
this._assertAuthority(); // recovery touches an existing sandbox
|
|
233
|
+
// tryLock: release fn = free (supervisor dead) → recover; null = held (alive).
|
|
234
|
+
const probe = tryLock(join(handle.directory, 'supervisor.sqlite'));
|
|
235
|
+
if (probe) { probe(); return this.recover(commandId); }
|
|
236
|
+
return handle; // supervisor alive
|
|
237
|
+
}
|
|
238
|
+
async start({ operationKey = randomUUID(), assertCurrent = () => true, ...input }) {
|
|
239
|
+
check(typeof assertCurrent === 'function', 'e2b_background_guard_invalid');
|
|
240
|
+
const request = commandRequest(input), commandId = this.commandId(operationKey), requestDigest = digest(request);
|
|
241
|
+
const release = await lockUntil(join(this.directory, 'launch.sqlite'), performance.now() + 5000);
|
|
242
|
+
try {
|
|
243
|
+
check(isConfirmed(assertCurrent()), 'e2b_background_authority_lost');
|
|
244
|
+
this._assertAuthority();
|
|
245
|
+
const directory = join(this.directory, commandId);
|
|
246
|
+
if (existsSync(directory)) {
|
|
247
|
+
const handle = this.connect(commandId);
|
|
248
|
+
check(readE2BRecord(directory, this.owner, commandId).requestDigest === requestDigest, 'background_operation_arguments_changed');
|
|
249
|
+
return handle; // An existing/unknown operation is never launched again.
|
|
250
|
+
}
|
|
251
|
+
const record = { schemaVersion: LOCAL_BACKGROUND_CONTRACT, owner: this.owner, commandId, requestDigest,
|
|
252
|
+
status: 'starting', createdAt: Date.now(), timeoutMs: request.timeoutSeconds * 1000, exitCode: null,
|
|
253
|
+
backend: 'e2b', generation: 0 };
|
|
254
|
+
const preparing = join(this.directory, `.preparing-${randomUUID()}`);
|
|
255
|
+
try {
|
|
256
|
+
privateDirectory(preparing, true);
|
|
257
|
+
for (const file of ['stdout', 'stderr', 'supervisor.sqlite']) createPrivate(join(preparing, file));
|
|
258
|
+
writeRecord(preparing, record);
|
|
259
|
+
renameSync(preparing, directory); syncDirectory(dirname(directory));
|
|
260
|
+
} finally { if (existsSync(preparing)) rmSync(preparing, { recursive: true, force: true }); }
|
|
261
|
+
const handle = this.connect(commandId);
|
|
262
|
+
check(isConfirmed(assertCurrent()), 'e2b_background_authority_lost');
|
|
263
|
+
this._spawnSupervisor(handle, { mode: 'start', generation: 0, request });
|
|
264
|
+
return handle;
|
|
265
|
+
} finally { release(); }
|
|
266
|
+
}
|
|
267
|
+
async recover(commandId) {
|
|
268
|
+
const handle = this.connect(commandId);
|
|
269
|
+
let record = handle._record();
|
|
270
|
+
if (TERMINAL.has(record.status)) return handle;
|
|
271
|
+
if (!record.pid || !record.sandboxId) return handle; // Cannot reconnect without a remote identity.
|
|
272
|
+
check(this._authorityProven(), 'e2b_background_authority_unprovable');
|
|
273
|
+
const release = await lockUntil(join(handle.directory, 'recover.sqlite'), performance.now() + 5000);
|
|
274
|
+
try {
|
|
275
|
+
record = handle._record();
|
|
276
|
+
if (TERMINAL.has(record.status)) return handle;
|
|
277
|
+
const probe = tryLock(join(handle.directory, 'supervisor.sqlite'));
|
|
278
|
+
if (probe) probe();
|
|
279
|
+
else return handle; // live supervisor owns it; nothing to recover.
|
|
280
|
+
const claimedGen = record.recoveryClaimGeneration;
|
|
281
|
+
if (Number.isSafeInteger(claimedGen)) {
|
|
282
|
+
const claimAge = Date.now() - (record.recoveryClaimAt ?? 0);
|
|
283
|
+
if (claimAge < RECOVERY_CLAIM_TTL_MS) return handle; // durable claim prevents a second spawn
|
|
284
|
+
record.staleClaimFloor = claimedGen;
|
|
285
|
+
delete record.recoveryClaimGeneration; delete record.recoveryClaimAt;
|
|
286
|
+
}
|
|
287
|
+
const priorGen = record.generation ?? 0, priorClaim = record.recoveryClaimGeneration ?? 0, staleFloor = record.staleClaimFloor ?? 0;
|
|
288
|
+
const generation = Math.max(priorGen, priorClaim, staleFloor) + 1;
|
|
289
|
+
record.recoveryClaimGeneration = generation; record.recoveryClaimAt = Date.now();
|
|
290
|
+
delete record.staleClaimFloor;
|
|
291
|
+
writeRecord(handle.directory, record);
|
|
292
|
+
this._spawnSupervisor(handle, { mode: 'reconnect', generation, request: null });
|
|
293
|
+
return handle;
|
|
294
|
+
} finally { release(); }
|
|
295
|
+
}
|
|
296
|
+
_spawnSupervisor(handle, { mode, generation, request }) {
|
|
297
|
+
// The frozen sandboxId: prefer the proven bound sandboxId from owner.ensure
|
|
298
|
+
// (set at store construction); fall back to sandbox.json if it exists. This
|
|
299
|
+
// ensures the first launch (no sandbox.json yet) still passes a trusted
|
|
300
|
+
// sandboxId to transport.start — the real SDK forbids a null sandboxId.
|
|
301
|
+
const frozen = this._frozenSandbox();
|
|
302
|
+
const fromJson = frozen && frozen.owner === this.owner && validSandboxId(frozen.sandboxId) ? frozen.sandboxId : null;
|
|
303
|
+
const frozenSandboxId = this._boundSandboxId ?? fromJson;
|
|
304
|
+
const config = { mode, directory: handle.directory, sandboxRoot: this._sandboxRoot, owner: this.owner,
|
|
305
|
+
commandId: handle.commandId, generation, request, frozenSandboxId,
|
|
306
|
+
transportModule: this._transportModule, transportConfig: this._transportConfig };
|
|
307
|
+
// In-process transport (tests): run the supervisor directly with the injected
|
|
308
|
+
// transport, so a closure-based fake transport works. Production uses the
|
|
309
|
+
// detached child with a transportModule the child imports.
|
|
310
|
+
if (this._transport) {
|
|
311
|
+
config.transport = this._transport;
|
|
312
|
+
import('./e2b-background-supervisor.mjs').then(({ supervise }) =>
|
|
313
|
+
supervise(config, this._transport).catch(() => {
|
|
314
|
+
// Generation-fenced fallback: only settle unknown if this supervisor is
|
|
315
|
+
// still current. A newer reclaim or terminal must not be clobbered.
|
|
316
|
+
try {
|
|
317
|
+
const dir = handle.directory, owner = this.owner, cid = handle.commandId, gen = config.generation ?? 0;
|
|
318
|
+
const rec = readE2BRecord(dir, owner, cid);
|
|
319
|
+
if (!['completed', 'failed', 'canceled', 'timed_out', 'output_limit', 'unknown'].includes(rec.status)
|
|
320
|
+
&& (rec.generation ?? 0) <= gen
|
|
321
|
+
&& !(Number.isSafeInteger(rec.recoveryClaimGeneration) && rec.recoveryClaimGeneration > gen))
|
|
322
|
+
unknownRecord(dir, rec, 'supervisor_in_process_failed');
|
|
323
|
+
} catch {}
|
|
324
|
+
}));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
let child;
|
|
328
|
+
try {
|
|
329
|
+
child = spawn(process.execPath, ['--input-type=module', '-e',
|
|
330
|
+
`import(${JSON.stringify(supervisor)}).then(m => m.runSupervisor()).catch(() => process.exit(1))`],
|
|
331
|
+
{ detached: true, stdio: ['pipe', 'ignore', 'ignore'], env: { ...process.env, NODE_NO_WARNINGS: '1' } });
|
|
332
|
+
} catch { unknownRecord(handle.directory, handle._record(), 'supervisor_launch_failed'); return; }
|
|
333
|
+
child.once('error', () => { const probe = tryLock(join(handle.directory, 'supervisor.sqlite'));
|
|
334
|
+
try { if (probe) { probe(); const current = handle._record(); if (current.status === 'starting') unknownRecord(handle.directory, current, 'supervisor_launch_failed'); } }
|
|
335
|
+
catch {} finally { /* probe already released if acquired */ } });
|
|
336
|
+
child.stdin.on('error', () => {});
|
|
337
|
+
child.stdin.end(JSON.stringify(config)); child.unref();
|
|
338
|
+
}
|
|
339
|
+
async close() {
|
|
340
|
+
// Kill the persistent sandbox. ASYNC contract: killSandbox may be sync or
|
|
341
|
+
// async (returning a Promise<boolean>); close() awaits it and checks the
|
|
342
|
+
// RESOLVED value === true. A Promise itself (truthy) is never accepted — it
|
|
343
|
+
// must resolve to exactly true. authority remains a strict synchronous ===true
|
|
344
|
+
// predicate. Zero side effects without proven authority.
|
|
345
|
+
if (this._killSandbox === null) return false;
|
|
346
|
+
const frozen = this._frozenSandbox();
|
|
347
|
+
if (!frozen || frozen.owner !== this.owner || !validSandboxId(frozen.sandboxId)) return false;
|
|
348
|
+
const sealed = digest(frozen);
|
|
349
|
+
if (!this._authorityProven() || digest(this._frozenSandbox()) !== sealed) return false;
|
|
350
|
+
try { return await Promise.resolve(this._killSandbox(frozen.sandboxId)) === true; }
|
|
351
|
+
catch { return false; }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Reader for createBackgroundCommandPort's canonical, owned launch receipts.
|
|
356
|
+
* No launch/cancel/record transition; never selects a backend from the environment. */
|
|
357
|
+
export function createE2BBackgroundReader(store) {
|
|
358
|
+
check(store instanceof E2BCommandStore, 'invalid_e2b_background_store');
|
|
359
|
+
return Object.freeze(async request => {
|
|
360
|
+
request.signal.throwIfAborted();
|
|
361
|
+
check(request.sessionId === store.sessionId, 'background_reader_owner_mismatch');
|
|
362
|
+
check(request.ref?.taskId === store.commandId(request.ref?.operationKey), 'background_reader_operation_mismatch');
|
|
363
|
+
check(request.waitSeconds === 0, 'background_observation_must_not_wait');
|
|
364
|
+
const result = store.connect(request.ref.taskId).readOutput({ stdoutCursor: request.stdoutCursor,
|
|
365
|
+
stderrCursor: request.stderrCursor, maxBytes: request.maxBytes });
|
|
366
|
+
request.signal.throwIfAborted(); return result;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// E2B background command state helpers. Reuses the verified local-command-state
|
|
2
|
+
// private-file/lock/cursor helpers but validates E2B terminal semantics
|
|
3
|
+
// (cancelled/timed_out with a null exit code from a remote kill), which the strict
|
|
4
|
+
// local_process readRecord exit-code conventions (124/122) do not allow.
|
|
5
|
+
import { constants, closeSync, fstatSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { LOCAL_BACKGROUND_CONTRACT, TERMINAL, LocalCommandError, check, openPrivate,
|
|
8
|
+
privateDirectory, validId } from './local-command-state.mjs';
|
|
9
|
+
|
|
10
|
+
export { LOCAL_BACKGROUND_CONTRACT, TERMINAL, LocalCommandError, check, createPrivate,
|
|
11
|
+
digest, lockUntil, privateDirectory, readAll, readWindow, syncDirectory, tryLock,
|
|
12
|
+
unknownRecord, validId, writeRecord, delay, openPrivate, atomicPrivate } from './local-command-state.mjs';
|
|
13
|
+
|
|
14
|
+
export function readE2BRecord(directory, owner, commandId) {
|
|
15
|
+
privateDirectory(directory);
|
|
16
|
+
const fd = openPrivate(join(directory, 'record.json'), constants.O_RDONLY);
|
|
17
|
+
let record;
|
|
18
|
+
try {
|
|
19
|
+
check(fstatSync(fd).size <= 65536, 'invalid_background_record');
|
|
20
|
+
const text = readFileSync(fd, 'utf8');
|
|
21
|
+
try { record = JSON.parse(text); } catch { throw new LocalCommandError('invalid_background_record'); }
|
|
22
|
+
} finally { closeSync(fd); }
|
|
23
|
+
check(record && record.schemaVersion === LOCAL_BACKGROUND_CONTRACT && record.owner === owner
|
|
24
|
+
&& record.commandId === commandId && validId(owner) && validId(commandId) && validId(record.requestDigest)
|
|
25
|
+
&& record.backend === 'e2b' && Number.isSafeInteger(record.generation) && record.generation >= 0
|
|
26
|
+
&& ['starting', 'running', ...TERMINAL].includes(record.status)
|
|
27
|
+
&& Number.isSafeInteger(record.createdAt) && record.createdAt > 0
|
|
28
|
+
&& Number.isSafeInteger(record.timeoutMs) && record.timeoutMs >= 0
|
|
29
|
+
&& (record.exitCode === null || Number.isSafeInteger(record.exitCode))
|
|
30
|
+
&& (!['completed', 'failed'].includes(record.status) || Number.isSafeInteger(record.exitCode))
|
|
31
|
+
&& (record.pid === undefined || Number.isSafeInteger(record.pid) && record.pid > 0)
|
|
32
|
+
&& (record.sandboxId === undefined || typeof record.sandboxId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(record.sandboxId))
|
|
33
|
+
&& (record.recoveryClaimGeneration === undefined || Number.isSafeInteger(record.recoveryClaimGeneration)
|
|
34
|
+
&& record.recoveryClaimGeneration > record.generation && Number.isSafeInteger(record.recoveryClaimAt) && record.recoveryClaimAt > 0)
|
|
35
|
+
&& (record.supervisorPid === undefined || Number.isSafeInteger(record.supervisorPid) && record.supervisorPid > 0)
|
|
36
|
+
&& (record.evidenceIncomplete === undefined || typeof record.evidenceIncomplete === 'boolean')
|
|
37
|
+
&& (record.observedExitCode === undefined || record.observedExitCode === null || Number.isSafeInteger(record.observedExitCode)),
|
|
38
|
+
'invalid_background_record');
|
|
39
|
+
return record;
|
|
40
|
+
}
|