@tanstack/ai-sandbox 0.3.0 → 0.3.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/README.md +15 -5
- package/dist/esm/agents-file.d.ts +15 -0
- package/dist/esm/agents-file.js +47 -1
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/bootstrap.js +2 -1
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/contracts.d.ts +12 -8
- package/dist/esm/git-exec.js +2 -0
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +3 -3
- package/dist/esm/middleware.js +4 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/sandbox.d.ts +2 -0
- package/dist/esm/sandbox.js +18 -1
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/tool-bridge.js +1 -1
- package/dist/esm/tool-bridge.js.map +1 -1
- package/package.json +13 -3
- package/skills/ai-sandbox/SKILL.md +13 -8
- package/src/agents-file.ts +65 -0
- package/src/bootstrap.ts +5 -1
- package/src/contracts.ts +12 -8
- package/src/git-exec.ts +7 -0
- package/src/index.ts +2 -0
- package/src/middleware.ts +4 -1
- package/src/sandbox.ts +23 -0
- package/src/tool-bridge.ts +3 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Pick a **provider** package for where the sandbox runs:
|
|
|
51
51
|
| `@tanstack/ai-sandbox-docker` | Isolated containers, snapshots, resume |
|
|
52
52
|
| `@tanstack/ai-sandbox-cloudflare` | Cloudflare Workers + Containers |
|
|
53
53
|
| `@tanstack/ai-sandbox-vercel` | Vercel Sandbox |
|
|
54
|
-
| `@tanstack/ai-sandbox-daytona` | Daytona
|
|
54
|
+
| `@tanstack/ai-sandbox-daytona` | Daytona cloud sandboxes, snapshots |
|
|
55
55
|
| `@tanstack/ai-sandbox-sprites` | Sprites stateful sandboxes |
|
|
56
56
|
|
|
57
57
|
**Harness adapters** are separate packages. The default path is **Grok Build** (`@tanstack/ai-grok-build`); others include `@tanstack/ai-claude-code`, `@tanstack/ai-codex`, and `@tanstack/ai-opencode`. All require `withSandbox(...)` middleware — `chat()` fails fast without it.
|
|
@@ -102,6 +102,18 @@ Guard what the agent may run:
|
|
|
102
102
|
|
|
103
103
|
```typescript
|
|
104
104
|
const policy = defineSandboxPolicy({
|
|
105
|
+
default: 'allow',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
defineSandbox({ id: 'agent', provider, workspace, policy })
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Headless Grok Build and Codex stay on auto-approve when `default` is `'allow'` and there is no `ask` list. Isolation is the outer sandbox (Docker, Daytona, and so on). Use Claude Code when you need command-level deny.
|
|
112
|
+
|
|
113
|
+
Claude Code can use an interactive policy:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
defineSandboxPolicy({
|
|
105
117
|
commands: {
|
|
106
118
|
allow: ['pnpm test', 'git diff'],
|
|
107
119
|
ask: ['pnpm install'],
|
|
@@ -110,11 +122,9 @@ const policy = defineSandboxPolicy({
|
|
|
110
122
|
capabilities: { fileWrite: 'allow', network: 'ask' },
|
|
111
123
|
default: 'ask',
|
|
112
124
|
})
|
|
113
|
-
|
|
114
|
-
defineSandbox({ id: 'agent', provider, workspace, policy })
|
|
115
125
|
```
|
|
116
126
|
|
|
117
|
-
Precedence is `deny` > `ask` > `allow`. Each harness adapter maps policy onto its native permission system (coarse flags for Grok Build/Codex; full interactive `approval-requested` on Claude Code).
|
|
127
|
+
Precedence is `deny` > `ask` > `allow`. Each harness adapter maps policy onto its native permission system (coarse flags for Grok Build/Codex; full interactive `approval-requested` on Claude Code). Provider-specific privilege and network rules live in the [providers](../../docs/sandbox/providers.md) guide.
|
|
118
128
|
|
|
119
129
|
### Lifecycle
|
|
120
130
|
|
|
@@ -129,7 +139,7 @@ lifecycle: {
|
|
|
129
139
|
|
|
130
140
|
### Secrets
|
|
131
141
|
|
|
132
|
-
Use `createSecrets()` so values stay behind opaque `SecretRef` tokens
|
|
142
|
+
Use `createSecrets()` so values stay behind opaque `SecretRef` tokens. They are never written to snapshots, the sandbox store, or event logs. The sandbox layer resolves them onto the live handle at create, resume, and snapshot restore:
|
|
133
143
|
|
|
134
144
|
```typescript
|
|
135
145
|
const secrets = createSecrets({ XAI_API_KEY: process.env.XAI_API_KEY ?? '' })
|
|
@@ -18,6 +18,21 @@ import { WorkspaceSkill } from './workspace.js';
|
|
|
18
18
|
export declare function resolveGitSkillDir(root: string, skill: Extract<WorkspaceSkill, {
|
|
19
19
|
kind: 'git';
|
|
20
20
|
}>): string;
|
|
21
|
+
/** A folder that contains `SKILL.md`, ready to project under a harness skills dir. */
|
|
22
|
+
export interface DiscoveredSkillDir {
|
|
23
|
+
name: string;
|
|
24
|
+
dir: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Find every skill folder under a cloned `gitSkill` repo.
|
|
28
|
+
*
|
|
29
|
+
* A skill folder is a directory that contains `SKILL.md`. Nested packs
|
|
30
|
+
* (`skills/foo/SKILL.md`) are returned as `{ name: 'foo', dir: '…/skills/foo' }`.
|
|
31
|
+
* A flat clone with `SKILL.md` at the root is returned as one entry named
|
|
32
|
+
* after the clone. If no `SKILL.md` is found, the clone itself is returned
|
|
33
|
+
* so existing basename projection still works.
|
|
34
|
+
*/
|
|
35
|
+
export declare function discoverSkillDirs(handle: SandboxHandle, cloneDir: string): Promise<Array<DiscoveredSkillDir>>;
|
|
21
36
|
/** Format workspace scripts as a `## Workspace scripts` markdown section. */
|
|
22
37
|
export declare function formatWorkspaceScriptsSection(scripts: Record<string, string>): string;
|
|
23
38
|
/**
|
package/dist/esm/agents-file.js
CHANGED
|
@@ -20,6 +20,52 @@ function resolveGitSkillDir(root, skill) {
|
|
|
20
20
|
const rawBasename = skill.repo.split("/").pop() ?? skill.repo;
|
|
21
21
|
return `${root}/.tanstack-skills/${rawBasename.endsWith(".git") ? rawBasename.slice(0, -4) : rawBasename}`;
|
|
22
22
|
}
|
|
23
|
+
var SKILL_FILE = "SKILL.md";
|
|
24
|
+
var SKIP_DIR_NAMES = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
25
|
+
var MAX_SKILL_WALK_DEPTH = 6;
|
|
26
|
+
function basenameOf(path) {
|
|
27
|
+
const segments = path.split("/").filter((segment) => segment !== "");
|
|
28
|
+
return segments[segments.length - 1] ?? path;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Find every skill folder under a cloned `gitSkill` repo.
|
|
32
|
+
*
|
|
33
|
+
* A skill folder is a directory that contains `SKILL.md`. Nested packs
|
|
34
|
+
* (`skills/foo/SKILL.md`) are returned as `{ name: 'foo', dir: '…/skills/foo' }`.
|
|
35
|
+
* A flat clone with `SKILL.md` at the root is returned as one entry named
|
|
36
|
+
* after the clone. If no `SKILL.md` is found, the clone itself is returned
|
|
37
|
+
* so existing basename projection still works.
|
|
38
|
+
*/
|
|
39
|
+
async function discoverSkillDirs(handle, cloneDir) {
|
|
40
|
+
const found = [];
|
|
41
|
+
await walkSkillDirs(handle, cloneDir, found, 0);
|
|
42
|
+
if (found.length === 0) return [{
|
|
43
|
+
name: basenameOf(cloneDir),
|
|
44
|
+
dir: cloneDir
|
|
45
|
+
}];
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
async function walkSkillDirs(handle, dir, found, depth) {
|
|
49
|
+
if (depth > MAX_SKILL_WALK_DEPTH) return;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = await handle.fs.list(dir);
|
|
53
|
+
} catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (entries.some((entry) => entry.type === "file" && entry.name.toLowerCase() === SKILL_FILE.toLowerCase())) {
|
|
57
|
+
found.push({
|
|
58
|
+
name: basenameOf(dir),
|
|
59
|
+
dir
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.type !== "dir") continue;
|
|
65
|
+
if (entry.name.startsWith(".") || SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
66
|
+
await walkSkillDirs(handle, entry.path, found, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
23
69
|
/** Format workspace scripts as a `## Workspace scripts` markdown section. */
|
|
24
70
|
function formatWorkspaceScriptsSection(scripts) {
|
|
25
71
|
const names = Object.keys(scripts).sort();
|
|
@@ -58,6 +104,6 @@ async function writeAgentsFile(handle, root, content) {
|
|
|
58
104
|
}
|
|
59
105
|
}
|
|
60
106
|
//#endregion
|
|
61
|
-
export { formatWorkspaceScriptsSection, mergeAgentsContent, resolveGitSkillDir, writeAgentsFile };
|
|
107
|
+
export { discoverSkillDirs, formatWorkspaceScriptsSection, mergeAgentsContent, resolveGitSkillDir, writeAgentsFile };
|
|
62
108
|
|
|
63
109
|
//# sourceMappingURL=agents-file.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agents-file.js","names":[],"sources":["../../src/agents-file.ts"],"sourcesContent":["/**\n * Universal AGENTS.md writer with per-CLI symlink projection, plus the\n * canonical helper for locating cloned gitSkill repositories inside a sandbox.\n *\n * The known-names set below lists the canonical instruction-file names for\n * each AI coding assistant CLI. Keep the list in one place so it is easy to\n * extend. The copy fallback ensures correctness on platforms without symlink\n * support (e.g. Windows).\n *\n * External per-CLI convention: each assistant looks for its own instruction\n * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …).\n * We write a single authoritative AGENTS.md and point each name at it.\n */\nimport type { SandboxHandle } from './contracts'\nimport type { WorkspaceSkill } from './workspace'\n\n/** CLI instruction-file names that should resolve to AGENTS.md. */\nconst SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md']\n\n/**\n * Resolve the directory a `gitSkill` repo is cloned into when no explicit\n * `into` override is provided. The convention is:\n *\n * `<root>/.tanstack-skills/<basename>`\n *\n * where `basename` is derived from the `repo` field by taking the last\n * path segment and stripping a trailing `.git` suffix.\n *\n * Per-harness projectors (e.g. the Claude Code adapter) import this helper\n * so they can locate cloned skill repos consistently.\n *\n * @param root - Workspace root inside the sandbox (e.g. `/workspace`).\n * @param skill - A `WorkspaceSkill` of `kind === 'git'`.\n */\nexport function resolveGitSkillDir(\n root: string,\n skill: Extract<WorkspaceSkill, { kind: 'git' }>,\n): string {\n const rawBasename = skill.repo.split('/').pop() ?? skill.repo\n const basename = rawBasename.endsWith('.git')\n ? rawBasename.slice(0, -4)\n : rawBasename\n return `${root}/.tanstack-skills/${basename}`\n}\n\n/** Format workspace scripts as a `## Workspace scripts` markdown section. */\nexport function formatWorkspaceScriptsSection(\n scripts: Record<string, string>,\n): string {\n const names = Object.keys(scripts).sort()\n if (names.length === 0) return ''\n const lines = names.map((name) => `- ${name} → ${scripts[name]}`)\n return `## Workspace scripts\\n\\n${lines.join('\\n')}`\n}\n\n/**\n * Merge base AGENTS.md content with an optional workspace scripts section.\n * Returns `undefined` when there is nothing to write.\n */\nexport function mergeAgentsContent(\n base: string | undefined,\n scripts: Record<string, string> | undefined,\n): string | undefined {\n const scriptsSection =\n scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : ''\n if (base === undefined && scriptsSection.length === 0) return undefined\n if (base === undefined) return scriptsSection\n if (scriptsSection.length === 0) return base\n return `${base.trimEnd()}\\n\\n${scriptsSection}`\n}\n\n/** Escape a string for safe use as a single-quoted shell argument. */\nfunction sqEscape(value: string): string {\n return value.replace(/'/g, `'\\\\''`)\n}\n\n/**\n * Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a\n * fallback when `ln -s` is unavailable).\n *\n * @param handle - The sandbox handle providing `fs` and `process`.\n * @param root - Absolute path inside the sandbox under which to write.\n * @param content - Markdown content for the instruction file.\n */\nexport async function writeAgentsFile(\n handle: SandboxHandle,\n root: string,\n content: string,\n): Promise<void> {\n const agentsPath = `${root}/AGENTS.md`\n await handle.fs.write(agentsPath, content)\n\n for (const name of SYMLINK_NAMES) {\n const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'`\n const result = await handle.process.exec(lnCmd, { cwd: root })\n if (result.exitCode !== 0) {\n // Symlinks are not supported on this platform — fall back to a copy.\n await handle.fs.write(`${root}/${name}`, content)\n }\n }\n}\n"],"mappings":";;AAiBA,IAAM,gBAAuC,CAAC,aAAa,WAAW;;;;;;;;;;;;;;;;AAiBtE,SAAgB,mBACd,MACA,OACQ;CACR,MAAM,cAAc,MAAM,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,MAAM;CAIzD,OAAO,GAAG,KAAK,oBAHE,YAAY,SAAS,MAAM,IACxC,YAAY,MAAM,GAAG,EAAE,IACvB;AAEN;;AAGA,SAAgB,8BACd,SACQ;CACR,MAAM,QAAQ,OAAO,KAAK,OAAO,CAAC,CAAC,KAAK;CACxC,IAAI,MAAM,WAAW,GAAG,OAAO;CAE/B,OAAO,2BADO,MAAM,KAAK,SAAS,KAAK,KAAK,KAAK,QAAQ,OACvB,CAAA,CAAM,KAAK,IAAI;AACnD;;;;;AAMA,SAAgB,mBACd,MACA,SACoB;CACpB,MAAM,iBACJ,YAAY,KAAA,IAAY,8BAA8B,OAAO,IAAI;CACnE,IAAI,SAAS,KAAA,KAAa,eAAe,WAAW,GAAG,OAAO,KAAA;CAC9D,IAAI,SAAS,KAAA,GAAW,OAAO;CAC/B,IAAI,eAAe,WAAW,GAAG,OAAO;CACxC,OAAO,GAAG,KAAK,QAAQ,EAAE,MAAM;AACjC;;AAGA,SAAS,SAAS,OAAuB;CACvC,OAAO,MAAM,QAAQ,MAAM,OAAO;AACpC;;;;;;;;;AAUA,eAAsB,gBACpB,QACA,MACA,SACe;CACf,MAAM,aAAa,GAAG,KAAK;CAC3B,MAAM,OAAO,GAAG,MAAM,YAAY,OAAO;CAEzC,KAAK,MAAM,QAAQ,eAAe;EAChC,MAAM,QAAQ,UAAU,SAAS,WAAW,EAAE,KAAK,SAAS,IAAI,EAAE;EAElE,KAAI,MADiB,OAAO,QAAQ,KAAK,OAAO,EAAE,KAAK,KAAK,CAAC,EAAA,CAClD,aAAa,GAEtB,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,OAAO;CAEpD;AACF"}
|
|
1
|
+
{"version":3,"file":"agents-file.js","names":[],"sources":["../../src/agents-file.ts"],"sourcesContent":["/**\n * Universal AGENTS.md writer with per-CLI symlink projection, plus the\n * canonical helper for locating cloned gitSkill repositories inside a sandbox.\n *\n * The known-names set below lists the canonical instruction-file names for\n * each AI coding assistant CLI. Keep the list in one place so it is easy to\n * extend. The copy fallback ensures correctness on platforms without symlink\n * support (e.g. Windows).\n *\n * External per-CLI convention: each assistant looks for its own instruction\n * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …).\n * We write a single authoritative AGENTS.md and point each name at it.\n */\nimport type { SandboxHandle } from './contracts'\nimport type { WorkspaceSkill } from './workspace'\n\n/** CLI instruction-file names that should resolve to AGENTS.md. */\nconst SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md']\n\n/**\n * Resolve the directory a `gitSkill` repo is cloned into when no explicit\n * `into` override is provided. The convention is:\n *\n * `<root>/.tanstack-skills/<basename>`\n *\n * where `basename` is derived from the `repo` field by taking the last\n * path segment and stripping a trailing `.git` suffix.\n *\n * Per-harness projectors (e.g. the Claude Code adapter) import this helper\n * so they can locate cloned skill repos consistently.\n *\n * @param root - Workspace root inside the sandbox (e.g. `/workspace`).\n * @param skill - A `WorkspaceSkill` of `kind === 'git'`.\n */\nexport function resolveGitSkillDir(\n root: string,\n skill: Extract<WorkspaceSkill, { kind: 'git' }>,\n): string {\n const rawBasename = skill.repo.split('/').pop() ?? skill.repo\n const basename = rawBasename.endsWith('.git')\n ? rawBasename.slice(0, -4)\n : rawBasename\n return `${root}/.tanstack-skills/${basename}`\n}\n\n/** A folder that contains `SKILL.md`, ready to project under a harness skills dir. */\nexport interface DiscoveredSkillDir {\n name: string\n dir: string\n}\n\nconst SKILL_FILE = 'SKILL.md'\nconst SKIP_DIR_NAMES = new Set(['.git', 'node_modules'])\nconst MAX_SKILL_WALK_DEPTH = 6\n\nfunction basenameOf(path: string): string {\n const segments = path.split('/').filter((segment) => segment !== '')\n return segments[segments.length - 1] ?? path\n}\n\n/**\n * Find every skill folder under a cloned `gitSkill` repo.\n *\n * A skill folder is a directory that contains `SKILL.md`. Nested packs\n * (`skills/foo/SKILL.md`) are returned as `{ name: 'foo', dir: '…/skills/foo' }`.\n * A flat clone with `SKILL.md` at the root is returned as one entry named\n * after the clone. If no `SKILL.md` is found, the clone itself is returned\n * so existing basename projection still works.\n */\nexport async function discoverSkillDirs(\n handle: SandboxHandle,\n cloneDir: string,\n): Promise<Array<DiscoveredSkillDir>> {\n const found: Array<DiscoveredSkillDir> = []\n await walkSkillDirs(handle, cloneDir, found, 0)\n if (found.length === 0) {\n return [{ name: basenameOf(cloneDir), dir: cloneDir }]\n }\n return found\n}\n\nasync function walkSkillDirs(\n handle: SandboxHandle,\n dir: string,\n found: Array<DiscoveredSkillDir>,\n depth: number,\n): Promise<void> {\n if (depth > MAX_SKILL_WALK_DEPTH) return\n let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>\n try {\n entries = await handle.fs.list(dir)\n } catch {\n return\n }\n const hasSkill = entries.some(\n (entry) =>\n entry.type === 'file' &&\n entry.name.toLowerCase() === SKILL_FILE.toLowerCase(),\n )\n if (hasSkill) {\n found.push({ name: basenameOf(dir), dir })\n return\n }\n for (const entry of entries) {\n if (entry.type !== 'dir') continue\n if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue\n await walkSkillDirs(handle, entry.path, found, depth + 1)\n }\n}\n\n/** Format workspace scripts as a `## Workspace scripts` markdown section. */\nexport function formatWorkspaceScriptsSection(\n scripts: Record<string, string>,\n): string {\n const names = Object.keys(scripts).sort()\n if (names.length === 0) return ''\n const lines = names.map((name) => `- ${name} → ${scripts[name]}`)\n return `## Workspace scripts\\n\\n${lines.join('\\n')}`\n}\n\n/**\n * Merge base AGENTS.md content with an optional workspace scripts section.\n * Returns `undefined` when there is nothing to write.\n */\nexport function mergeAgentsContent(\n base: string | undefined,\n scripts: Record<string, string> | undefined,\n): string | undefined {\n const scriptsSection =\n scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : ''\n if (base === undefined && scriptsSection.length === 0) return undefined\n if (base === undefined) return scriptsSection\n if (scriptsSection.length === 0) return base\n return `${base.trimEnd()}\\n\\n${scriptsSection}`\n}\n\n/** Escape a string for safe use as a single-quoted shell argument. */\nfunction sqEscape(value: string): string {\n return value.replace(/'/g, `'\\\\''`)\n}\n\n/**\n * Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a\n * fallback when `ln -s` is unavailable).\n *\n * @param handle - The sandbox handle providing `fs` and `process`.\n * @param root - Absolute path inside the sandbox under which to write.\n * @param content - Markdown content for the instruction file.\n */\nexport async function writeAgentsFile(\n handle: SandboxHandle,\n root: string,\n content: string,\n): Promise<void> {\n const agentsPath = `${root}/AGENTS.md`\n await handle.fs.write(agentsPath, content)\n\n for (const name of SYMLINK_NAMES) {\n const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'`\n const result = await handle.process.exec(lnCmd, { cwd: root })\n if (result.exitCode !== 0) {\n // Symlinks are not supported on this platform — fall back to a copy.\n await handle.fs.write(`${root}/${name}`, content)\n }\n }\n}\n"],"mappings":";;AAiBA,IAAM,gBAAuC,CAAC,aAAa,WAAW;;;;;;;;;;;;;;;;AAiBtE,SAAgB,mBACd,MACA,OACQ;CACR,MAAM,cAAc,MAAM,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,MAAM;CAIzD,OAAO,GAAG,KAAK,oBAHE,YAAY,SAAS,MAAM,IACxC,YAAY,MAAM,GAAG,EAAE,IACvB;AAEN;AAQA,IAAM,aAAa;AACnB,IAAM,iCAAiB,IAAI,IAAI,CAAC,QAAQ,cAAc,CAAC;AACvD,IAAM,uBAAuB;AAE7B,SAAS,WAAW,MAAsB;CACxC,MAAM,WAAW,KAAK,MAAM,GAAG,CAAC,CAAC,QAAQ,YAAY,YAAY,EAAE;CACnE,OAAO,SAAS,SAAS,SAAS,MAAM;AAC1C;;;;;;;;;;AAWA,eAAsB,kBACpB,QACA,UACoC;CACpC,MAAM,QAAmC,CAAC;CAC1C,MAAM,cAAc,QAAQ,UAAU,OAAO,CAAC;CAC9C,IAAI,MAAM,WAAW,GACnB,OAAO,CAAC;EAAE,MAAM,WAAW,QAAQ;EAAG,KAAK;CAAS,CAAC;CAEvD,OAAO;AACT;AAEA,eAAe,cACb,QACA,KACA,OACA,OACe;CACf,IAAI,QAAQ,sBAAsB;CAClC,IAAI;CACJ,IAAI;EACF,UAAU,MAAM,OAAO,GAAG,KAAK,GAAG;CACpC,QAAQ;EACN;CACF;CAMA,IALiB,QAAQ,MACtB,UACC,MAAM,SAAS,UACf,MAAM,KAAK,YAAY,MAAM,WAAW,YAAY,CAEpD,GAAU;EACZ,MAAM,KAAK;GAAE,MAAM,WAAW,GAAG;GAAG;EAAI,CAAC;EACzC;CACF;CACA,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,MAAM,SAAS,OAAO;EAC1B,IAAI,MAAM,KAAK,WAAW,GAAG,KAAK,eAAe,IAAI,MAAM,IAAI,GAAG;EAClE,MAAM,cAAc,QAAQ,MAAM,MAAM,OAAO,QAAQ,CAAC;CAC1D;AACF;;AAGA,SAAgB,8BACd,SACQ;CACR,MAAM,QAAQ,OAAO,KAAK,OAAO,CAAC,CAAC,KAAK;CACxC,IAAI,MAAM,WAAW,GAAG,OAAO;CAE/B,OAAO,2BADO,MAAM,KAAK,SAAS,KAAK,KAAK,KAAK,QAAQ,OACvB,CAAA,CAAM,KAAK,IAAI;AACnD;;;;;AAMA,SAAgB,mBACd,MACA,SACoB;CACpB,MAAM,iBACJ,YAAY,KAAA,IAAY,8BAA8B,OAAO,IAAI;CACnE,IAAI,SAAS,KAAA,KAAa,eAAe,WAAW,GAAG,OAAO,KAAA;CAC9D,IAAI,SAAS,KAAA,GAAW,OAAO;CAC/B,IAAI,eAAe,WAAW,GAAG,OAAO;CACxC,OAAO,GAAG,KAAK,QAAQ,EAAE,MAAM;AACjC;;AAGA,SAAS,SAAS,OAAuB;CACvC,OAAO,MAAM,QAAQ,MAAM,OAAO;AACpC;;;;;;;;;AAUA,eAAsB,gBACpB,QACA,MACA,SACe;CACf,MAAM,aAAa,GAAG,KAAK;CAC3B,MAAM,OAAO,GAAG,MAAM,YAAY,OAAO;CAEzC,KAAK,MAAM,QAAQ,eAAe;EAChC,MAAM,QAAQ,UAAU,SAAS,WAAW,EAAE,KAAK,SAAS,IAAI,EAAE;EAElE,KAAI,MADiB,OAAO,QAAQ,KAAK,OAAO,EAAE,KAAK,KAAK,CAAC,EAAA,CAClD,aAAa,GAEtB,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,OAAO;CAEpD;AACF"}
|
package/dist/esm/bootstrap.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveAllSecrets, resolveSecret } from "./secrets.js";
|
|
2
|
+
import { resolveHarnessCwd } from "./harness-cwd.js";
|
|
2
3
|
import { buildSetupPlan } from "./setup-plan.js";
|
|
3
4
|
import { createBootstrapShell } from "./shell.js";
|
|
4
5
|
import { mergeAgentsContent, resolveGitSkillDir, writeAgentsFile } from "./agents-file.js";
|
|
@@ -48,7 +49,7 @@ async function bootstrapWorkspace(handle, workspace, options = {}) {
|
|
|
48
49
|
const skills = workspace.skills ?? [];
|
|
49
50
|
for (const skill of skills) if (skill.kind === "git") {
|
|
50
51
|
const url = skill.repo.startsWith("http") ? skill.repo : `https://github.com/${skill.repo}.git`;
|
|
51
|
-
const dir = skill.into ?? resolveGitSkillDir(root, skill);
|
|
52
|
+
const dir = resolveHarnessCwd(handle, skill.into ?? resolveGitSkillDir(root, skill));
|
|
52
53
|
const auth = skill.secret !== void 0 && workspace.secrets !== void 0 ? { token: resolveSecret(workspace.secrets, skill.secret) } : void 0;
|
|
53
54
|
await handle.git.clone({
|
|
54
55
|
url,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bootstrap.js","names":[],"sources":["../../src/bootstrap.ts"],"sourcesContent":["/**\n * Workspace bootstrap engine — provider-agnostic because it only uses the\n * {@link SandboxHandle} contract. Runs once when a sandbox is freshly created\n * (or restored without its working tree): land the source, inject secrets,\n * detect the package manager, and run setup commands.\n *\n * Harness-specific projection (CLAUDE.md, agent skills, MCP config) is NOT done\n * here — that's each adapter's `projectWorkspace()` hook, since the format\n * differs per harness.\n */\nimport { buildSetupPlan } from './setup-plan'\nimport { createBootstrapShell } from './shell'\nimport {\n mergeAgentsContent,\n resolveGitSkillDir,\n writeAgentsFile,\n} from './agents-file'\nimport { resolveAllSecrets, resolveSecret } from './secrets'\nimport type { SandboxHandle } from './contracts'\nimport type { PackageManager, WorkspaceDefinition } from './workspace'\n\nconst LOCKFILES: Record<Exclude<PackageManager, 'auto'>, string> = {\n pnpm: 'pnpm-lock.yaml',\n yarn: 'yarn.lock',\n bun: 'bun.lockb',\n npm: 'package-lock.json',\n}\n\nexport const DEFAULT_WORKSPACE_ROOT = '/workspace'\n\n/** Resolve the package manager, detecting from a lockfile when `'auto'`. */\nexport async function detectPackageManager(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition,\n root: string,\n): Promise<Exclude<PackageManager, 'auto'> | undefined> {\n const pm = workspace.packageManager ?? 'auto'\n if (pm !== 'auto') return pm\n for (const [manager, lockfile] of Object.entries(LOCKFILES) as Array<\n [Exclude<PackageManager, 'auto'>, string]\n >) {\n if (await handle.fs.exists(`${root}/${lockfile}`)) return manager\n }\n return undefined\n}\n\nexport interface BootstrapResult {\n packageManager?: Exclude<PackageManager, 'auto'>\n ranSetup: Array<string>\n}\n\n/**\n * Bootstrap a freshly created sandbox's workspace. Idempotent enough to be safe\n * on restore: a git clone into a populated dir is skipped by checking for the\n * target dir first.\n */\nexport async function bootstrapWorkspace(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition,\n options: { signal?: AbortSignal } = {},\n): Promise<BootstrapResult> {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n\n // Secrets live only in the running sandbox env (never persisted).\n if (workspace.secrets !== undefined) {\n const resolved = resolveAllSecrets(workspace.secrets)\n if (Object.keys(resolved).length > 0) {\n await handle.env.set(resolved)\n }\n }\n\n // Land the source. Clone into the handle's own default root (each provider\n // maps the conventional `/workspace` virtual root to its real backing dir),\n // rather than passing a virtual `dir` that can't be remapped inside a shell\n // command string.\n if (workspace.source.type === 'git') {\n const alreadyCloned = await handle.fs.exists(`${root}/.git`)\n if (!alreadyCloned) {\n await handle.git.clone({\n url: workspace.source.url,\n ref: workspace.source.ref,\n auth: workspace.source.auth,\n ...(workspace.source.depth !== undefined\n ? { depth: workspace.source.depth }\n : {}),\n })\n }\n }\n // 'local' is provider-pre-populated at create; 'none' starts empty.\n\n // Clone git-skill repos so setup steps (and the harness projector) can use\n // them. gitSkill clones are always shallow (depth 1) unless the skill's own\n // repo entry carries a depth override — the WorkspaceSkill `git` variant\n // does not expose one, so depth always defaults to 1 inside git.clone.\n const skills = workspace.skills ?? []\n for (const skill of skills) {\n if (skill.kind === 'git') {\n const url = skill.repo.startsWith('http')\n ? skill.repo\n : `https://github.com/${skill.repo}.git`\n const dir = skill.into ?? resolveGitSkillDir(root, skill)\n const auth =\n skill.secret !== undefined && workspace.secrets !== undefined\n ? { token: resolveSecret(workspace.secrets, skill.secret) }\n : undefined\n await handle.git.clone({\n url,\n dir,\n ...(auth !== undefined ? { auth } : {}),\n depth: 1,\n })\n }\n }\n\n // Write AGENTS.md (and its per-CLI symlinks) when instructions are provided\n // directly on the workspace, via a fileSkill whose path is `AGENTS.md`, or\n // when named workspace scripts should be surfaced for the agent.\n let agentsContent: string | undefined\n if (\n workspace.instructions !== undefined &&\n workspace.instructions.length > 0\n ) {\n agentsContent = workspace.instructions\n } else {\n const agentsFileSkill = skills.find(\n (s): s is Extract<typeof s, { kind: 'file' }> =>\n s.kind === 'file' && s.path === 'AGENTS.md',\n )\n if (agentsFileSkill !== undefined) {\n agentsContent = agentsFileSkill.content\n }\n }\n agentsContent = mergeAgentsContent(agentsContent, workspace.scripts)\n if (agentsContent !== undefined) {\n await writeAgentsFile(handle, root, agentsContent)\n }\n\n // Write all other fileSkills directly into the workspace root.\n for (const skill of skills) {\n if (skill.kind === 'file' && skill.path !== 'AGENTS.md') {\n await handle.fs.write(`${root}/${skill.path}`, skill.content)\n }\n }\n\n const packageManager = await detectPackageManager(handle, workspace, root)\n\n // Run setup over a single persistent shell so `cd`/exports persist across\n // serial steps. Parallel groups fork the shell's current cwd+env into\n // concurrent one-shot exec calls.\n const ranSetup: Array<string> = []\n const plan = buildSetupPlan(workspace.setup)\n if (plan.length > 0) {\n const shell = await createBootstrapShell(handle, { cwd: root })\n try {\n for (const group of plan) {\n if (group.kind === 'serial') {\n const result = await shell.run(group.command)\n if (result.exitCode !== 0) {\n const tail = result.stdout.trim().slice(-1500)\n throw new Error(\n `setup step failed: ${group.command} (exit ${result.exitCode})${tail ? `\\n${tail}` : ''}`,\n )\n }\n ranSetup.push(group.command)\n } else {\n const { cwd, env } = await shell.forkState()\n const results = await Promise.all(\n group.commands.map((command) =>\n handle.process\n .exec(command, {\n cwd,\n env,\n ...(options.signal ? { signal: options.signal } : {}),\n })\n .then((res) => ({ command, res })),\n ),\n )\n const failed = results.find((entry) => entry.res.exitCode !== 0)\n if (failed !== undefined) {\n const tail = `${failed.res.stdout}\\n${failed.res.stderr}`\n .trim()\n .slice(-1500)\n throw new Error(\n `setup step failed: ${failed.command} (exit ${failed.res.exitCode})${tail ? `\\n${tail}` : ''}`,\n )\n }\n ranSetup.push(...group.commands)\n }\n }\n } finally {\n await shell.dispose()\n }\n }\n\n return { packageManager, ranSetup }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bootstrap.js","names":[],"sources":["../../src/bootstrap.ts"],"sourcesContent":["/**\n * Workspace bootstrap engine — provider-agnostic because it only uses the\n * {@link SandboxHandle} contract. Runs once when a sandbox is freshly created\n * (or restored without its working tree): land the source, inject secrets,\n * detect the package manager, and run setup commands.\n *\n * Harness-specific projection (CLAUDE.md, agent skills, MCP config) is NOT done\n * here — that's each adapter's `projectWorkspace()` hook, since the format\n * differs per harness.\n */\nimport { resolveHarnessCwd } from './harness-cwd'\nimport { buildSetupPlan } from './setup-plan'\nimport { createBootstrapShell } from './shell'\nimport {\n mergeAgentsContent,\n resolveGitSkillDir,\n writeAgentsFile,\n} from './agents-file'\nimport { resolveAllSecrets, resolveSecret } from './secrets'\nimport type { SandboxHandle } from './contracts'\nimport type { PackageManager, WorkspaceDefinition } from './workspace'\n\nconst LOCKFILES: Record<Exclude<PackageManager, 'auto'>, string> = {\n pnpm: 'pnpm-lock.yaml',\n yarn: 'yarn.lock',\n bun: 'bun.lockb',\n npm: 'package-lock.json',\n}\n\nexport const DEFAULT_WORKSPACE_ROOT = '/workspace'\n\n/** Resolve the package manager, detecting from a lockfile when `'auto'`. */\nexport async function detectPackageManager(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition,\n root: string,\n): Promise<Exclude<PackageManager, 'auto'> | undefined> {\n const pm = workspace.packageManager ?? 'auto'\n if (pm !== 'auto') return pm\n for (const [manager, lockfile] of Object.entries(LOCKFILES) as Array<\n [Exclude<PackageManager, 'auto'>, string]\n >) {\n if (await handle.fs.exists(`${root}/${lockfile}`)) return manager\n }\n return undefined\n}\n\nexport interface BootstrapResult {\n packageManager?: Exclude<PackageManager, 'auto'>\n ranSetup: Array<string>\n}\n\n/**\n * Bootstrap a freshly created sandbox's workspace. Idempotent enough to be safe\n * on restore: a git clone into a populated dir is skipped by checking for the\n * target dir first.\n */\nexport async function bootstrapWorkspace(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition,\n options: { signal?: AbortSignal } = {},\n): Promise<BootstrapResult> {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n\n // Secrets live only in the running sandbox env (never persisted).\n if (workspace.secrets !== undefined) {\n const resolved = resolveAllSecrets(workspace.secrets)\n if (Object.keys(resolved).length > 0) {\n await handle.env.set(resolved)\n }\n }\n\n // Land the source. Clone into the handle's own default root (each provider\n // maps the conventional `/workspace` virtual root to its real backing dir),\n // rather than passing a virtual `dir` that can't be remapped inside a shell\n // command string.\n if (workspace.source.type === 'git') {\n const alreadyCloned = await handle.fs.exists(`${root}/.git`)\n if (!alreadyCloned) {\n await handle.git.clone({\n url: workspace.source.url,\n ref: workspace.source.ref,\n auth: workspace.source.auth,\n ...(workspace.source.depth !== undefined\n ? { depth: workspace.source.depth }\n : {}),\n })\n }\n }\n // 'local' is provider-pre-populated at create; 'none' starts empty.\n\n // Clone git-skill repos so setup steps (and the harness projector) can use\n // them. gitSkill clones are always shallow (depth 1) unless the skill's own\n // repo entry carries a depth override — the WorkspaceSkill `git` variant\n // does not expose one, so depth always defaults to 1 inside git.clone.\n const skills = workspace.skills ?? []\n for (const skill of skills) {\n if (skill.kind === 'git') {\n const url = skill.repo.startsWith('http')\n ? skill.repo\n : `https://github.com/${skill.repo}.git`\n const dir = resolveHarnessCwd(\n handle,\n skill.into ?? resolveGitSkillDir(root, skill),\n )\n const auth =\n skill.secret !== undefined && workspace.secrets !== undefined\n ? { token: resolveSecret(workspace.secrets, skill.secret) }\n : undefined\n await handle.git.clone({\n url,\n dir,\n ...(auth !== undefined ? { auth } : {}),\n depth: 1,\n })\n }\n }\n\n // Write AGENTS.md (and its per-CLI symlinks) when instructions are provided\n // directly on the workspace, via a fileSkill whose path is `AGENTS.md`, or\n // when named workspace scripts should be surfaced for the agent.\n let agentsContent: string | undefined\n if (\n workspace.instructions !== undefined &&\n workspace.instructions.length > 0\n ) {\n agentsContent = workspace.instructions\n } else {\n const agentsFileSkill = skills.find(\n (s): s is Extract<typeof s, { kind: 'file' }> =>\n s.kind === 'file' && s.path === 'AGENTS.md',\n )\n if (agentsFileSkill !== undefined) {\n agentsContent = agentsFileSkill.content\n }\n }\n agentsContent = mergeAgentsContent(agentsContent, workspace.scripts)\n if (agentsContent !== undefined) {\n await writeAgentsFile(handle, root, agentsContent)\n }\n\n // Write all other fileSkills directly into the workspace root.\n for (const skill of skills) {\n if (skill.kind === 'file' && skill.path !== 'AGENTS.md') {\n await handle.fs.write(`${root}/${skill.path}`, skill.content)\n }\n }\n\n const packageManager = await detectPackageManager(handle, workspace, root)\n\n // Run setup over a single persistent shell so `cd`/exports persist across\n // serial steps. Parallel groups fork the shell's current cwd+env into\n // concurrent one-shot exec calls.\n const ranSetup: Array<string> = []\n const plan = buildSetupPlan(workspace.setup)\n if (plan.length > 0) {\n const shell = await createBootstrapShell(handle, { cwd: root })\n try {\n for (const group of plan) {\n if (group.kind === 'serial') {\n const result = await shell.run(group.command)\n if (result.exitCode !== 0) {\n const tail = result.stdout.trim().slice(-1500)\n throw new Error(\n `setup step failed: ${group.command} (exit ${result.exitCode})${tail ? `\\n${tail}` : ''}`,\n )\n }\n ranSetup.push(group.command)\n } else {\n const { cwd, env } = await shell.forkState()\n const results = await Promise.all(\n group.commands.map((command) =>\n handle.process\n .exec(command, {\n cwd,\n env,\n ...(options.signal ? { signal: options.signal } : {}),\n })\n .then((res) => ({ command, res })),\n ),\n )\n const failed = results.find((entry) => entry.res.exitCode !== 0)\n if (failed !== undefined) {\n const tail = `${failed.res.stdout}\\n${failed.res.stderr}`\n .trim()\n .slice(-1500)\n throw new Error(\n `setup step failed: ${failed.command} (exit ${failed.res.exitCode})${tail ? `\\n${tail}` : ''}`,\n )\n }\n ranSetup.push(...group.commands)\n }\n }\n } finally {\n await shell.dispose()\n }\n }\n\n return { packageManager, ranSetup }\n}\n"],"mappings":"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"}
|
package/dist/esm/contracts.d.ts
CHANGED
|
@@ -14,19 +14,21 @@ export interface SandboxCapabilities {
|
|
|
14
14
|
backgroundProcesses: boolean;
|
|
15
15
|
/**
|
|
16
16
|
* A spawned process exposes a writable host→process stdin
|
|
17
|
-
* ({@link SpawnHandle.stdin}). `true` for host
|
|
18
|
-
*
|
|
19
|
-
* harness adapters that feed a prompt over
|
|
20
|
-
* file + shell redirection.
|
|
17
|
+
* ({@link SpawnHandle.stdin}). `true` for host (`localProcessSandbox`).
|
|
18
|
+
* `false` for Docker container, Docker Sandboxes (`sbx`), Daytona, Vercel,
|
|
19
|
+
* and Cloudflare. When `false`, harness adapters that feed a prompt over
|
|
20
|
+
* stdin must instead deliver it via a file + shell redirection.
|
|
21
21
|
*/
|
|
22
22
|
writableStdin: boolean;
|
|
23
23
|
/**
|
|
24
24
|
* A spawned process can be forcibly terminated via {@link SpawnHandle.kill}
|
|
25
25
|
* and aborted mid-flight via the {@link ProcessOptions.signal} passed to
|
|
26
|
-
* {@link SandboxProcess.spawn}. `true` for host
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* {@link SandboxProcess.spawn}. `true` for host and Docker container.
|
|
27
|
+
* `false` for Docker Sandboxes (`sbx`) until measured, and for Daytona,
|
|
28
|
+
* Vercel, and Cloudflare. Those providers implement `kill()` as a no-op or
|
|
29
|
+
* have not been measured yet, so a long-running follower process
|
|
30
|
+
* (e.g. `tail -f`) started there can never be stopped by the caller, only
|
|
31
|
+
* polled and abandoned.
|
|
30
32
|
* Callers MUST branch on this before relying on `kill`/abort to reclaim a
|
|
31
33
|
* background process: a bring-your-own provider that omits it would
|
|
32
34
|
* otherwise be silently treated as killable, leaking an unstoppable process
|
|
@@ -199,6 +201,8 @@ export interface SandboxCreateInput {
|
|
|
199
201
|
policy?: SandboxPolicy;
|
|
200
202
|
env?: Record<string, string>;
|
|
201
203
|
signal?: AbortSignal;
|
|
204
|
+
/** Harness adapter name. Optional. Providers that do not use it ignore it. */
|
|
205
|
+
adapterName?: string;
|
|
202
206
|
}
|
|
203
207
|
/** Input passed to {@link SandboxProvider.resume}. */
|
|
204
208
|
export interface SandboxResumeInput {
|
package/dist/esm/git-exec.js
CHANGED
|
@@ -24,6 +24,8 @@ function createExecBackedGit(process, defaultRoot) {
|
|
|
24
24
|
const resolvedDepth = depth ?? 1;
|
|
25
25
|
if (resolvedDepth !== "full" && (!Number.isInteger(resolvedDepth) || resolvedDepth <= 0)) throw new Error("git-exec: depth must be a positive integer or \"full\".");
|
|
26
26
|
const depthArg = resolvedDepth === "full" ? "" : `--depth ${resolvedDepth} --single-branch `;
|
|
27
|
+
const parentSlash = target.lastIndexOf("/");
|
|
28
|
+
if (parentSlash > 0) await process.exec(`mkdir -p ${q(target.slice(0, parentSlash))}`);
|
|
27
29
|
if (auth?.token) {
|
|
28
30
|
await process.exec(`git -c credential.helper=${q(CREDENTIAL_HELPER)} clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`, { env: {
|
|
29
31
|
GIT_ASKPASS_USER: auth.username ?? "x-access-token",
|
package/dist/esm/git-exec.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git-exec.js","names":[],"sources":["../../src/git-exec.ts"],"sourcesContent":["/**\n * An exec-backed {@link SandboxGit} implementation. Providers without a native\n * git API (local-process, Docker) get a uniform `sandbox.git` by desugaring to\n * `process.exec(\"git …\")`. Providers WITH native git (Daytona, Cloudflare) may\n * supply their own implementation instead.\n *\n * Security:\n * - Every interpolated value is single-quote escaped (no shell injection).\n * - A `--` end-of-options separator precedes untrusted positionals and values\n * are rejected if they begin with `-`, so a repo URL / ref / path can't\n * smuggle a git flag (e.g. `--upload-pack=…`).\n * - Auth tokens NEVER appear in argv (they'd leak via `ps` / process logs).\n * Instead a one-shot `credential.helper` reads the token from the child\n * process ENV. The helper string is single-quoted so the OUTER shell never\n * expands the env var — only git's own helper subshell does, at use time.\n *\n * NOTE: `SandboxProcess.exec` takes a command STRING by design (the sandbox\n * runs shell commands), so we mitigate flag smuggling with `--` + validation\n * rather than an argv array.\n */\nimport type { SandboxGit, SandboxProcess } from './contracts'\n\n/** POSIX single-quote escape: wrap in '…' and escape embedded quotes. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/** Reject values that could be parsed as a git flag when used as a positional. */\nfunction assertNoLeadingDash(value: string, name: string): void {\n if (value.startsWith('-')) {\n throw new Error(\n `git-exec: ${name} \"${value}\" must not begin with \"-\" (argument-injection guard).`,\n )\n }\n}\n\n// Credential helper that prints creds read from the child ENV. Single-quoted at\n// the call site so the outer shell passes it literally; git expands the vars in\n// its own helper subshell, keeping the token out of argv.\nconst CREDENTIAL_HELPER =\n '!f() { echo \"username=${GIT_ASKPASS_USER}\"; echo \"password=${GIT_ASKPASS_TOKEN}\"; }; f'\n\nexport function createExecBackedGit(\n process: SandboxProcess,\n defaultRoot: string,\n): SandboxGit {\n const at = (dir?: string): string => {\n const d = dir ?? defaultRoot\n assertNoLeadingDash(d, 'dir')\n return q(d)\n }\n\n return {\n clone: async ({ url, dir, ref, auth, depth }) => {\n assertNoLeadingDash(url, 'url')\n const target = dir ?? defaultRoot\n assertNoLeadingDash(target, 'dir')\n if (ref !== undefined) assertNoLeadingDash(ref, 'ref')\n const refArg = ref ? `--branch ${q(ref)} ` : ''\n const resolvedDepth = depth ?? 1\n // `depth` is interpolated unquoted into the command, so validate it the\n // same way other positionals are guarded — a non-positive-integer (e.g. an\n // untyped caller passing a string) must never reach the shell.\n if (\n resolvedDepth !== 'full' &&\n (!Number.isInteger(resolvedDepth) || resolvedDepth <= 0)\n ) {\n throw new Error('git-exec: depth must be a positive integer or \"full\".')\n }\n const depthArg =\n resolvedDepth === 'full'\n ? ''\n : `--depth ${resolvedDepth} --single-branch `\n\n if (auth?.token) {\n await process.exec(\n `git -c credential.helper=${q(CREDENTIAL_HELPER)} clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,\n {\n // Token lives only in the child env, never in argv.\n env: {\n GIT_ASKPASS_USER: auth.username ?? 'x-access-token',\n GIT_ASKPASS_TOKEN: auth.token,\n GIT_TERMINAL_PROMPT: '0',\n },\n },\n )\n return\n }\n\n await process.exec(\n `git clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,\n )\n },\n status: async (dir) =>\n (await process.exec(`git -C ${at(dir)} status --porcelain`)).stdout,\n add: async (paths, dir) => {\n paths.forEach((p, i) => assertNoLeadingDash(p, `path[${i}]`))\n await process.exec(`git -C ${at(dir)} add -- ${paths.map(q).join(' ')}`)\n },\n commit: async (message, dir) => {\n await process.exec(`git -C ${at(dir)} commit -m ${q(message)}`)\n },\n push: async (dir) => {\n await process.exec(`git -C ${at(dir)} push`)\n },\n pull: async (dir) => {\n await process.exec(`git -C ${at(dir)} pull`)\n },\n branch: async (dir) =>\n (\n await process.exec(`git -C ${at(dir)} rev-parse --abbrev-ref HEAD`)\n ).stdout.trim(),\n }\n}\n"],"mappings":";;AAuBA,SAAS,EAAE,OAAuB;CAChC,OAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,EAAE;AAC1C;;AAGA,SAAS,oBAAoB,OAAe,MAAoB;CAC9D,IAAI,MAAM,WAAW,GAAG,GACtB,MAAM,IAAI,MACR,aAAa,KAAK,IAAI,MAAM,sDAC9B;AAEJ;AAKA,IAAM,oBACJ;AAEF,SAAgB,oBACd,SACA,aACY;CACZ,MAAM,MAAM,QAAyB;EACnC,MAAM,IAAI,OAAO;EACjB,oBAAoB,GAAG,KAAK;EAC5B,OAAO,EAAE,CAAC;CACZ;CAEA,OAAO;EACL,OAAO,OAAO,EAAE,KAAK,KAAK,KAAK,MAAM,YAAY;GAC/C,oBAAoB,KAAK,KAAK;GAC9B,MAAM,SAAS,OAAO;GACtB,oBAAoB,QAAQ,KAAK;GACjC,IAAI,QAAQ,KAAA,GAAW,oBAAoB,KAAK,KAAK;GACrD,MAAM,SAAS,MAAM,YAAY,EAAE,GAAG,EAAE,KAAK;GAC7C,MAAM,gBAAgB,SAAS;GAI/B,IACE,kBAAkB,WACjB,CAAC,OAAO,UAAU,aAAa,KAAK,iBAAiB,IAEtD,MAAM,IAAI,MAAM,yDAAuD;GAEzE,MAAM,WACJ,kBAAkB,SACd,KACA,WAAW,cAAc;
|
|
1
|
+
{"version":3,"file":"git-exec.js","names":[],"sources":["../../src/git-exec.ts"],"sourcesContent":["/**\n * An exec-backed {@link SandboxGit} implementation. Providers without a native\n * git API (local-process, Docker) get a uniform `sandbox.git` by desugaring to\n * `process.exec(\"git …\")`. Providers WITH native git (Daytona, Cloudflare) may\n * supply their own implementation instead.\n *\n * Security:\n * - Every interpolated value is single-quote escaped (no shell injection).\n * - A `--` end-of-options separator precedes untrusted positionals and values\n * are rejected if they begin with `-`, so a repo URL / ref / path can't\n * smuggle a git flag (e.g. `--upload-pack=…`).\n * - Auth tokens NEVER appear in argv (they'd leak via `ps` / process logs).\n * Instead a one-shot `credential.helper` reads the token from the child\n * process ENV. The helper string is single-quoted so the OUTER shell never\n * expands the env var — only git's own helper subshell does, at use time.\n *\n * NOTE: `SandboxProcess.exec` takes a command STRING by design (the sandbox\n * runs shell commands), so we mitigate flag smuggling with `--` + validation\n * rather than an argv array.\n */\nimport type { SandboxGit, SandboxProcess } from './contracts'\n\n/** POSIX single-quote escape: wrap in '…' and escape embedded quotes. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/** Reject values that could be parsed as a git flag when used as a positional. */\nfunction assertNoLeadingDash(value: string, name: string): void {\n if (value.startsWith('-')) {\n throw new Error(\n `git-exec: ${name} \"${value}\" must not begin with \"-\" (argument-injection guard).`,\n )\n }\n}\n\n// Credential helper that prints creds read from the child ENV. Single-quoted at\n// the call site so the outer shell passes it literally; git expands the vars in\n// its own helper subshell, keeping the token out of argv.\nconst CREDENTIAL_HELPER =\n '!f() { echo \"username=${GIT_ASKPASS_USER}\"; echo \"password=${GIT_ASKPASS_TOKEN}\"; }; f'\n\nexport function createExecBackedGit(\n process: SandboxProcess,\n defaultRoot: string,\n): SandboxGit {\n const at = (dir?: string): string => {\n const d = dir ?? defaultRoot\n assertNoLeadingDash(d, 'dir')\n return q(d)\n }\n\n return {\n clone: async ({ url, dir, ref, auth, depth }) => {\n assertNoLeadingDash(url, 'url')\n const target = dir ?? defaultRoot\n assertNoLeadingDash(target, 'dir')\n if (ref !== undefined) assertNoLeadingDash(ref, 'ref')\n const refArg = ref ? `--branch ${q(ref)} ` : ''\n const resolvedDepth = depth ?? 1\n // `depth` is interpolated unquoted into the command, so validate it the\n // same way other positionals are guarded — a non-positive-integer (e.g. an\n // untyped caller passing a string) must never reach the shell.\n if (\n resolvedDepth !== 'full' &&\n (!Number.isInteger(resolvedDepth) || resolvedDepth <= 0)\n ) {\n throw new Error('git-exec: depth must be a positive integer or \"full\".')\n }\n const depthArg =\n resolvedDepth === 'full'\n ? ''\n : `--depth ${resolvedDepth} --single-branch `\n\n // `git clone` does not create missing parents. gitSkill clones into\n // `<root>/.tanstack-skills/<name>`, so create that parent first.\n const parentSlash = target.lastIndexOf('/')\n if (parentSlash > 0) {\n await process.exec(`mkdir -p ${q(target.slice(0, parentSlash))}`)\n }\n\n if (auth?.token) {\n await process.exec(\n `git -c credential.helper=${q(CREDENTIAL_HELPER)} clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,\n {\n // Token lives only in the child env, never in argv.\n env: {\n GIT_ASKPASS_USER: auth.username ?? 'x-access-token',\n GIT_ASKPASS_TOKEN: auth.token,\n GIT_TERMINAL_PROMPT: '0',\n },\n },\n )\n return\n }\n\n await process.exec(\n `git clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,\n )\n },\n status: async (dir) =>\n (await process.exec(`git -C ${at(dir)} status --porcelain`)).stdout,\n add: async (paths, dir) => {\n paths.forEach((p, i) => assertNoLeadingDash(p, `path[${i}]`))\n await process.exec(`git -C ${at(dir)} add -- ${paths.map(q).join(' ')}`)\n },\n commit: async (message, dir) => {\n await process.exec(`git -C ${at(dir)} commit -m ${q(message)}`)\n },\n push: async (dir) => {\n await process.exec(`git -C ${at(dir)} push`)\n },\n pull: async (dir) => {\n await process.exec(`git -C ${at(dir)} pull`)\n },\n branch: async (dir) =>\n (\n await process.exec(`git -C ${at(dir)} rev-parse --abbrev-ref HEAD`)\n ).stdout.trim(),\n }\n}\n"],"mappings":";;AAuBA,SAAS,EAAE,OAAuB;CAChC,OAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,EAAE;AAC1C;;AAGA,SAAS,oBAAoB,OAAe,MAAoB;CAC9D,IAAI,MAAM,WAAW,GAAG,GACtB,MAAM,IAAI,MACR,aAAa,KAAK,IAAI,MAAM,sDAC9B;AAEJ;AAKA,IAAM,oBACJ;AAEF,SAAgB,oBACd,SACA,aACY;CACZ,MAAM,MAAM,QAAyB;EACnC,MAAM,IAAI,OAAO;EACjB,oBAAoB,GAAG,KAAK;EAC5B,OAAO,EAAE,CAAC;CACZ;CAEA,OAAO;EACL,OAAO,OAAO,EAAE,KAAK,KAAK,KAAK,MAAM,YAAY;GAC/C,oBAAoB,KAAK,KAAK;GAC9B,MAAM,SAAS,OAAO;GACtB,oBAAoB,QAAQ,KAAK;GACjC,IAAI,QAAQ,KAAA,GAAW,oBAAoB,KAAK,KAAK;GACrD,MAAM,SAAS,MAAM,YAAY,EAAE,GAAG,EAAE,KAAK;GAC7C,MAAM,gBAAgB,SAAS;GAI/B,IACE,kBAAkB,WACjB,CAAC,OAAO,UAAU,aAAa,KAAK,iBAAiB,IAEtD,MAAM,IAAI,MAAM,yDAAuD;GAEzE,MAAM,WACJ,kBAAkB,SACd,KACA,WAAW,cAAc;GAI/B,MAAM,cAAc,OAAO,YAAY,GAAG;GAC1C,IAAI,cAAc,GAChB,MAAM,QAAQ,KAAK,YAAY,EAAE,OAAO,MAAM,GAAG,WAAW,CAAC,GAAG;GAGlE,IAAI,MAAM,OAAO;IACf,MAAM,QAAQ,KACZ,4BAA4B,EAAE,iBAAiB,EAAE,SAAS,SAAS,SAAS,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,KACnG,EAEE,KAAK;KACH,kBAAkB,KAAK,YAAY;KACnC,mBAAmB,KAAK;KACxB,qBAAqB;IACvB,EACF,CACF;IACA;GACF;GAEA,MAAM,QAAQ,KACZ,aAAa,SAAS,SAAS,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,GACxD;EACF;EACA,QAAQ,OAAO,SACZ,MAAM,QAAQ,KAAK,UAAU,GAAG,GAAG,EAAE,oBAAoB,EAAA,CAAG;EAC/D,KAAK,OAAO,OAAO,QAAQ;GACzB,MAAM,SAAS,GAAG,MAAM,oBAAoB,GAAG,QAAQ,EAAE,EAAE,CAAC;GAC5D,MAAM,QAAQ,KAAK,UAAU,GAAG,GAAG,EAAE,UAAU,MAAM,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,GAAG;EACzE;EACA,QAAQ,OAAO,SAAS,QAAQ;GAC9B,MAAM,QAAQ,KAAK,UAAU,GAAG,GAAG,EAAE,aAAa,EAAE,OAAO,GAAG;EAChE;EACA,MAAM,OAAO,QAAQ;GACnB,MAAM,QAAQ,KAAK,UAAU,GAAG,GAAG,EAAE,MAAM;EAC7C;EACA,MAAM,OAAO,QAAQ;GACnB,MAAM,QAAQ,KAAK,UAAU,GAAG,GAAG,EAAE,MAAM;EAC7C;EACA,QAAQ,OAAO,SAEX,MAAM,QAAQ,KAAK,UAAU,GAAG,GAAG,EAAE,6BAA6B,EAAA,CAClE,OAAO,KAAK;CAClB;AACF"}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type { SandboxProvider, SandboxHandle, SandboxCapabilities, SandboxFs, Sa
|
|
|
18
18
|
export { bootstrapWorkspace, detectPackageManager, DEFAULT_WORKSPACE_ROOT, } from './bootstrap.js';
|
|
19
19
|
export { resolveHarnessCwd } from './harness-cwd.js';
|
|
20
20
|
export type { BootstrapResult } from './bootstrap.js';
|
|
21
|
-
export { writeAgentsFile, resolveGitSkillDir, formatWorkspaceScriptsSection, mergeAgentsContent, } from './agents-file.js';
|
|
21
|
+
export { writeAgentsFile, resolveGitSkillDir, discoverSkillDirs, formatWorkspaceScriptsSection, mergeAgentsContent, } from './agents-file.js';
|
|
22
|
+
export type { DiscoveredSkillDir } from './agents-file.js';
|
|
22
23
|
export { createExecBackedGit } from './git-exec.js';
|
|
23
24
|
export { spawnNdjson, toLines, startJournaledAgent, readJournalNdjson, } from './runner.js';
|
|
24
25
|
export type { SpawnNdjsonOptions, JournalOptions } from './runner.js';
|
package/dist/esm/index.js
CHANGED
|
@@ -8,14 +8,14 @@ import { DurableAttachNotSupportedError, DurableRunIdRequiredError, DurableThrea
|
|
|
8
8
|
import { computeSandboxKey, computeWorkspaceHash } from "./key.js";
|
|
9
9
|
import { bearer, createSecrets, isSecretRef, resolveAllSecrets, resolveBearer, resolveSecret } from "./secrets.js";
|
|
10
10
|
import { isSandboxToolCall } from "./tool-history.js";
|
|
11
|
-
import {
|
|
11
|
+
import { resolveHarnessCwd } from "./harness-cwd.js";
|
|
12
|
+
import { discoverSkillDirs, formatWorkspaceScriptsSection, mergeAgentsContent, resolveGitSkillDir, writeAgentsFile } from "./agents-file.js";
|
|
12
13
|
import { DEFAULT_WORKSPACE_ROOT, bootstrapWorkspace, detectPackageManager } from "./bootstrap.js";
|
|
13
14
|
import { diffSnapshots, watchWorkspace } from "./watch.js";
|
|
14
15
|
import { withSandbox } from "./middleware.js";
|
|
15
16
|
import { defineSandbox } from "./sandbox.js";
|
|
16
17
|
import { agentSkill, defineWorkspace, fileSkill, gitSkill, gitSource, githubRepo, localSource, mcpSkill } from "./workspace.js";
|
|
17
18
|
import { commandAliases, defineSandboxPolicy, evaluateCommand } from "./policy.js";
|
|
18
|
-
import { resolveHarnessCwd } from "./harness-cwd.js";
|
|
19
19
|
import { createExecBackedGit } from "./git-exec.js";
|
|
20
20
|
import { DEFAULT_ATTACH_JOURNAL_WAIT_MS, DEFAULT_ATTACH_PROBE_INTERVAL_MS, JournalAttachUnavailableError, awaitAttachableJournal } from "./attach-preflight.js";
|
|
21
21
|
import { decodeBase64Stream, toJournalLines } from "./journal-bytes.js";
|
|
@@ -32,4 +32,4 @@ import { createBridgeEventChannel, mergeChunkStreams } from "./bridge-events.js"
|
|
|
32
32
|
import { executeHostTool, httpRemoteToolExecutor, isToolExecRequest, remoteToolStubs, toolDescriptors } from "./remote-tools.js";
|
|
33
33
|
import { APPROVAL_REQUESTED_EVENT, approvalId, buildApprovalRequestedEvent, resolveApproval } from "./approvals.js";
|
|
34
34
|
import { MissingSandboxError, UnsupportedCapabilityError } from "./errors.js";
|
|
35
|
-
export { APPROVAL_REQUESTED_EVENT, BRIDGED_MCP_SERVER_NAME, DEFAULT_ATTACH_JOURNAL_WAIT_MS, DEFAULT_ATTACH_PROBE_INTERVAL_MS, DEFAULT_EXIT_PROBE_BYTES, DEFAULT_FENCE_QUIET_MS, DEFAULT_JOURNAL_DIR, DEFAULT_JOURNAL_POLL_MS, DEFAULT_MAX_DELETES, DEFAULT_MAX_OUT_OF_BAND_SKIP, DEFAULT_MAX_RUNS, DEFAULT_ORPHAN_TTL_MS, DEFAULT_RUN_BUDGET_MS, DEFAULT_WORKSPACE_ROOT, DurableAttachNotSupportedError, DurableRunIdRequiredError, DurableThreadIdRequiredError, EXIT_SENTINEL_KEY, EXIT_SENTINEL_NONCE_KEY, InMemorySandboxInstanceStore, JournalAttachUnavailableError, JournalReplayDivergedError, JournalReplayThreadIdMismatchError, MissingSandboxError, ProjectionCapability, RunClaimLostError, RunClaimNotAcquiredError, RunController, RunDriverPipeOutsideClaimError, SandboxCapability, SandboxDurabilityCapability, SandboxInstanceStoreCapability, SandboxPolicyCapability, SandboxReclaimFailedError, ToolBridgeProvisionerCapability, UnsupportedCapabilityError, agentSkill, alignToStoredLog, alignedIfAttaching, approvalId, awaitAttachableJournal, bearer, bootstrapWorkspace, buildApprovalRequestedEvent, chunkFingerprint, chunkFingerprintIgnoringThreadId, chunkThreadId, commandAliases, computeSandboxKey, computeWorkspaceHash, createBridgeEventChannel, createExecBackedGit, createRunScopedIdGen, createSecrets, createToolBridgeCore, decodeBase64Stream, decodeJournalRunId, defineSandbox, defineSandboxInstanceStore, defineSandboxPolicy, defineWorkspace, detectPackageManager, diffSnapshots, encodeRunId, evaluateCommand, executeHostTool, exitSentinelLine, fileSkill, formatWorkspaceScriptsSection, getSandbox, getSandboxDurability, getSandboxInstanceStore, getSandboxPolicy, getToolBridgeProvisioner, getWorkspaceProjection, gitSkill, gitSource, githubRepo, handleBridgeJsonRpc, hostForSandbox, httpRemoteToolExecutor, isBridgeCustomChunk, isSandboxToolCall, isSecretRef, isToolExecRequest, journalExistsCommand, journalExitProbeCommand, journalFollowCommand, journalListCommand, journalMtimeListCommand, journalOptionsFor, journalPaths, journalReadCommand, journalReadStrategy, journaledCommand, localSource, mcpSkill, mergeAgentsContent, mergeChunkStreams, nodeHttpBridgeProvisioner, parseExitSentinel, parseJournalExit, parseJournalMtimeListing, pipeToRunLog, probeRunExit, provideSandbox, provideSandboxDurability, provideSandboxInstanceStore, provideSandboxPolicy, provideToolBridgeProvisioner, provideWorkspaceProjection, pruneJournals, readJournal, readJournalNdjson, reapDetachedRuns, reclaimSandbox, remoteToolStubs, resolveAllSecrets, resolveApproval, resolveBearer, resolveDurableRunId, resolveDurableThreadId, resolveGitSkillDir, resolveHarnessCwd, resolveSecret, sandboxReclaimer, sandboxRunDriver, spawnNdjson, startHostToolBridge, startJournaledAgent, timingSafeBearerEqual, toJournalLines, toLines, toolDescriptors, watchWorkspace, withSandbox, writeAgentsFile };
|
|
35
|
+
export { APPROVAL_REQUESTED_EVENT, BRIDGED_MCP_SERVER_NAME, DEFAULT_ATTACH_JOURNAL_WAIT_MS, DEFAULT_ATTACH_PROBE_INTERVAL_MS, DEFAULT_EXIT_PROBE_BYTES, DEFAULT_FENCE_QUIET_MS, DEFAULT_JOURNAL_DIR, DEFAULT_JOURNAL_POLL_MS, DEFAULT_MAX_DELETES, DEFAULT_MAX_OUT_OF_BAND_SKIP, DEFAULT_MAX_RUNS, DEFAULT_ORPHAN_TTL_MS, DEFAULT_RUN_BUDGET_MS, DEFAULT_WORKSPACE_ROOT, DurableAttachNotSupportedError, DurableRunIdRequiredError, DurableThreadIdRequiredError, EXIT_SENTINEL_KEY, EXIT_SENTINEL_NONCE_KEY, InMemorySandboxInstanceStore, JournalAttachUnavailableError, JournalReplayDivergedError, JournalReplayThreadIdMismatchError, MissingSandboxError, ProjectionCapability, RunClaimLostError, RunClaimNotAcquiredError, RunController, RunDriverPipeOutsideClaimError, SandboxCapability, SandboxDurabilityCapability, SandboxInstanceStoreCapability, SandboxPolicyCapability, SandboxReclaimFailedError, ToolBridgeProvisionerCapability, UnsupportedCapabilityError, agentSkill, alignToStoredLog, alignedIfAttaching, approvalId, awaitAttachableJournal, bearer, bootstrapWorkspace, buildApprovalRequestedEvent, chunkFingerprint, chunkFingerprintIgnoringThreadId, chunkThreadId, commandAliases, computeSandboxKey, computeWorkspaceHash, createBridgeEventChannel, createExecBackedGit, createRunScopedIdGen, createSecrets, createToolBridgeCore, decodeBase64Stream, decodeJournalRunId, defineSandbox, defineSandboxInstanceStore, defineSandboxPolicy, defineWorkspace, detectPackageManager, diffSnapshots, discoverSkillDirs, encodeRunId, evaluateCommand, executeHostTool, exitSentinelLine, fileSkill, formatWorkspaceScriptsSection, getSandbox, getSandboxDurability, getSandboxInstanceStore, getSandboxPolicy, getToolBridgeProvisioner, getWorkspaceProjection, gitSkill, gitSource, githubRepo, handleBridgeJsonRpc, hostForSandbox, httpRemoteToolExecutor, isBridgeCustomChunk, isSandboxToolCall, isSecretRef, isToolExecRequest, journalExistsCommand, journalExitProbeCommand, journalFollowCommand, journalListCommand, journalMtimeListCommand, journalOptionsFor, journalPaths, journalReadCommand, journalReadStrategy, journaledCommand, localSource, mcpSkill, mergeAgentsContent, mergeChunkStreams, nodeHttpBridgeProvisioner, parseExitSentinel, parseJournalExit, parseJournalMtimeListing, pipeToRunLog, probeRunExit, provideSandbox, provideSandboxDurability, provideSandboxInstanceStore, provideSandboxPolicy, provideToolBridgeProvisioner, provideWorkspaceProjection, pruneJournals, readJournal, readJournalNdjson, reapDetachedRuns, reclaimSandbox, remoteToolStubs, resolveAllSecrets, resolveApproval, resolveBearer, resolveDurableRunId, resolveDurableThreadId, resolveGitSkillDir, resolveHarnessCwd, resolveSecret, sandboxReclaimer, sandboxRunDriver, spawnNdjson, startHostToolBridge, startJournaledAgent, timingSafeBearerEqual, toJournalLines, toLines, toolDescriptors, watchWorkspace, withSandbox, writeAgentsFile };
|
package/dist/esm/middleware.js
CHANGED
|
@@ -6,6 +6,7 @@ import { computeWorkspaceHash } from "./key.js";
|
|
|
6
6
|
import { buildFileHookEvent, resolveFileEvents } from "./file-diff.js";
|
|
7
7
|
import { resolveSecret } from "./secrets.js";
|
|
8
8
|
import { createToolHistoryRecorder, stripObservedToolCalls } from "./tool-history.js";
|
|
9
|
+
import { resolveHarnessCwd } from "./harness-cwd.js";
|
|
9
10
|
import "./bootstrap.js";
|
|
10
11
|
import { watchWorkspace } from "./watch.js";
|
|
11
12
|
import { defineChatMiddleware, provideDetachableRun, provideRunDetached, wasCancelRequested } from "@tanstack/ai";
|
|
@@ -122,7 +123,8 @@ function buildEnsureCtx(ctx, options) {
|
|
|
122
123
|
store: options?.instances ?? ctx.getOptional(SandboxInstanceStoreCapability),
|
|
123
124
|
locks: options?.locks ?? ctx.getOptional(LocksCapability),
|
|
124
125
|
tenant: tenantFrom(ctx.context),
|
|
125
|
-
signal: ctx.signal
|
|
126
|
+
signal: ctx.signal,
|
|
127
|
+
adapterName: ctx.provider
|
|
126
128
|
};
|
|
127
129
|
}
|
|
128
130
|
/**
|
|
@@ -228,7 +230,7 @@ function withSandbox(definition, options) {
|
|
|
228
230
|
}
|
|
229
231
|
const workspace = definition.workspace;
|
|
230
232
|
if (workspace !== void 0) {
|
|
231
|
-
const root = workspace.root ?? "/workspace";
|
|
233
|
+
const root = resolveHarnessCwd(handle, workspace.root ?? "/workspace");
|
|
232
234
|
const workspaceHash = computeWorkspaceHash(workspace);
|
|
233
235
|
const secrets = workspace.secrets;
|
|
234
236
|
provideWorkspaceProjection(ctx, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition, options?)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the durability seams from\n * {@link SandboxMiddlewareOptions} (or, failing that, a bus-provided\n * SandboxInstanceStoreCapability / LocksCapability, then an in-memory\n * fallback). If `fileEvents` is not false, starts a\n * watcher that dispatches to sandbox-scoped hooks and forwards to the runtime\n * sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport {\n defineChatMiddleware,\n provideDetachableRun,\n provideRunDetached,\n wasCancelRequested,\n} from '@tanstack/ai'\nimport { InMemoryLockStore, LocksCapability } from '@tanstack/ai/locks'\nimport {\n getPendingTurn,\n getRunDisconnect,\n getSandboxRuntime,\n} from '@tanstack/ai/adapter-internals'\nimport {\n SandboxCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport {\n provideSandboxDurability,\n resolveSandboxDurability,\n} from './durability'\nimport { SandboxInstanceStoreCapability } from './instance-store'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport {\n createToolHistoryRecorder,\n stripObservedToolCalls,\n} from './tool-history'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n RunStore,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type {\n SandboxDurabilityOptions,\n SandboxRunDurability,\n} from './durability'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { ToolHistoryRecorder } from './tool-history'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n /**\n * OPTIONAL because the state is registered BEFORE `definition.ensure()` is\n * awaited, and `ensure` is the slowest thing in the whole run — cloning a repo\n * into a fresh sandbox is minutes wide. That window is where the most common\n * disconnect of all lands (a user starts a run and switches away while the UI\n * still says \"starting the sandbox\"), so it is the one window the teardown and\n * disconnect hooks most need to be able to act in. Registering only after the\n * handle exists left exactly that window uncovered.\n *\n * Nothing the disconnect path does needs the handle: `detachedSince` and\n * `sandboxKey` come from `ensureCtx`, which is built before `ensure` is called.\n * Only `onFinish`'s snapshot needs it, and that cannot run before `setup` has\n * completed.\n */\n handle?: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n /** Logger captured at setup, so terminal hooks can log watcher teardown. */\n logger?: InternalLogger\n /**\n * Durability resolved once at setup (absent when the run is not durable), so\n * `onAbort` cannot reach a different verdict than the one `setup` published\n * on the capability bus.\n */\n durability?: SandboxRunDurability\n /**\n * Records the harness's own tool calls into the transcript, so a finished run\n * restores its tool cards from the message store instead of only from the (live,\n * rejoin-only) delivery log. See `./tool-history`.\n */\n toolHistory: ToolHistoryRecorder\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/**\n * Stop the watcher and drain any in-flight `diff()` promises before teardown,\n * so the final file's diff isn't dropped when a run finishes/aborts/errors\n * mid-computation. The `pendingDiffs` await is the load-bearing line — without\n * it a deferred diff resolves after the run is gone and its chunk is lost.\n */\nasync function drainWatcher(\n state: SandboxRunState,\n phase: 'finish' | 'abort' | 'error',\n): Promise<void> {\n // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of\n // here, or the caller skips the `definition.destroy(...)` that follows —\n // leaking the sandbox on exactly the abort path that must ALWAYS tear down.\n try {\n await state.watcher?.stop()\n } catch (error) {\n state.logger?.warn('sandbox watcher stop failed', { phase, error })\n }\n await Promise.allSettled(state.pendingDiffs)\n if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })\n}\n\n/**\n * Record the two facts a later attach and the reaper both need, then publish the\n * detach verdict core reads.\n *\n * Shared by the DISCONNECT subscriber registered in `setup` (the run is still\n * going — the normal case) and `onAbort`'s detach branch (the run is being torn\n * down while detachable), so the two can never write a different shape of detach.\n *\n * GUARDED, and reports failure rather than throwing. `update` is a documented\n * no-op for an unknown runId, so a vanished record does not turn teardown into a\n * throw; a genuinely rejecting store is the caller's to react to — `onAbort` falls\n * through to destroying the sandbox, because a DESTROYED sandbox beats an\n * unreachable one, while the disconnect subscriber has nothing to fall back to\n * (the run is alive and still using the sandbox) and simply leaves the verdict\n * unpublished.\n *\n * The verdict is published ONLY on success. Publishing it after a failed record\n * write would leave core holding the log open for a takeover that can never be\n * found, since nothing in the store points at the run.\n */\nasync function recordDetach(\n definition: SandboxDefinition,\n state: SandboxRunState,\n durability: SandboxRunDurability,\n ctx: ChatMiddlewareContext,\n phase: 'disconnect' | 'abort',\n): Promise<boolean> {\n try {\n // The record already exists: `setup` pre-creates it for every durable run\n // BEFORE `ensure`, precisely so this stamp cannot land on a runId the store has\n // never heard of — `RunStore.update` is a documented no-op for an unknown\n // runId, which is how the detach used to be lost silently (measured against the\n // browser repro: `detached_since` and `sandbox_key` both stayed NULL for a run\n // that had genuinely detached). If it has since vanished, that no-op is the\n // correct outcome and this must not throw.\n await durability.runs.update(ctx.runId, {\n detachedSince: Date.now(),\n sandboxKey: definition.key(state.ensureCtx),\n })\n } catch (error) {\n state.logger?.warn('sandbox detach record write failed', {\n runId: ctx.runId,\n phase,\n error,\n })\n return false\n }\n // Core's durable delivery sink reads this (see `RunDetachedCapability`) and\n // leaves the run's log OPEN instead of appending a synthetic terminal\n // `RUN_ERROR` and closing it — a terminalized log ends a later attach's replay\n // at the prefix and diverges the takeover's journal replay, which recorded a\n // healthy detached run as `'failed'`.\n provideRunDetached(ctx, true)\n return true\n}\n\n/**\n * Whether an out-of-band cancel has been recorded for this run, in EITHER band.\n * A user pressing Stop and a user closing the tab produce the IDENTICAL\n * connection close, so intent is never inferred from the disconnect itself: it\n * arrives in-process (the abort reason carried the cancel sentinel) or durably\n * (another host recorded it on the run record).\n */\nasync function cancelIntent(\n durability: SandboxRunDurability | undefined,\n runId: string,\n inProcess: boolean,\n): Promise<boolean> {\n if (inProcess) return true\n if (durability === undefined) return false\n // No guard needed here, and one would be dead code: `wasCancelRequested` already\n // answers `false` for a store read that rejects. That matters on this path,\n // because a rejection escaping into `onAbort` would skip BOTH of its branches at\n // once, leaving a sandbox that is neither reclaimable nor destroyed. The test\n // 'DETACHES when the cancel probe REJECTS' pins the composition.\n return wasCancelRequested(durability.runs, runId)\n}\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\n/**\n * Durability seams for a sandboxed run. Both are optional; each independently\n * falls back to a process-lifetime in-memory default, which is correct for a\n * single process but NOT across replicas.\n */\nexport interface SandboxMiddlewareOptions<TOffset extends string = string> {\n /**\n * Durable instance map (which provider sandbox to resume for a key). Pass\n * your own store to make resume survive across processes/replicas.\n *\n * Takes precedence over a store provided on the capability bus (see\n * `provideSandboxInstanceStore`), so the call site wins over ambient wiring.\n */\n instances?: SandboxInstanceStore\n /**\n * Distributed lock serializing resume-or-create for one key. Needed for\n * multi-replica correctness so two concurrent runs don't both create.\n *\n * Prefer `withLocks` from `@tanstack/ai/locks` when other middleware also\n * needs the lock; use this option to scope one to this sandbox. Takes\n * precedence over a bus-provided lock.\n */\n locks?: LockStore\n /**\n * Run lifecycle records. Pair with `durability.adapter` to make a run\n * DETACHABLE: a client disconnect then leaves the agent running and records\n * `detachedSince` instead of destroying the sandbox.\n *\n * Pass the SAME store chat persistence uses (`persistence.stores.runs`) so\n * one record describes the run instead of two that can disagree.\n *\n * Defaults to `undefined`: an app that passes neither this nor `durability`\n * keeps today's destroy-on-disconnect behavior exactly.\n */\n runs?: RunStore\n /**\n * Delivery durability for the run's event log, plus the journal and detach\n * knobs. Requires `runs`; either alone is not durable.\n *\n * `TOffset` is inferred from the adapter passed here, so a branded-cursor\n * backend (`durableStream`) wires without a cast and without the call site\n * ever naming the parameter.\n */\n durability?: SandboxDurabilityOptions<TOffset>\n}\n\n/**\n * Resolve the ensure seams. Precedence is explicit option → capability bus →\n * (in `ensure`) the in-memory fallback. The option wins because it is visible\n * at the call site; the bus remains for platform/framework injection.\n */\nfunction buildEnsureCtx(\n ctx: ChatMiddlewareContext,\n // Narrowed to the two seams it reads rather than taking the whole options\n // object: `SandboxMiddlewareOptions` is now generic in the durability offset,\n // and `SandboxMiddlewareOptions<TOffset>` is not assignable to\n // `SandboxMiddlewareOptions<string>`. Both members here are offset-free, so\n // the narrowing keeps this helper independent of that parameter entirely.\n options: Pick<SandboxMiddlewareOptions, 'instances' | 'locks'> | undefined,\n): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store:\n options?.instances ?? ctx.getOptional(SandboxInstanceStoreCapability),\n locks: options?.locks ?? ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run — but are logged under the `errors` category first, so\n * a throwing hook is observable (matching the run-scoped path in the engine\n * and the behavior the observability docs promise).\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n logger?: InternalLogger,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch (error) {\n // swallowed — one bad hook must not break the run — but logged so the\n // failure isn't invisible.\n logger?.errors('sandbox file hook failed', {\n path: event.path,\n type: event.type,\n error,\n })\n }\n }\n}\n\nexport function withSandbox<TOffset extends string = string>(\n definition: SandboxDefinition,\n options?: SandboxMiddlewareOptions<TOffset>,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`. SandboxDurabilityCapability and\n // DetachableRunCapability are conditional for the same reason (only when\n // `runs` + `durability` are both wired), so they are intentionally NOT\n // declared here either.\n optionalRequires: [SandboxInstanceStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx, options)\n\n // Resolving here (not lazily on the abort path) is what keeps `setup` and\n // `onAbort` on one verdict: the payload the bus carries is the same object\n // the teardown path consults.\n // `TOffset` is passed explicitly: `options` is possibly `undefined` here,\n // so inference has nothing to work from on that branch and would fall\n // back to the `= string` default, re-erecting the very wall this\n // parameter exists to remove.\n const durability = resolveSandboxDurability<TOffset>(options)\n if (durability !== undefined) {\n provideSandboxDurability(ctx, durability)\n // A neutral boolean core owns, so `@tanstack/ai-persistence` can ask\n // \"is this run detachable?\" without depending on this package.\n provideDetachableRun(ctx, true)\n }\n\n // Pull the runtime (and its logger) up front so `baseSha` capture and\n // hook dispatch below can log through the same `sandbox`/`errors`\n // categories the engine uses.\n const runtime = getSandboxRuntime(ctx, { optional: true })\n const logger = runtime?.logger\n\n // REGISTER THE RUN STATE NOW — before `definition.ensure()`, not merely\n // before the end of `setup`.\n //\n // `onAbort` and the disconnect subscriber both need this state, so until\n // this map is populated they are silent no-ops. `ensure` is the LONGEST\n // await in the entire run (create a sandbox, clone a repo — minutes), and it\n // is where the most common disconnect of all lands: a user starts a run and\n // switches away while the UI still says \"starting the sandbox\". Registering\n // after `ensure` returned still left that whole window uncovered.\n //\n // Leaving it uncovered loses every teardown behavior at once: no\n // `detachedSince`/`sandboxKey`, so `listReclaimable` can never surface the\n // run and the reaper can never reclaim it; no `definition.destroy`, so the\n // sandbox leaks; and no detach verdict for core to read.\n //\n // Everything those hooks read is already resolved above: the ensure context\n // (which is all `definition.key` needs), the durability verdict, and the\n // logger. The fields discovered later (`handle`, `watcher`) are ASSIGNED onto\n // this same object as they become available, so the teardown path always\n // sees the most complete state that exists at the moment it runs.\n const state: SandboxRunState = {\n ensureCtx,\n pendingDiffs: [],\n toolHistory: createToolHistoryRecorder(),\n ...(logger ? { logger } : {}),\n ...(durability ? { durability } : {}),\n }\n runState.set(ctx, state)\n\n // MAKE THE RUN FINDABLE BEFORE `ensure`, not after the run finally streams.\n //\n // Chat persistence creates the run record from `onConfig`, which runs after\n // EVERY middleware `setup` — so for the whole of `definition.ensure` (create a\n // sandbox, clone a repo: minutes) the run has no record at all, and\n // `findActiveRun` answers \"no active run\" for a run that is demonstrably\n // starting. Measured: a status sidebar read straight off `findActiveRun`\n // reported `idle` for 6.5 minutes while the sandbox was being built, and a\n // client returning to the thread in that window had nothing to tell it a run\n // was in flight — so it rendered an empty pane instead of \"starting sandbox\".\n //\n // A crash in the same window is worse: no record means `listReclaimable` can\n // never surface the run, so the sandbox leaks with no recovery path.\n //\n // `createOrResume` is idempotent and never resurrects a finished run, so\n // persistence's own later call stays correct and simply finds this record.\n if (durability !== undefined) {\n try {\n await durability.runs.createOrResume({\n runId: ctx.runId,\n threadId: ctx.threadId,\n startedAt: Date.now(),\n })\n } catch (error) {\n // Best-effort: a store blip must not stop a run that is otherwise fine.\n // The run is simply invisible until persistence's own `onConfig` call.\n logger?.warn('sandbox run record pre-create failed', {\n runId: ctx.runId,\n error,\n })\n }\n\n // NO ATTACH MARKER HERE. A joiner does need a chunk in the log before the\n // harness has emitted anything — an empty log fails every joiner's\n // fast-fail (`memoryStream`'s first-chunk deadline, the client's rejoin\n // connect deadline) and flushes no HTTP headers, so a reload during\n // `ensure` reads a live run as gone. Core does it: a fresh durable producer\n // appends `RUN_ACCEPTED_EVENT` before the producer stream is first pulled,\n // for EVERY durable run rather than only sandboxed ones, and never on an\n // attach. A second marker from here would only land mid-stream in a run\n // that is already producing.\n\n // STORE THE USER'S TURN NOW, before `ensure` takes minutes.\n //\n // Chat persistence stores it from `onStart`, which runs after every\n // middleware `setup` — so without this the thread holds NOTHING for the\n // whole sandbox build. Measured: a reload during the build asked the server\n // for the conversation and got `{\"messages\":[],…}`, so the user saw no sign\n // of the message they had just sent, and a second device saw an empty\n // thread.\n //\n // The persistence layer owns WHAT gets stored (see `PendingTurnCapability`):\n // `saveThread` replaces the thread, so deciding the list here would risk\n // deleting the history. Absent when the app wires no persistence, which is\n // simply a run with no transcript to store.\n try {\n await getPendingTurn(ctx, { optional: true })?.snapshot()\n } catch (error) {\n // Best-effort: the run is still worth doing, and `onStart` stores the\n // turn again once setup completes.\n logger?.warn('sandbox pending-turn snapshot failed', {\n runId: ctx.runId,\n error,\n })\n }\n }\n\n // SUBSCRIBE BEFORE `ensure`, for the same reason the state is registered\n // before it: `ensure` is the minutes-wide await a disconnect actually lands\n // in. Core calls back immediately if the socket has already closed, so\n // subscribing here cannot miss a disconnect that beat us to it.\n //\n // This is what makes a durable run SURVIVE losing its viewer. The only route\n // a disconnect previously had into this middleware was the application\n // mirroring `request.signal` into `chat()`'s `abortController` — which aborts\n // the run, so `chat()` returned right after this `setup` and the harness\n // adapter's `chatStream` was never called: the agent in the sandbox we just\n // spent minutes creating was NEVER LAUNCHED, and no takeover could recover it\n // because an agent that never ran wrote no journal to replay.\n if (durability !== undefined && durability.detachOnDisconnect) {\n getRunDisconnect(ctx, { optional: true })?.subscribe(async () => {\n // BOOKKEEPING ONLY — the run is still executing. Deliberately absent:\n // `drainWatcher` (would blind a live agent's file events for the whole\n // remainder) and `definition.destroy` (the run is still using the\n // sandbox). Both belong to the terminal hooks, which still run exactly\n // once afterwards.\n //\n // A run with a cancel already recorded is left alone: that is `onAbort`'s\n // path, and stamping `detachedSince` on a deliberately-stopped run would\n // hand it to the reaper as reclaimable work.\n if (await cancelIntent(durability, ctx.runId, false)) return\n if (\n await recordDetach(definition, state, durability, ctx, 'disconnect')\n ) {\n state.logger?.sandbox(\n 'sandbox run detached on disconnect; the run continues',\n { runId: ctx.runId },\n )\n }\n })\n }\n\n const handle = await definition.ensure(ensureCtx)\n // MUTATE, don't re-`set`: a disconnect that landed during `ensure` already\n // captured this object.\n state.handle = handle\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n // Deliberately placed AFTER `logger` is in scope rather than next to the\n // `provideSandboxDurability` call above — there is no logger to warn\n // through until the runtime has been read.\n //\n // `ensureCtx.locks === undefined` counts as in-memory: `defineSandbox`'s\n // `ensure` falls back to a process-lifetime `InMemoryLockStore` when no\n // lock is wired, so an unwired lock has exactly the deficiency being\n // warned about — it is the MOST in-memory case, not an exempt one.\n if (\n durability !== undefined &&\n (ensureCtx.locks === undefined ||\n ensureCtx.locks instanceof InMemoryLockStore)\n ) {\n logger?.warn(\n 'sandbox durability is wired over an InMemoryLockStore: run claims are ' +\n 'serialized within this process only and the lease never signals loss, ' +\n 'so two hosts can drive one run and duplicate its event log. Use a ' +\n 'distributed LockStore via withLocks for any multi-replica deploy.',\n { runId: ctx.runId },\n )\n }\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) {\n baseSha = shaRes.stdout.trim()\n logger?.sandbox('sandbox git baseline captured', {\n root: watchRoot,\n baseSha,\n })\n } else {\n // Non-zero exit: either not a git repository (non-git workspace) or a\n // repo with no commits (no HEAD). Expected, but it silently degrades\n // every subsequent diff to a full-file add-patch, so surface it\n // under `sandbox` (with stderr) rather than leaving nothing to grep.\n logger?.sandbox('sandbox git baseline unavailable (non-zero exit)', {\n root: watchRoot,\n exitCode: shaRes.exitCode,\n stderr: shaRes.stderr,\n })\n }\n } catch (error) {\n // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''\n // and accessors fall back, but this is a real anomaly, not a plain\n // non-git workspace, so warn.\n logger?.warn('sandbox git baseline capture failed', {\n root: watchRoot,\n error,\n })\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n // THE SAME array the run state already holds, not a fresh one. The watcher\n // callback below closes over this reference, and `drainWatcher` awaits\n // `state.pendingDiffs` — a second array would silently drop every in-flight\n // diff from the teardown drain.\n const pendingDiffs = state.pendingDiffs\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n logger,\n )\n void dispatchDefinitionHooks(hooks, enriched, logger)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch((error: unknown) => {\n logger?.warn('sandbox file diff emit failed', {\n path: event.path,\n error,\n })\n }),\n )\n }\n },\n // Watch the SAME root the enrichment layer relativizes against\n // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`\n // capture). Without this the watcher defaults to `/workspace` while\n // enrichment uses `watchRoot`, so a custom `workspace.root` makes the\n // two look at different directories and git pathspecs break.\n root: watchRoot,\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n logger?.sandbox('sandbox watcher started', {\n root: watchRoot,\n diff: fe.diff,\n })\n }\n\n // MUTATE the object registered above rather than `set`-ing a second one: an\n // abort that landed mid-setup already captured a reference to it (and may\n // already be draining `pendingDiffs`), so replacing the entry would hand the\n // teardown path a different object than the watcher writes into.\n // `pendingDiffs` needs no copying — it IS `state.pendingDiffs`.\n if (watcher) state.watcher = watcher\n },\n\n // Keep the recorded tool history OUT of the request to the model. It is stored\n // history for the next turn, it names tools the provider was never given, and one\n // triage-sized run is hundreds of kilobytes — so replaying it is wasteful at best\n // and rejected at worst. `ctx.messages` keeps it (that is what gets stored and\n // rendered); only `config.messages` loses it.\n onConfig(_ctx, config) {\n const messages = stripObservedToolCalls(config.messages)\n if (messages.length === config.messages.length) return\n return { messages }\n },\n\n // The engine re-syncs `middlewareCtx.messages` from its own array once per agent\n // iteration, which drops whatever the recorder appended during the previous\n // iteration's stream. Restoring it here — AFTER that sync — is what makes a\n // multi-iteration run keep its full history without depending on where this\n // middleware sits relative to persistence in the middleware array.\n onIteration(ctx) {\n runState.get(ctx)?.toolHistory.reconcile(ctx)\n },\n\n // Record the harness's own tool calls as transcript messages. Observe only:\n // returning nothing passes every chunk through untouched.\n onChunk(ctx, chunk) {\n runState.get(ctx)?.toolHistory.observe(chunk, ctx)\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n // Last chance before persistence writes the transcript. Only matters if a\n // config sync landed after the final tool chunk; the recorder is idempotent, so\n // in the normal case this changes nothing.\n state.toolHistory.reconcile(ctx)\n\n await drainWatcher(state, 'finish')\n\n const lifecycle = definition.lifecycle\n\n // `handle` is absent only if `setup` never got past `definition.ensure`, in\n // which case there is no sandbox to snapshot.\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle?.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n // First on BOTH branches: a diff still in flight must be drained whether\n // the sandbox is about to be destroyed or merely detached, or the final\n // file's diff is dropped.\n await drainWatcher(state, 'abort')\n\n const durability = state.durability\n const cancelled = await cancelIntent(\n durability,\n ctx.runId,\n info.cancelRequested === true,\n )\n\n if (\n durability !== undefined &&\n !cancelled &&\n durability.detachOnDisconnect\n ) {\n // DETACH on the teardown path. Reached when the run is aborted for a\n // reason that is NOT an out-of-band cancel while detachable — a genuine\n // stop from elsewhere, or a host going down. The ordinary disconnect is\n // handled by the disconnect subscriber in `setup`, which does not end the\n // run at all.\n //\n // On a failed record write this branch is ABANDONED for the destroy one\n // below, because a rejection here is the worst shape available: the\n // verdict is unpublished, so core terminalizes the log and records a\n // healthy detached run as failed; `detachedSince`/`sandboxKey` are\n // unwritten, so `listReclaimable` can never surface the run and\n // `reapDetachedRuns` can never reclaim it. A DESTROYED sandbox beats an\n // unreachable one — the same reasoning `drainWatcher` applies to its own\n // guarded `stop()`.\n if (await recordDetach(definition, state, durability, ctx, 'abort')) {\n return\n }\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n return\n }\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await drainWatcher(state, 'error')\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition, options?)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the durability seams from\n * {@link SandboxMiddlewareOptions} (or, failing that, a bus-provided\n * SandboxInstanceStoreCapability / LocksCapability, then an in-memory\n * fallback). If `fileEvents` is not false, starts a\n * watcher that dispatches to sandbox-scoped hooks and forwards to the runtime\n * sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport {\n defineChatMiddleware,\n provideDetachableRun,\n provideRunDetached,\n wasCancelRequested,\n} from '@tanstack/ai'\nimport { InMemoryLockStore, LocksCapability } from '@tanstack/ai/locks'\nimport {\n getPendingTurn,\n getRunDisconnect,\n getSandboxRuntime,\n} from '@tanstack/ai/adapter-internals'\nimport {\n SandboxCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport {\n provideSandboxDurability,\n resolveSandboxDurability,\n} from './durability'\nimport { SandboxInstanceStoreCapability } from './instance-store'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport {\n createToolHistoryRecorder,\n stripObservedToolCalls,\n} from './tool-history'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport { resolveHarnessCwd } from './harness-cwd'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n RunStore,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type {\n SandboxDurabilityOptions,\n SandboxRunDurability,\n} from './durability'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { ToolHistoryRecorder } from './tool-history'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n /**\n * OPTIONAL because the state is registered BEFORE `definition.ensure()` is\n * awaited, and `ensure` is the slowest thing in the whole run — cloning a repo\n * into a fresh sandbox is minutes wide. That window is where the most common\n * disconnect of all lands (a user starts a run and switches away while the UI\n * still says \"starting the sandbox\"), so it is the one window the teardown and\n * disconnect hooks most need to be able to act in. Registering only after the\n * handle exists left exactly that window uncovered.\n *\n * Nothing the disconnect path does needs the handle: `detachedSince` and\n * `sandboxKey` come from `ensureCtx`, which is built before `ensure` is called.\n * Only `onFinish`'s snapshot needs it, and that cannot run before `setup` has\n * completed.\n */\n handle?: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n /** Logger captured at setup, so terminal hooks can log watcher teardown. */\n logger?: InternalLogger\n /**\n * Durability resolved once at setup (absent when the run is not durable), so\n * `onAbort` cannot reach a different verdict than the one `setup` published\n * on the capability bus.\n */\n durability?: SandboxRunDurability\n /**\n * Records the harness's own tool calls into the transcript, so a finished run\n * restores its tool cards from the message store instead of only from the (live,\n * rejoin-only) delivery log. See `./tool-history`.\n */\n toolHistory: ToolHistoryRecorder\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/**\n * Stop the watcher and drain any in-flight `diff()` promises before teardown,\n * so the final file's diff isn't dropped when a run finishes/aborts/errors\n * mid-computation. The `pendingDiffs` await is the load-bearing line — without\n * it a deferred diff resolves after the run is gone and its chunk is lost.\n */\nasync function drainWatcher(\n state: SandboxRunState,\n phase: 'finish' | 'abort' | 'error',\n): Promise<void> {\n // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of\n // here, or the caller skips the `definition.destroy(...)` that follows —\n // leaking the sandbox on exactly the abort path that must ALWAYS tear down.\n try {\n await state.watcher?.stop()\n } catch (error) {\n state.logger?.warn('sandbox watcher stop failed', { phase, error })\n }\n await Promise.allSettled(state.pendingDiffs)\n if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })\n}\n\n/**\n * Record the two facts a later attach and the reaper both need, then publish the\n * detach verdict core reads.\n *\n * Shared by the DISCONNECT subscriber registered in `setup` (the run is still\n * going — the normal case) and `onAbort`'s detach branch (the run is being torn\n * down while detachable), so the two can never write a different shape of detach.\n *\n * GUARDED, and reports failure rather than throwing. `update` is a documented\n * no-op for an unknown runId, so a vanished record does not turn teardown into a\n * throw; a genuinely rejecting store is the caller's to react to — `onAbort` falls\n * through to destroying the sandbox, because a DESTROYED sandbox beats an\n * unreachable one, while the disconnect subscriber has nothing to fall back to\n * (the run is alive and still using the sandbox) and simply leaves the verdict\n * unpublished.\n *\n * The verdict is published ONLY on success. Publishing it after a failed record\n * write would leave core holding the log open for a takeover that can never be\n * found, since nothing in the store points at the run.\n */\nasync function recordDetach(\n definition: SandboxDefinition,\n state: SandboxRunState,\n durability: SandboxRunDurability,\n ctx: ChatMiddlewareContext,\n phase: 'disconnect' | 'abort',\n): Promise<boolean> {\n try {\n // The record already exists: `setup` pre-creates it for every durable run\n // BEFORE `ensure`, precisely so this stamp cannot land on a runId the store has\n // never heard of — `RunStore.update` is a documented no-op for an unknown\n // runId, which is how the detach used to be lost silently (measured against the\n // browser repro: `detached_since` and `sandbox_key` both stayed NULL for a run\n // that had genuinely detached). If it has since vanished, that no-op is the\n // correct outcome and this must not throw.\n await durability.runs.update(ctx.runId, {\n detachedSince: Date.now(),\n sandboxKey: definition.key(state.ensureCtx),\n })\n } catch (error) {\n state.logger?.warn('sandbox detach record write failed', {\n runId: ctx.runId,\n phase,\n error,\n })\n return false\n }\n // Core's durable delivery sink reads this (see `RunDetachedCapability`) and\n // leaves the run's log OPEN instead of appending a synthetic terminal\n // `RUN_ERROR` and closing it — a terminalized log ends a later attach's replay\n // at the prefix and diverges the takeover's journal replay, which recorded a\n // healthy detached run as `'failed'`.\n provideRunDetached(ctx, true)\n return true\n}\n\n/**\n * Whether an out-of-band cancel has been recorded for this run, in EITHER band.\n * A user pressing Stop and a user closing the tab produce the IDENTICAL\n * connection close, so intent is never inferred from the disconnect itself: it\n * arrives in-process (the abort reason carried the cancel sentinel) or durably\n * (another host recorded it on the run record).\n */\nasync function cancelIntent(\n durability: SandboxRunDurability | undefined,\n runId: string,\n inProcess: boolean,\n): Promise<boolean> {\n if (inProcess) return true\n if (durability === undefined) return false\n // No guard needed here, and one would be dead code: `wasCancelRequested` already\n // answers `false` for a store read that rejects. That matters on this path,\n // because a rejection escaping into `onAbort` would skip BOTH of its branches at\n // once, leaving a sandbox that is neither reclaimable nor destroyed. The test\n // 'DETACHES when the cancel probe REJECTS' pins the composition.\n return wasCancelRequested(durability.runs, runId)\n}\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\n/**\n * Durability seams for a sandboxed run. Both are optional; each independently\n * falls back to a process-lifetime in-memory default, which is correct for a\n * single process but NOT across replicas.\n */\nexport interface SandboxMiddlewareOptions<TOffset extends string = string> {\n /**\n * Durable instance map (which provider sandbox to resume for a key). Pass\n * your own store to make resume survive across processes/replicas.\n *\n * Takes precedence over a store provided on the capability bus (see\n * `provideSandboxInstanceStore`), so the call site wins over ambient wiring.\n */\n instances?: SandboxInstanceStore\n /**\n * Distributed lock serializing resume-or-create for one key. Needed for\n * multi-replica correctness so two concurrent runs don't both create.\n *\n * Prefer `withLocks` from `@tanstack/ai/locks` when other middleware also\n * needs the lock; use this option to scope one to this sandbox. Takes\n * precedence over a bus-provided lock.\n */\n locks?: LockStore\n /**\n * Run lifecycle records. Pair with `durability.adapter` to make a run\n * DETACHABLE: a client disconnect then leaves the agent running and records\n * `detachedSince` instead of destroying the sandbox.\n *\n * Pass the SAME store chat persistence uses (`persistence.stores.runs`) so\n * one record describes the run instead of two that can disagree.\n *\n * Defaults to `undefined`: an app that passes neither this nor `durability`\n * keeps today's destroy-on-disconnect behavior exactly.\n */\n runs?: RunStore\n /**\n * Delivery durability for the run's event log, plus the journal and detach\n * knobs. Requires `runs`; either alone is not durable.\n *\n * `TOffset` is inferred from the adapter passed here, so a branded-cursor\n * backend (`durableStream`) wires without a cast and without the call site\n * ever naming the parameter.\n */\n durability?: SandboxDurabilityOptions<TOffset>\n}\n\n/**\n * Resolve the ensure seams. Precedence is explicit option → capability bus →\n * (in `ensure`) the in-memory fallback. The option wins because it is visible\n * at the call site; the bus remains for platform/framework injection.\n */\nfunction buildEnsureCtx(\n ctx: ChatMiddlewareContext,\n // Narrowed to the two seams it reads rather than taking the whole options\n // object: `SandboxMiddlewareOptions` is now generic in the durability offset,\n // and `SandboxMiddlewareOptions<TOffset>` is not assignable to\n // `SandboxMiddlewareOptions<string>`. Both members here are offset-free, so\n // the narrowing keeps this helper independent of that parameter entirely.\n options: Pick<SandboxMiddlewareOptions, 'instances' | 'locks'> | undefined,\n): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store:\n options?.instances ?? ctx.getOptional(SandboxInstanceStoreCapability),\n locks: options?.locks ?? ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n adapterName: ctx.provider,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run — but are logged under the `errors` category first, so\n * a throwing hook is observable (matching the run-scoped path in the engine\n * and the behavior the observability docs promise).\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n logger?: InternalLogger,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch (error) {\n // swallowed — one bad hook must not break the run — but logged so the\n // failure isn't invisible.\n logger?.errors('sandbox file hook failed', {\n path: event.path,\n type: event.type,\n error,\n })\n }\n }\n}\n\nexport function withSandbox<TOffset extends string = string>(\n definition: SandboxDefinition,\n options?: SandboxMiddlewareOptions<TOffset>,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`. SandboxDurabilityCapability and\n // DetachableRunCapability are conditional for the same reason (only when\n // `runs` + `durability` are both wired), so they are intentionally NOT\n // declared here either.\n optionalRequires: [SandboxInstanceStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx, options)\n\n // Resolving here (not lazily on the abort path) is what keeps `setup` and\n // `onAbort` on one verdict: the payload the bus carries is the same object\n // the teardown path consults.\n // `TOffset` is passed explicitly: `options` is possibly `undefined` here,\n // so inference has nothing to work from on that branch and would fall\n // back to the `= string` default, re-erecting the very wall this\n // parameter exists to remove.\n const durability = resolveSandboxDurability<TOffset>(options)\n if (durability !== undefined) {\n provideSandboxDurability(ctx, durability)\n // A neutral boolean core owns, so `@tanstack/ai-persistence` can ask\n // \"is this run detachable?\" without depending on this package.\n provideDetachableRun(ctx, true)\n }\n\n // Pull the runtime (and its logger) up front so `baseSha` capture and\n // hook dispatch below can log through the same `sandbox`/`errors`\n // categories the engine uses.\n const runtime = getSandboxRuntime(ctx, { optional: true })\n const logger = runtime?.logger\n\n // REGISTER THE RUN STATE NOW — before `definition.ensure()`, not merely\n // before the end of `setup`.\n //\n // `onAbort` and the disconnect subscriber both need this state, so until\n // this map is populated they are silent no-ops. `ensure` is the LONGEST\n // await in the entire run (create a sandbox, clone a repo — minutes), and it\n // is where the most common disconnect of all lands: a user starts a run and\n // switches away while the UI still says \"starting the sandbox\". Registering\n // after `ensure` returned still left that whole window uncovered.\n //\n // Leaving it uncovered loses every teardown behavior at once: no\n // `detachedSince`/`sandboxKey`, so `listReclaimable` can never surface the\n // run and the reaper can never reclaim it; no `definition.destroy`, so the\n // sandbox leaks; and no detach verdict for core to read.\n //\n // Everything those hooks read is already resolved above: the ensure context\n // (which is all `definition.key` needs), the durability verdict, and the\n // logger. The fields discovered later (`handle`, `watcher`) are ASSIGNED onto\n // this same object as they become available, so the teardown path always\n // sees the most complete state that exists at the moment it runs.\n const state: SandboxRunState = {\n ensureCtx,\n pendingDiffs: [],\n toolHistory: createToolHistoryRecorder(),\n ...(logger ? { logger } : {}),\n ...(durability ? { durability } : {}),\n }\n runState.set(ctx, state)\n\n // MAKE THE RUN FINDABLE BEFORE `ensure`, not after the run finally streams.\n //\n // Chat persistence creates the run record from `onConfig`, which runs after\n // EVERY middleware `setup` — so for the whole of `definition.ensure` (create a\n // sandbox, clone a repo: minutes) the run has no record at all, and\n // `findActiveRun` answers \"no active run\" for a run that is demonstrably\n // starting. Measured: a status sidebar read straight off `findActiveRun`\n // reported `idle` for 6.5 minutes while the sandbox was being built, and a\n // client returning to the thread in that window had nothing to tell it a run\n // was in flight — so it rendered an empty pane instead of \"starting sandbox\".\n //\n // A crash in the same window is worse: no record means `listReclaimable` can\n // never surface the run, so the sandbox leaks with no recovery path.\n //\n // `createOrResume` is idempotent and never resurrects a finished run, so\n // persistence's own later call stays correct and simply finds this record.\n if (durability !== undefined) {\n try {\n await durability.runs.createOrResume({\n runId: ctx.runId,\n threadId: ctx.threadId,\n startedAt: Date.now(),\n })\n } catch (error) {\n // Best-effort: a store blip must not stop a run that is otherwise fine.\n // The run is simply invisible until persistence's own `onConfig` call.\n logger?.warn('sandbox run record pre-create failed', {\n runId: ctx.runId,\n error,\n })\n }\n\n // NO ATTACH MARKER HERE. A joiner does need a chunk in the log before the\n // harness has emitted anything — an empty log fails every joiner's\n // fast-fail (`memoryStream`'s first-chunk deadline, the client's rejoin\n // connect deadline) and flushes no HTTP headers, so a reload during\n // `ensure` reads a live run as gone. Core does it: a fresh durable producer\n // appends `RUN_ACCEPTED_EVENT` before the producer stream is first pulled,\n // for EVERY durable run rather than only sandboxed ones, and never on an\n // attach. A second marker from here would only land mid-stream in a run\n // that is already producing.\n\n // STORE THE USER'S TURN NOW, before `ensure` takes minutes.\n //\n // Chat persistence stores it from `onStart`, which runs after every\n // middleware `setup` — so without this the thread holds NOTHING for the\n // whole sandbox build. Measured: a reload during the build asked the server\n // for the conversation and got `{\"messages\":[],…}`, so the user saw no sign\n // of the message they had just sent, and a second device saw an empty\n // thread.\n //\n // The persistence layer owns WHAT gets stored (see `PendingTurnCapability`):\n // `saveThread` replaces the thread, so deciding the list here would risk\n // deleting the history. Absent when the app wires no persistence, which is\n // simply a run with no transcript to store.\n try {\n await getPendingTurn(ctx, { optional: true })?.snapshot()\n } catch (error) {\n // Best-effort: the run is still worth doing, and `onStart` stores the\n // turn again once setup completes.\n logger?.warn('sandbox pending-turn snapshot failed', {\n runId: ctx.runId,\n error,\n })\n }\n }\n\n // SUBSCRIBE BEFORE `ensure`, for the same reason the state is registered\n // before it: `ensure` is the minutes-wide await a disconnect actually lands\n // in. Core calls back immediately if the socket has already closed, so\n // subscribing here cannot miss a disconnect that beat us to it.\n //\n // This is what makes a durable run SURVIVE losing its viewer. The only route\n // a disconnect previously had into this middleware was the application\n // mirroring `request.signal` into `chat()`'s `abortController` — which aborts\n // the run, so `chat()` returned right after this `setup` and the harness\n // adapter's `chatStream` was never called: the agent in the sandbox we just\n // spent minutes creating was NEVER LAUNCHED, and no takeover could recover it\n // because an agent that never ran wrote no journal to replay.\n if (durability !== undefined && durability.detachOnDisconnect) {\n getRunDisconnect(ctx, { optional: true })?.subscribe(async () => {\n // BOOKKEEPING ONLY — the run is still executing. Deliberately absent:\n // `drainWatcher` (would blind a live agent's file events for the whole\n // remainder) and `definition.destroy` (the run is still using the\n // sandbox). Both belong to the terminal hooks, which still run exactly\n // once afterwards.\n //\n // A run with a cancel already recorded is left alone: that is `onAbort`'s\n // path, and stamping `detachedSince` on a deliberately-stopped run would\n // hand it to the reaper as reclaimable work.\n if (await cancelIntent(durability, ctx.runId, false)) return\n if (\n await recordDetach(definition, state, durability, ctx, 'disconnect')\n ) {\n state.logger?.sandbox(\n 'sandbox run detached on disconnect; the run continues',\n { runId: ctx.runId },\n )\n }\n })\n }\n\n const handle = await definition.ensure(ensureCtx)\n // MUTATE, don't re-`set`: a disconnect that landed during `ensure` already\n // captured this object.\n state.handle = handle\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n // Deliberately placed AFTER `logger` is in scope rather than next to the\n // `provideSandboxDurability` call above — there is no logger to warn\n // through until the runtime has been read.\n //\n // `ensureCtx.locks === undefined` counts as in-memory: `defineSandbox`'s\n // `ensure` falls back to a process-lifetime `InMemoryLockStore` when no\n // lock is wired, so an unwired lock has exactly the deficiency being\n // warned about — it is the MOST in-memory case, not an exempt one.\n if (\n durability !== undefined &&\n (ensureCtx.locks === undefined ||\n ensureCtx.locks instanceof InMemoryLockStore)\n ) {\n logger?.warn(\n 'sandbox durability is wired over an InMemoryLockStore: run claims are ' +\n 'serialized within this process only and the lease never signals loss, ' +\n 'so two hosts can drive one run and duplicate its event log. Use a ' +\n 'distributed LockStore via withLocks for any multi-replica deploy.',\n { runId: ctx.runId },\n )\n }\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) {\n baseSha = shaRes.stdout.trim()\n logger?.sandbox('sandbox git baseline captured', {\n root: watchRoot,\n baseSha,\n })\n } else {\n // Non-zero exit: either not a git repository (non-git workspace) or a\n // repo with no commits (no HEAD). Expected, but it silently degrades\n // every subsequent diff to a full-file add-patch, so surface it\n // under `sandbox` (with stderr) rather than leaving nothing to grep.\n logger?.sandbox('sandbox git baseline unavailable (non-zero exit)', {\n root: watchRoot,\n exitCode: shaRes.exitCode,\n stderr: shaRes.stderr,\n })\n }\n } catch (error) {\n // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''\n // and accessors fall back, but this is a real anomaly, not a plain\n // non-git workspace, so warn.\n logger?.warn('sandbox git baseline capture failed', {\n root: watchRoot,\n error,\n })\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const virtualRoot = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const root = resolveHarnessCwd(handle, virtualRoot)\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n // THE SAME array the run state already holds, not a fresh one. The watcher\n // callback below closes over this reference, and `drainWatcher` awaits\n // `state.pendingDiffs` — a second array would silently drop every in-flight\n // diff from the teardown drain.\n const pendingDiffs = state.pendingDiffs\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n logger,\n )\n void dispatchDefinitionHooks(hooks, enriched, logger)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch((error: unknown) => {\n logger?.warn('sandbox file diff emit failed', {\n path: event.path,\n error,\n })\n }),\n )\n }\n },\n // Watch the SAME root the enrichment layer relativizes against\n // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`\n // capture). Without this the watcher defaults to `/workspace` while\n // enrichment uses `watchRoot`, so a custom `workspace.root` makes the\n // two look at different directories and git pathspecs break.\n root: watchRoot,\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n logger?.sandbox('sandbox watcher started', {\n root: watchRoot,\n diff: fe.diff,\n })\n }\n\n // MUTATE the object registered above rather than `set`-ing a second one: an\n // abort that landed mid-setup already captured a reference to it (and may\n // already be draining `pendingDiffs`), so replacing the entry would hand the\n // teardown path a different object than the watcher writes into.\n // `pendingDiffs` needs no copying — it IS `state.pendingDiffs`.\n if (watcher) state.watcher = watcher\n },\n\n // Keep the recorded tool history OUT of the request to the model. It is stored\n // history for the next turn, it names tools the provider was never given, and one\n // triage-sized run is hundreds of kilobytes — so replaying it is wasteful at best\n // and rejected at worst. `ctx.messages` keeps it (that is what gets stored and\n // rendered); only `config.messages` loses it.\n onConfig(_ctx, config) {\n const messages = stripObservedToolCalls(config.messages)\n if (messages.length === config.messages.length) return\n return { messages }\n },\n\n // The engine re-syncs `middlewareCtx.messages` from its own array once per agent\n // iteration, which drops whatever the recorder appended during the previous\n // iteration's stream. Restoring it here — AFTER that sync — is what makes a\n // multi-iteration run keep its full history without depending on where this\n // middleware sits relative to persistence in the middleware array.\n onIteration(ctx) {\n runState.get(ctx)?.toolHistory.reconcile(ctx)\n },\n\n // Record the harness's own tool calls as transcript messages. Observe only:\n // returning nothing passes every chunk through untouched.\n onChunk(ctx, chunk) {\n runState.get(ctx)?.toolHistory.observe(chunk, ctx)\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n // Last chance before persistence writes the transcript. Only matters if a\n // config sync landed after the final tool chunk; the recorder is idempotent, so\n // in the normal case this changes nothing.\n state.toolHistory.reconcile(ctx)\n\n await drainWatcher(state, 'finish')\n\n const lifecycle = definition.lifecycle\n\n // `handle` is absent only if `setup` never got past `definition.ensure`, in\n // which case there is no sandbox to snapshot.\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle?.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n // First on BOTH branches: a diff still in flight must be drained whether\n // the sandbox is about to be destroyed or merely detached, or the final\n // file's diff is dropped.\n await drainWatcher(state, 'abort')\n\n const durability = state.durability\n const cancelled = await cancelIntent(\n durability,\n ctx.runId,\n info.cancelRequested === true,\n )\n\n if (\n durability !== undefined &&\n !cancelled &&\n durability.detachOnDisconnect\n ) {\n // DETACH on the teardown path. Reached when the run is aborted for a\n // reason that is NOT an out-of-band cancel while detachable — a genuine\n // stop from elsewhere, or a host going down. The ordinary disconnect is\n // handled by the disconnect subscriber in `setup`, which does not end the\n // run at all.\n //\n // On a failed record write this branch is ABANDONED for the destroy one\n // below, because a rejection here is the worst shape available: the\n // verdict is unpublished, so core terminalizes the log and records a\n // healthy detached run as failed; `detachedSince`/`sandboxKey` are\n // unwritten, so `listReclaimable` can never surface the run and\n // `reapDetachedRuns` can never reclaim it. A DESTROYED sandbox beats an\n // unreachable one — the same reasoning `drainWatcher` applies to its own\n // guarded `stop()`.\n if (await recordDetach(definition, state, durability, ctx, 'abort')) {\n return\n }\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n return\n }\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await drainWatcher(state, 'error')\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"mappings":"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"}
|
package/dist/esm/sandbox.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface SandboxEnsureContext {
|
|
|
62
62
|
orgId?: string;
|
|
63
63
|
};
|
|
64
64
|
signal?: AbortSignal;
|
|
65
|
+
/** Harness adapter name (`grok-build`, `claude-code`, `codex`, `opencode`). Optional. */
|
|
66
|
+
adapterName?: string;
|
|
65
67
|
}
|
|
66
68
|
export interface SandboxDefinition {
|
|
67
69
|
readonly id: string;
|
package/dist/esm/sandbox.js
CHANGED
|
@@ -32,6 +32,20 @@ function parseMaxAgeMs(value) {
|
|
|
32
32
|
var DESTROY_TIMEOUT_MS = 60 * 1e3;
|
|
33
33
|
var fallbackStore = new InMemorySandboxInstanceStore();
|
|
34
34
|
var fallbackLocks = new InMemoryLockStore();
|
|
35
|
+
/**
|
|
36
|
+
* Put workspace secrets onto a live handle. Resume and snapshot restore skip
|
|
37
|
+
* bootstrap, so this is the only path that re-injects them after reconnect.
|
|
38
|
+
* Create injects secrets via `provider.create({ env })`, but resume/restore
|
|
39
|
+
* return a handle whose process env is empty unless we set it here. sbx in
|
|
40
|
+
* particular has no Docker Env on resume, so this is the only way secrets
|
|
41
|
+
* come back for that provider.
|
|
42
|
+
*/
|
|
43
|
+
async function applyWorkspaceSecrets(handle, workspace) {
|
|
44
|
+
if (workspace?.secrets === void 0) return;
|
|
45
|
+
const resolved = resolveAllSecrets(workspace.secrets);
|
|
46
|
+
if (Object.keys(resolved).length === 0) return;
|
|
47
|
+
await handle.env.set(resolved);
|
|
48
|
+
}
|
|
35
49
|
function defineSandbox(config) {
|
|
36
50
|
const keyInputFor = (ctx) => ({
|
|
37
51
|
threadId: config.lifecycle?.reuse === "none" ? `${ctx.threadId}:${ctx.runId}` : ctx.threadId,
|
|
@@ -56,6 +70,7 @@ function defineSandbox(config) {
|
|
|
56
70
|
signal: ctx.signal
|
|
57
71
|
});
|
|
58
72
|
if (resumed) {
|
|
73
|
+
await applyWorkspaceSecrets(resumed, config.workspace);
|
|
59
74
|
await store.upsert({
|
|
60
75
|
...existing,
|
|
61
76
|
latestRunId: ctx.runId,
|
|
@@ -71,6 +86,7 @@ function defineSandbox(config) {
|
|
|
71
86
|
env: config.workspace?.secrets !== void 0 ? resolveAllSecrets(config.workspace.secrets) : void 0,
|
|
72
87
|
signal: ctx.signal
|
|
73
88
|
});
|
|
89
|
+
await applyWorkspaceSecrets(restored, config.workspace);
|
|
74
90
|
await store.upsert({
|
|
75
91
|
...existing,
|
|
76
92
|
providerSandboxId: restored.id,
|
|
@@ -86,7 +102,8 @@ function defineSandbox(config) {
|
|
|
86
102
|
workspace: config.workspace,
|
|
87
103
|
policy: config.policy,
|
|
88
104
|
env: config.workspace?.secrets !== void 0 ? resolveAllSecrets(config.workspace.secrets) : void 0,
|
|
89
|
-
signal: ctx.signal
|
|
105
|
+
signal: ctx.signal,
|
|
106
|
+
adapterName: ctx.adapterName
|
|
90
107
|
});
|
|
91
108
|
if (config.workspace) try {
|
|
92
109
|
await bootstrapWorkspace(created, config.workspace, { signal: ctx.signal });
|
package/dist/esm/sandbox.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sandbox.js","names":[],"sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport { InMemorySandboxInstanceStore } from './instance-store'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxInstanceStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n/**\n * Bound for the unfenced teardown `destroy` call (see `destroy` below). Long\n * enough that a slow provider API still completes, short enough that a wedged\n * one cannot pin the process forever.\n */\nconst DESTROY_TIMEOUT_MS = 60 * 1000\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxInstanceStore()\nconst fallbackLocks = new InMemoryLockStore()\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n /*\n * TEARDOWN IS DELIBERATELY NOT FENCED BY `ctx.signal`.\n *\n * `destroy` runs on every teardown path INCLUDING the one caused by that\n * very signal aborting, so forwarding it hands the provider a signal that is\n * already aborted: a provider that honors it does nothing and returns\n * successfully, and `store.delete` below then removes the only pointer to a\n * live, billed sandbox. `SandboxInstanceStore` has no `list` (see the note\n * at the top of `reclaim.ts`), so that sandbox is unreachable from then on.\n *\n * Same reasoning as `close()` never being fenced by the run claim (see\n * `fenceDurability` in `claim.ts`): cleanup must outlive whatever cancelled\n * the work. A fresh controller with its own bounded timeout keeps the call\n * from hanging forever without letting the caller's abort cancel it.\n */\n const teardown = new AbortController()\n const timer = setTimeout(() => teardown.abort(), DESTROY_TIMEOUT_MS)\n try {\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: teardown.signal,\n })\n } finally {\n clearTimeout(timer)\n }\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sandbox.js","names":[],"sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport { InMemorySandboxInstanceStore } from './instance-store'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxInstanceStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n /** Harness adapter name (`grok-build`, `claude-code`, `codex`, `opencode`). Optional. */\n adapterName?: string\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n/**\n * Bound for the unfenced teardown `destroy` call (see `destroy` below). Long\n * enough that a slow provider API still completes, short enough that a wedged\n * one cannot pin the process forever.\n */\nconst DESTROY_TIMEOUT_MS = 60 * 1000\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxInstanceStore()\nconst fallbackLocks = new InMemoryLockStore()\n\n/**\n * Put workspace secrets onto a live handle. Resume and snapshot restore skip\n * bootstrap, so this is the only path that re-injects them after reconnect.\n * Create injects secrets via `provider.create({ env })`, but resume/restore\n * return a handle whose process env is empty unless we set it here. sbx in\n * particular has no Docker Env on resume, so this is the only way secrets\n * come back for that provider.\n */\nasync function applyWorkspaceSecrets(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition | undefined,\n): Promise<void> {\n if (workspace?.secrets === undefined) return\n const resolved = resolveAllSecrets(workspace.secrets)\n if (Object.keys(resolved).length === 0) return\n await handle.env.set(resolved)\n}\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await applyWorkspaceSecrets(resumed, config.workspace)\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await applyWorkspaceSecrets(restored, config.workspace)\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n adapterName: ctx.adapterName,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n /*\n * TEARDOWN IS DELIBERATELY NOT FENCED BY `ctx.signal`.\n *\n * `destroy` runs on every teardown path INCLUDING the one caused by that\n * very signal aborting, so forwarding it hands the provider a signal that is\n * already aborted: a provider that honors it does nothing and returns\n * successfully, and `store.delete` below then removes the only pointer to a\n * live, billed sandbox. `SandboxInstanceStore` has no `list` (see the note\n * at the top of `reclaim.ts`), so that sandbox is unreachable from then on.\n *\n * Same reasoning as `close()` never being fenced by the run claim (see\n * `fenceDurability` in `claim.ts`): cleanup must outlive whatever cancelled\n * the work. A fresh controller with its own bounded timeout keeps the call\n * from hanging forever without letting the caller's abort cancel it.\n */\n const teardown = new AbortController()\n const timer = setTimeout(() => teardown.abort(), DESTROY_TIMEOUT_MS)\n try {\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: teardown.signal,\n })\n } finally {\n clearTimeout(timer)\n }\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA0GA,SAAS,cAAc,OAA+C;CACpE,IAAI,UAAU,KAAA,GAAW,OAAO,KAAA;CAChC,MAAM,YAAY,WAAW,KAAK,KAAK;CACvC,IAAI,WAAW,OAAO,OAAO,UAAU,EAAE,IAAI,KAAK,KAAK;CACvD,MAAM,cAAc,WAAW,KAAK,KAAK;CACzC,IAAI,aAAa,OAAO,OAAO,YAAY,EAAE,IAAI,KAAK;AAExD;;;;;;AAOA,IAAM,qBAAqB,KAAK;AAIhC,IAAM,gBAAgB,IAAI,6BAA6B;AACvD,IAAM,gBAAgB,IAAI,kBAAkB;;;;;;;;;AAU5C,eAAe,sBACb,QACA,WACe;CACf,IAAI,WAAW,YAAY,KAAA,GAAW;CACtC,MAAM,WAAW,kBAAkB,UAAU,OAAO;CACpD,IAAI,OAAO,KAAK,QAAQ,CAAC,CAAC,WAAW,GAAG;CACxC,MAAM,OAAO,IAAI,IAAI,QAAQ;AAC/B;AAEA,SAAgB,cAAc,QAA0C;CACtE,MAAM,eAAe,SAAgD;EACnE,UACE,OAAO,WAAW,UAAU,SACxB,GAAG,IAAI,SAAS,GAAG,IAAI,UACvB,IAAI;EACV,WAAW,OAAO;EAClB,cAAc,OAAO,SAAS;EAC9B,WAAW,OAAO;EAClB,QAAQ,IAAI;CACd;CAEA,MAAM,SAAS,OAAO,QAAsD;EAC1E,MAAM,QAAQ,IAAI,SAAS;EAC3B,MAAM,QAAQ,IAAI,SAAS;EAC3B,MAAM,MAAM,kBAAkB,YAAY,GAAG,CAAC;EAC9C,MAAM,OAAO,OAAO,SAAS,aAAa;EAE1C,OAAO,MAAM,SAAS,WAAW,OAAO,YAAY;GAClD,MAAM,oBACJ,OAAO,WAAW,aAAa,KAAK,YAAY,gBAAgB;GAClE,MAAM,WAAW,cAAc,OAAO,WAAW,cAAc;GAE/D,MAAM,WAAW,MAAM,MAAM,IAAI,GAAG;GACpC,IAAI;QAME,EAFF,aAAa,KAAA,KAAa,KAAK,IAAI,IAAI,SAAS,YAAY,WAEjD;KAEX,MAAM,UAAU,MAAM,OAAO,SAAS,OAAO;MAC3C,IAAI,SAAS;MACb,QAAQ,IAAI;KACd,CAAC;KACD,IAAI,SAAS;MACX,MAAM,sBAAsB,SAAS,OAAO,SAAS;MACrD,MAAM,MAAM,OAAO;OACjB,GAAG;OACH,aAAa,IAAI;OACjB,WAAW,KAAK,IAAI;MACtB,CAAC;MACD,OAAO;KACT;KAEA,IACE,SAAS,oBACT,KAAK,aACL,OAAO,SAAS,iBAChB;MACA,MAAM,WAAW,MAAM,OAAO,SAAS,gBAAgB;OACrD,YAAY,SAAS;OACrB,WAAW,OAAO;OAClB,QAAQ,OAAO;OACf,KACE,OAAO,WAAW,YAAY,KAAA,IAC1B,kBAAkB,OAAO,UAAU,OAAO,IAC1C,KAAA;OACN,QAAQ,IAAI;MACd,CAAC;MACD,MAAM,sBAAsB,UAAU,OAAO,SAAS;MACtD,MAAM,MAAM,OAAO;OACjB,GAAG;OACH,mBAAmB,SAAS;OAC5B,aAAa,IAAI;OACjB,WAAW,KAAK,IAAI;MACtB,CAAC;MACD,OAAO;KACT;IACF;;GAMF,MAAM,UAAU,MAAM,OAAO,SAAS,OAAO;IAG3C,IAAI;IACJ,WAAW,OAAO;IAClB,QAAQ,OAAO;IACf,KACE,OAAO,WAAW,YAAY,KAAA,IAC1B,kBAAkB,OAAO,UAAU,OAAO,IAC1C,KAAA;IACN,QAAQ,IAAI;IACZ,aAAa,IAAI;GACnB,CAAC;GAED,IAAI,OAAO,WACT,IAAI;IACF,MAAM,mBAAmB,SAAS,OAAO,WAAW,EAClD,QAAQ,IAAI,OACd,CAAC;GACH,SAAS,OAAO;IAId,MAAM,QAAQ,QAAQ,CAAC,CAAC,YAAY,CAAC,CAAC;IACtC,MAAM;GACR;GAGF,IAAI;GACJ,IACE,sBAAsB,iBACtB,KAAK,aACL,QAAQ,UAER,oBAAoB,MAAM,QAAQ,SAAS,aAAa,EAAA,CAAG;GAG7D,MAAM,MAAM,OAAO;IACjB;IACA,UAAU,OAAO,SAAS;IAC1B,mBAAmB,QAAQ;IAC3B;IACA,UAAU,IAAI;IACd,aAAa,IAAI;IACjB,WAAW,KAAK,IAAI;GACtB,CAAC;GACD,OAAO;EACT,CAAC;CACH;CAEA,MAAM,UAAU,OAAO,QAA6C;EAClE,MAAM,QAAQ,IAAI,SAAS;EAC3B,MAAM,MAAM,kBAAkB,YAAY,GAAG,CAAC;EAC9C,MAAM,WAAW,MAAM,MAAM,IAAI,GAAG;EACpC,IAAI,CAAC,UAAU;EAgBf,MAAM,WAAW,IAAI,gBAAgB;EACrC,MAAM,QAAQ,iBAAiB,SAAS,MAAM,GAAG,kBAAkB;EACnE,IAAI;GACF,MAAM,OAAO,SAAS,QAAQ;IAC5B,IAAI,SAAS;IACb,QAAQ,SAAS;GACnB,CAAC;EACH,UAAU;GACR,aAAa,KAAK;EACpB;EACA,MAAM,MAAM,OAAO,GAAG;CACxB;CAEA,OAAO;EACL,IAAI,OAAO;EACX,UAAU,OAAO;EACjB,WAAW,OAAO;EAClB,QAAQ,OAAO;EACf,WAAW,OAAO;EAClB,OAAO,OAAO;EACd,YAAY,OAAO;EACnB,MAAM,QAAQ,kBAAkB,YAAY,GAAG,CAAC;EAChD;EACA;CACF;AACF"}
|
package/dist/esm/tool-bridge.js
CHANGED
|
@@ -32,7 +32,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
32
32
|
var BRIDGED_MCP_SERVER_NAME = "tanstack";
|
|
33
33
|
/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */
|
|
34
34
|
function hostForSandbox(provider) {
|
|
35
|
-
return provider === "docker" ? "host.docker.internal" : "127.0.0.1";
|
|
35
|
+
return provider === "docker" || provider === "sbx" ? "host.docker.internal" : "127.0.0.1";
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-bridge.js","names":[],"sources":["../../src/tool-bridge.ts"],"sourcesContent":["/**\n * MCP tool-proxy bridge, shared by all harness adapters.\n *\n * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.\n * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is\n * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the\n * orchestrator process (with its closures / DB / secrets), and the result is\n * returned into the sandbox.\n *\n * The bridge is split into a transport-agnostic CORE and a TRANSPORT:\n * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver\n * (no I/O). It is what makes the bridge portable.\n * - {@link startHostToolBridge} is the `node:http` transport for a long-running\n * host (laptop / CI / Docker orchestrator). It binds loopback unless the\n * sandbox must reach it via `host.docker.internal`, and authenticates with a\n * constant-time bearer check.\n * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the\n * SAME core from its own `fetch` handler — no raw TCP listener — see\n * {@link handleBridgeJsonRpc} and the Cloudflare example.\n */\nimport { createServer } from 'node:http'\nimport { randomBytes, timingSafeEqual } from 'node:crypto'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport type { AddressInfo } from 'node:net'\nimport type { AnyTool } from '@tanstack/ai'\n\n/**\n * Name of the bridged MCP server. The agent sees tools as\n * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix\n * so tool-call events match the names the application registered.\n */\nexport const BRIDGED_MCP_SERVER_NAME = 'tanstack'\n\n/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */\nexport function hostForSandbox(provider: string): string {\n return provider === 'docker' ? 'host.docker.internal' : '127.0.0.1'\n}\n\n/** Result of a permission decision returned to the harness's prompt tool. */\nexport interface PermissionToolResult {\n behavior: 'allow' | 'deny'\n message?: string\n updatedInput?: unknown\n}\n\nexport interface BridgePermission {\n toolName: string\n resolve: (input: {\n tool_name?: string\n input?: unknown\n }) => PermissionToolResult | Promise<PermissionToolResult>\n}\n\nexport interface ToolBridgeCoreOptions {\n /** Runtime context forwarded to each tool's `execute()`. */\n context?: unknown\n /** Abort signal forwarded to each tool's `execute()`. */\n signal?: AbortSignal\n /**\n * Forwarded to each tool's `execute()` so a bridged tool can stream progress /\n * custom events back to the client mid-execution (e.g. code mode's\n * `code_mode:console` logs). Without it those events are silently dropped — the\n * bridge runs out-of-band from the main tool executor, so the executor's own\n * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies\n * one that injects a CUSTOM chunk into its live output stream.\n */\n emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void\n /**\n * Optional permission-prompt tool (e.g. for Claude Code's\n * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool\n * `<name>` whose handler returns the orchestrator's allow/deny decision.\n */\n permission?: BridgePermission\n}\n\n/** An MCP tool descriptor as advertised to the in-sandbox agent. */\nexport interface ToolDescriptor {\n name: string\n description?: string\n inputSchema: { type: 'object'; [key: string]: unknown }\n}\n\n/**\n * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,\n * substituting an empty object schema when it isn't already a JSON-schema object\n * (project rule: a guard, not an `as` cast).\n */\nfunction toObjectSchema(schema: unknown): {\n type: 'object'\n [key: string]: unknown\n} {\n if (\n schema !== null &&\n typeof schema === 'object' &&\n 'type' in schema &&\n schema.type === 'object'\n ) {\n return { ...schema, type: 'object' }\n }\n return { type: 'object', properties: {} }\n}\n\n/** MCP `tools/call` result shape. */\nexport interface ToolCallResult {\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n}\n\n/**\n * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission\n * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a\n * `fetch` handler) wraps this and owns I/O + the bearer check.\n */\nexport interface ToolBridgeCore {\n listTools: () => Array<ToolDescriptor>\n callTool: (name: string, args: unknown) => Promise<ToolCallResult>\n}\n\n/** Build the transport-agnostic bridge core for the given tools. */\nexport function createToolBridgeCore(\n tools: Array<AnyTool>,\n options: ToolBridgeCoreOptions = {},\n): ToolBridgeCore {\n const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))\n const permission = options.permission\n\n const permissionDescriptor: ToolDescriptor | undefined = permission\n ? {\n name: permission.toolName,\n description:\n 'Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.',\n inputSchema: { type: 'object', properties: {} },\n }\n : undefined\n\n return {\n listTools() {\n return [\n ...tools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n inputSchema: toObjectSchema(tool.inputSchema),\n })),\n ...(permissionDescriptor ? [permissionDescriptor] : []),\n ]\n },\n\n async callTool(name, args) {\n if (permission && name === permission.toolName) {\n const result = await permission.resolve(args ?? {})\n return { content: [{ type: 'text', text: JSON.stringify(result) }] }\n }\n const tool = toolsByName.get(name)\n if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)\n try {\n const result: unknown = await tool.execute(args ?? {}, {\n context: options.context,\n abortSignal: options.signal,\n // No-op default so tools that always call it (e.g. code mode) don't\n // crash when the transport didn't wire a sink.\n emitCustomEvent: options.emitCustomEvent ?? (() => {}),\n })\n const text =\n typeof result === 'string' ? result : JSON.stringify(result)\n return { content: [{ type: 'text', text }] }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n isError: true,\n content: [\n { type: 'text', text: `Tool execution failed: ${message}` },\n ],\n }\n }\n },\n }\n}\n\n/**\n * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based\n * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`\n * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC\n * response object, or `null` for a notification (no `id`).\n */\nexport async function handleBridgeJsonRpc(\n core: ToolBridgeCore,\n message: unknown,\n): Promise<unknown> {\n if (message === null || typeof message !== 'object') {\n return {\n jsonrpc: '2.0',\n id: null,\n error: { code: -32600, message: 'Invalid Request' },\n }\n }\n const rpc = message as { id?: unknown; method?: unknown; params?: unknown }\n const id = rpc.id ?? null\n const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })\n switch (rpc.method) {\n case 'initialize':\n return respond({\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n })\n case 'notifications/initialized':\n return null\n case 'tools/list':\n return respond({ tools: core.listTools() })\n case 'tools/call': {\n const params = (rpc.params ?? {}) as {\n name?: unknown\n arguments?: unknown\n }\n if (typeof params.name !== 'string') {\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32602, message: 'Invalid params: name' },\n }\n }\n return respond(await core.callTool(params.name, params.arguments ?? {}))\n }\n default:\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32601, message: 'Method not found' },\n }\n }\n}\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. Length mismatch returns false early (token length is not\n * secret); equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqual(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = Buffer.from(header)\n const b = Buffer.from(`Bearer ${token}`)\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n\nexport interface HostToolBridge {\n /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */\n name: string\n /** URL the SANDBOX uses to reach this bridge. */\n url: string\n /** Per-run bearer token gating the endpoint. */\n token: string\n close: () => Promise<void>\n}\n\nexport interface StartBridgeOptions extends ToolBridgeCoreOptions {\n /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */\n hostForSandbox: string\n /**\n * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is\n * widened to `0.0.0.0` only when the sandbox reaches the host via\n * `host.docker.internal` (a container can't reach the host's loopback).\n */\n bindAddress?: string\n}\n\nfunction buildMcpServer(core: ToolBridgeCore): McpServer {\n const server = new McpServer(\n { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n { capabilities: { tools: {} } },\n )\n server.server.setRequestHandler(ListToolsRequestSchema, () => ({\n tools: core.listTools(),\n }))\n server.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const result = await core.callTool(\n request.params.name,\n request.params.arguments ?? {},\n )\n return {\n content: result.content,\n ...(result.isError ? { isError: true } : {}),\n }\n })\n return server\n}\n\n/**\n * Start the `node:http` MCP tool-proxy bridge for the given tools. For a\n * long-running host (laptop / CI / Docker orchestrator). Serverless/edge\n * orchestrators serve {@link createToolBridgeCore} from their own `fetch`\n * handler instead.\n */\nexport async function startHostToolBridge(\n tools: Array<AnyTool>,\n options: StartBridgeOptions,\n): Promise<HostToolBridge> {\n const token = randomBytes(24).toString('hex')\n const core = createToolBridgeCore(tools, options)\n // Loopback by default; widen to all interfaces only for the Docker bridge,\n // which a container reaches via host.docker.internal (host gateway).\n const bindAddress =\n options.bindAddress ??\n (options.hostForSandbox === 'host.docker.internal'\n ? '0.0.0.0'\n : '127.0.0.1')\n\n const httpServer = createServer((req, res) => {\n void (async () => {\n if (!timingSafeBearerEqual(req.headers['authorization'], token)) {\n res.writeHead(401).end('unauthorized')\n return\n }\n const server = buildMcpServer(core)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n })\n res.on('close', () => {\n void transport.close()\n void server.close()\n })\n await server.connect(transport)\n\n let body = ''\n for await (const chunk of req) body += chunk\n let parsed: unknown\n try {\n parsed = body ? JSON.parse(body) : undefined\n } catch {\n // Malformed agent request → 400, distinct from an internal 500.\n if (!res.headersSent) res.writeHead(400).end('invalid JSON body')\n return\n }\n await transport.handleRequest(req, res, parsed)\n })().catch((error: unknown) => {\n // Log the underlying fault — on the host/Docker path there is no run-log\n // capturing it, so swallowing it leaves an operator with nothing.\n console.error('[tool-bridge] request handler failed:', error)\n if (!res.headersSent) res.writeHead(500).end('bridge error')\n })\n })\n\n await new Promise<void>((resolve) =>\n httpServer.listen(0, bindAddress, resolve),\n )\n const port = (httpServer.address() as AddressInfo).port\n const url = `http://${options.hostForSandbox}:${port}/mcp`\n\n return {\n name: BRIDGED_MCP_SERVER_NAME,\n url,\n token,\n close: () =>\n new Promise<void>((resolve) => httpServer.close(() => resolve())),\n }\n}\n\n/** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */\nexport type ProvisionedBridge = HostToolBridge\n\nexport interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {\n /** Sandbox provider name, to derive how the sandbox reaches the bridge. */\n provider: string\n}\n\n/**\n * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge\n * portable across runtimes: a harness adapter asks its capability context for a\n * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /\n * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable\n * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}\n * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP\n * listener is needed.\n */\nexport interface ToolBridgeProvisioner {\n provision: (\n tools: Array<AnyTool>,\n options: ToolBridgeProvisionOptions,\n ) => Promise<ProvisionedBridge>\n}\n\n/** Default provisioner: a `node:http` listener on the host. */\nexport const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {\n provision(tools, options) {\n const { provider, ...core } = options\n return startHostToolBridge(tools, {\n hostForSandbox: hostForSandbox(provider),\n ...core,\n })\n },\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tool-bridge.js","names":[],"sources":["../../src/tool-bridge.ts"],"sourcesContent":["/**\n * MCP tool-proxy bridge, shared by all harness adapters.\n *\n * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.\n * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is\n * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the\n * orchestrator process (with its closures / DB / secrets), and the result is\n * returned into the sandbox.\n *\n * The bridge is split into a transport-agnostic CORE and a TRANSPORT:\n * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver\n * (no I/O). It is what makes the bridge portable.\n * - {@link startHostToolBridge} is the `node:http` transport for a long-running\n * host (laptop / CI / Docker orchestrator). It binds loopback unless the\n * sandbox must reach it via `host.docker.internal`, and authenticates with a\n * constant-time bearer check.\n * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the\n * SAME core from its own `fetch` handler — no raw TCP listener — see\n * {@link handleBridgeJsonRpc} and the Cloudflare example.\n */\nimport { createServer } from 'node:http'\nimport { randomBytes, timingSafeEqual } from 'node:crypto'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport type { AddressInfo } from 'node:net'\nimport type { AnyTool } from '@tanstack/ai'\n\n/**\n * Name of the bridged MCP server. The agent sees tools as\n * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix\n * so tool-call events match the names the application registered.\n */\nexport const BRIDGED_MCP_SERVER_NAME = 'tanstack'\n\n/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */\nexport function hostForSandbox(provider: string): string {\n return provider === 'docker' || provider === 'sbx'\n ? 'host.docker.internal'\n : '127.0.0.1'\n}\n\n/** Result of a permission decision returned to the harness's prompt tool. */\nexport interface PermissionToolResult {\n behavior: 'allow' | 'deny'\n message?: string\n updatedInput?: unknown\n}\n\nexport interface BridgePermission {\n toolName: string\n resolve: (input: {\n tool_name?: string\n input?: unknown\n }) => PermissionToolResult | Promise<PermissionToolResult>\n}\n\nexport interface ToolBridgeCoreOptions {\n /** Runtime context forwarded to each tool's `execute()`. */\n context?: unknown\n /** Abort signal forwarded to each tool's `execute()`. */\n signal?: AbortSignal\n /**\n * Forwarded to each tool's `execute()` so a bridged tool can stream progress /\n * custom events back to the client mid-execution (e.g. code mode's\n * `code_mode:console` logs). Without it those events are silently dropped — the\n * bridge runs out-of-band from the main tool executor, so the executor's own\n * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies\n * one that injects a CUSTOM chunk into its live output stream.\n */\n emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void\n /**\n * Optional permission-prompt tool (e.g. for Claude Code's\n * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool\n * `<name>` whose handler returns the orchestrator's allow/deny decision.\n */\n permission?: BridgePermission\n}\n\n/** An MCP tool descriptor as advertised to the in-sandbox agent. */\nexport interface ToolDescriptor {\n name: string\n description?: string\n inputSchema: { type: 'object'; [key: string]: unknown }\n}\n\n/**\n * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,\n * substituting an empty object schema when it isn't already a JSON-schema object\n * (project rule: a guard, not an `as` cast).\n */\nfunction toObjectSchema(schema: unknown): {\n type: 'object'\n [key: string]: unknown\n} {\n if (\n schema !== null &&\n typeof schema === 'object' &&\n 'type' in schema &&\n schema.type === 'object'\n ) {\n return { ...schema, type: 'object' }\n }\n return { type: 'object', properties: {} }\n}\n\n/** MCP `tools/call` result shape. */\nexport interface ToolCallResult {\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n}\n\n/**\n * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission\n * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a\n * `fetch` handler) wraps this and owns I/O + the bearer check.\n */\nexport interface ToolBridgeCore {\n listTools: () => Array<ToolDescriptor>\n callTool: (name: string, args: unknown) => Promise<ToolCallResult>\n}\n\n/** Build the transport-agnostic bridge core for the given tools. */\nexport function createToolBridgeCore(\n tools: Array<AnyTool>,\n options: ToolBridgeCoreOptions = {},\n): ToolBridgeCore {\n const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))\n const permission = options.permission\n\n const permissionDescriptor: ToolDescriptor | undefined = permission\n ? {\n name: permission.toolName,\n description:\n 'Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.',\n inputSchema: { type: 'object', properties: {} },\n }\n : undefined\n\n return {\n listTools() {\n return [\n ...tools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n inputSchema: toObjectSchema(tool.inputSchema),\n })),\n ...(permissionDescriptor ? [permissionDescriptor] : []),\n ]\n },\n\n async callTool(name, args) {\n if (permission && name === permission.toolName) {\n const result = await permission.resolve(args ?? {})\n return { content: [{ type: 'text', text: JSON.stringify(result) }] }\n }\n const tool = toolsByName.get(name)\n if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)\n try {\n const result: unknown = await tool.execute(args ?? {}, {\n context: options.context,\n abortSignal: options.signal,\n // No-op default so tools that always call it (e.g. code mode) don't\n // crash when the transport didn't wire a sink.\n emitCustomEvent: options.emitCustomEvent ?? (() => {}),\n })\n const text =\n typeof result === 'string' ? result : JSON.stringify(result)\n return { content: [{ type: 'text', text }] }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n isError: true,\n content: [\n { type: 'text', text: `Tool execution failed: ${message}` },\n ],\n }\n }\n },\n }\n}\n\n/**\n * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based\n * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`\n * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC\n * response object, or `null` for a notification (no `id`).\n */\nexport async function handleBridgeJsonRpc(\n core: ToolBridgeCore,\n message: unknown,\n): Promise<unknown> {\n if (message === null || typeof message !== 'object') {\n return {\n jsonrpc: '2.0',\n id: null,\n error: { code: -32600, message: 'Invalid Request' },\n }\n }\n const rpc = message as { id?: unknown; method?: unknown; params?: unknown }\n const id = rpc.id ?? null\n const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })\n switch (rpc.method) {\n case 'initialize':\n return respond({\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n })\n case 'notifications/initialized':\n return null\n case 'tools/list':\n return respond({ tools: core.listTools() })\n case 'tools/call': {\n const params = (rpc.params ?? {}) as {\n name?: unknown\n arguments?: unknown\n }\n if (typeof params.name !== 'string') {\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32602, message: 'Invalid params: name' },\n }\n }\n return respond(await core.callTool(params.name, params.arguments ?? {}))\n }\n default:\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32601, message: 'Method not found' },\n }\n }\n}\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. Length mismatch returns false early (token length is not\n * secret); equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqual(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = Buffer.from(header)\n const b = Buffer.from(`Bearer ${token}`)\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n\nexport interface HostToolBridge {\n /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */\n name: string\n /** URL the SANDBOX uses to reach this bridge. */\n url: string\n /** Per-run bearer token gating the endpoint. */\n token: string\n close: () => Promise<void>\n}\n\nexport interface StartBridgeOptions extends ToolBridgeCoreOptions {\n /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */\n hostForSandbox: string\n /**\n * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is\n * widened to `0.0.0.0` only when the sandbox reaches the host via\n * `host.docker.internal` (a container can't reach the host's loopback).\n */\n bindAddress?: string\n}\n\nfunction buildMcpServer(core: ToolBridgeCore): McpServer {\n const server = new McpServer(\n { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n { capabilities: { tools: {} } },\n )\n server.server.setRequestHandler(ListToolsRequestSchema, () => ({\n tools: core.listTools(),\n }))\n server.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const result = await core.callTool(\n request.params.name,\n request.params.arguments ?? {},\n )\n return {\n content: result.content,\n ...(result.isError ? { isError: true } : {}),\n }\n })\n return server\n}\n\n/**\n * Start the `node:http` MCP tool-proxy bridge for the given tools. For a\n * long-running host (laptop / CI / Docker orchestrator). Serverless/edge\n * orchestrators serve {@link createToolBridgeCore} from their own `fetch`\n * handler instead.\n */\nexport async function startHostToolBridge(\n tools: Array<AnyTool>,\n options: StartBridgeOptions,\n): Promise<HostToolBridge> {\n const token = randomBytes(24).toString('hex')\n const core = createToolBridgeCore(tools, options)\n // Loopback by default; widen to all interfaces only for the Docker bridge,\n // which a container reaches via host.docker.internal (host gateway).\n const bindAddress =\n options.bindAddress ??\n (options.hostForSandbox === 'host.docker.internal'\n ? '0.0.0.0'\n : '127.0.0.1')\n\n const httpServer = createServer((req, res) => {\n void (async () => {\n if (!timingSafeBearerEqual(req.headers['authorization'], token)) {\n res.writeHead(401).end('unauthorized')\n return\n }\n const server = buildMcpServer(core)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n })\n res.on('close', () => {\n void transport.close()\n void server.close()\n })\n await server.connect(transport)\n\n let body = ''\n for await (const chunk of req) body += chunk\n let parsed: unknown\n try {\n parsed = body ? JSON.parse(body) : undefined\n } catch {\n // Malformed agent request → 400, distinct from an internal 500.\n if (!res.headersSent) res.writeHead(400).end('invalid JSON body')\n return\n }\n await transport.handleRequest(req, res, parsed)\n })().catch((error: unknown) => {\n // Log the underlying fault — on the host/Docker path there is no run-log\n // capturing it, so swallowing it leaves an operator with nothing.\n console.error('[tool-bridge] request handler failed:', error)\n if (!res.headersSent) res.writeHead(500).end('bridge error')\n })\n })\n\n await new Promise<void>((resolve) =>\n httpServer.listen(0, bindAddress, resolve),\n )\n const port = (httpServer.address() as AddressInfo).port\n const url = `http://${options.hostForSandbox}:${port}/mcp`\n\n return {\n name: BRIDGED_MCP_SERVER_NAME,\n url,\n token,\n close: () =>\n new Promise<void>((resolve) => httpServer.close(() => resolve())),\n }\n}\n\n/** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */\nexport type ProvisionedBridge = HostToolBridge\n\nexport interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {\n /** Sandbox provider name, to derive how the sandbox reaches the bridge. */\n provider: string\n}\n\n/**\n * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge\n * portable across runtimes: a harness adapter asks its capability context for a\n * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /\n * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable\n * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}\n * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP\n * listener is needed.\n */\nexport interface ToolBridgeProvisioner {\n provision: (\n tools: Array<AnyTool>,\n options: ToolBridgeProvisionOptions,\n ) => Promise<ProvisionedBridge>\n}\n\n/** Default provisioner: a `node:http` listener on the host. */\nexport const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {\n provision(tools, options) {\n const { provider, ...core } = options\n return startHostToolBridge(tools, {\n hostForSandbox: hostForSandbox(provider),\n ...core,\n })\n },\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-sandbox",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,13 +44,23 @@
|
|
|
44
44
|
"src",
|
|
45
45
|
"skills"
|
|
46
46
|
],
|
|
47
|
+
"nx": {
|
|
48
|
+
"targets": {
|
|
49
|
+
"test:types": {
|
|
50
|
+
"dependsOn": [
|
|
51
|
+
"build",
|
|
52
|
+
"^build"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
47
57
|
"dependencies": {
|
|
48
58
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
49
59
|
},
|
|
50
60
|
"peerDependencies": {
|
|
51
61
|
"@ngrok/ngrok": "^1.0.0",
|
|
52
62
|
"vitest": "^4.1.10",
|
|
53
|
-
"@tanstack/ai": "^0.
|
|
63
|
+
"@tanstack/ai": "^0.44.0"
|
|
54
64
|
},
|
|
55
65
|
"peerDependenciesMeta": {
|
|
56
66
|
"@ngrok/ngrok": {
|
|
@@ -64,7 +74,7 @@
|
|
|
64
74
|
"@ngrok/ngrok": "^1.7.0",
|
|
65
75
|
"@vitest/coverage-v8": "4.0.14",
|
|
66
76
|
"vitest": "^4.1.10",
|
|
67
|
-
"@tanstack/ai": "0.
|
|
77
|
+
"@tanstack/ai": "0.44.0"
|
|
68
78
|
},
|
|
69
79
|
"scripts": {
|
|
70
80
|
"build": "vite build",
|
|
@@ -189,8 +189,13 @@ Providers without snapshot support skip the step silently.
|
|
|
189
189
|
|
|
190
190
|
- `localProcessSandbox()` — runs on the host (no isolation; dev loop only).
|
|
191
191
|
- `dockerSandbox({ image })` — isolated container; snapshots, fork, resume-by-id.
|
|
192
|
+
- `daytonaSandbox({ apiKey, snapshot, autoStopInterval, ephemeral })` —
|
|
193
|
+
Daytona cloud sandbox; snapshots after setup; resume starts stopped or
|
|
194
|
+
archived sandboxes. `/workspace` maps to `/home/daytona/workspace`. Setup
|
|
195
|
+
that installs packages must use `sudo -n` (do not deny `sudo *`). See
|
|
196
|
+
`docs/sandbox/providers.md` for network and secret injection details.
|
|
192
197
|
|
|
193
|
-
|
|
198
|
+
All implement the same `SandboxHandle`: `fs` (read/write/list/mkdir/remove/
|
|
194
199
|
rename/exists), `git` (clone/status/add/commit/push/pull/branch), `process`
|
|
195
200
|
(`exec` + duplex `spawn`), `ports.connect(port)`, `env.set`, optional
|
|
196
201
|
`snapshot()`/`fork()`, `destroy()`. Providers advertise support via
|
|
@@ -202,18 +207,18 @@ rename/exists), `git` (clone/status/add/commit/push/pull/branch), `process`
|
|
|
202
207
|
```typescript
|
|
203
208
|
import { defineSandboxPolicy } from '@tanstack/ai-sandbox'
|
|
204
209
|
|
|
210
|
+
// Headless Grok Build / Codex: stay on auto-approve. Isolation is the
|
|
211
|
+
// outer sandbox (Docker, Daytona, …), not commands.deny on this policy.
|
|
205
212
|
const policy = defineSandboxPolicy({
|
|
206
|
-
|
|
207
|
-
allow: ['pnpm test'],
|
|
208
|
-
ask: ['curl *'],
|
|
209
|
-
deny: ['sudo *', 'rm -rf *'],
|
|
210
|
-
},
|
|
211
|
-
capabilities: { fileWrite: 'allow', network: 'ask' },
|
|
212
|
-
default: 'ask', // deny > ask > allow
|
|
213
|
+
default: 'allow',
|
|
213
214
|
})
|
|
214
215
|
// pass to defineSandbox({ policy }); harness adapters map it to native permissions
|
|
215
216
|
```
|
|
216
217
|
|
|
218
|
+
Claude Code can use `default: 'ask'` plus allow/ask/deny lists. Use Claude Code
|
|
219
|
+
when you need command-level deny. Provider privilege rules (non-root users,
|
|
220
|
+
network block at create) live in `docs/sandbox/providers.md`.
|
|
221
|
+
|
|
217
222
|
## Lifecycle & resume
|
|
218
223
|
|
|
219
224
|
`reuse: 'thread'` resumes one sandbox per `threadId`; the compound key folds in
|
package/src/agents-file.ts
CHANGED
|
@@ -43,6 +43,71 @@ export function resolveGitSkillDir(
|
|
|
43
43
|
return `${root}/.tanstack-skills/${basename}`
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A folder that contains `SKILL.md`, ready to project under a harness skills dir. */
|
|
47
|
+
export interface DiscoveredSkillDir {
|
|
48
|
+
name: string
|
|
49
|
+
dir: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SKILL_FILE = 'SKILL.md'
|
|
53
|
+
const SKIP_DIR_NAMES = new Set(['.git', 'node_modules'])
|
|
54
|
+
const MAX_SKILL_WALK_DEPTH = 6
|
|
55
|
+
|
|
56
|
+
function basenameOf(path: string): string {
|
|
57
|
+
const segments = path.split('/').filter((segment) => segment !== '')
|
|
58
|
+
return segments[segments.length - 1] ?? path
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find every skill folder under a cloned `gitSkill` repo.
|
|
63
|
+
*
|
|
64
|
+
* A skill folder is a directory that contains `SKILL.md`. Nested packs
|
|
65
|
+
* (`skills/foo/SKILL.md`) are returned as `{ name: 'foo', dir: '…/skills/foo' }`.
|
|
66
|
+
* A flat clone with `SKILL.md` at the root is returned as one entry named
|
|
67
|
+
* after the clone. If no `SKILL.md` is found, the clone itself is returned
|
|
68
|
+
* so existing basename projection still works.
|
|
69
|
+
*/
|
|
70
|
+
export async function discoverSkillDirs(
|
|
71
|
+
handle: SandboxHandle,
|
|
72
|
+
cloneDir: string,
|
|
73
|
+
): Promise<Array<DiscoveredSkillDir>> {
|
|
74
|
+
const found: Array<DiscoveredSkillDir> = []
|
|
75
|
+
await walkSkillDirs(handle, cloneDir, found, 0)
|
|
76
|
+
if (found.length === 0) {
|
|
77
|
+
return [{ name: basenameOf(cloneDir), dir: cloneDir }]
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function walkSkillDirs(
|
|
83
|
+
handle: SandboxHandle,
|
|
84
|
+
dir: string,
|
|
85
|
+
found: Array<DiscoveredSkillDir>,
|
|
86
|
+
depth: number,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
if (depth > MAX_SKILL_WALK_DEPTH) return
|
|
89
|
+
let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>
|
|
90
|
+
try {
|
|
91
|
+
entries = await handle.fs.list(dir)
|
|
92
|
+
} catch {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const hasSkill = entries.some(
|
|
96
|
+
(entry) =>
|
|
97
|
+
entry.type === 'file' &&
|
|
98
|
+
entry.name.toLowerCase() === SKILL_FILE.toLowerCase(),
|
|
99
|
+
)
|
|
100
|
+
if (hasSkill) {
|
|
101
|
+
found.push({ name: basenameOf(dir), dir })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (entry.type !== 'dir') continue
|
|
106
|
+
if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue
|
|
107
|
+
await walkSkillDirs(handle, entry.path, found, depth + 1)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
46
111
|
/** Format workspace scripts as a `## Workspace scripts` markdown section. */
|
|
47
112
|
export function formatWorkspaceScriptsSection(
|
|
48
113
|
scripts: Record<string, string>,
|
package/src/bootstrap.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* here — that's each adapter's `projectWorkspace()` hook, since the format
|
|
9
9
|
* differs per harness.
|
|
10
10
|
*/
|
|
11
|
+
import { resolveHarnessCwd } from './harness-cwd'
|
|
11
12
|
import { buildSetupPlan } from './setup-plan'
|
|
12
13
|
import { createBootstrapShell } from './shell'
|
|
13
14
|
import {
|
|
@@ -98,7 +99,10 @@ export async function bootstrapWorkspace(
|
|
|
98
99
|
const url = skill.repo.startsWith('http')
|
|
99
100
|
? skill.repo
|
|
100
101
|
: `https://github.com/${skill.repo}.git`
|
|
101
|
-
const dir =
|
|
102
|
+
const dir = resolveHarnessCwd(
|
|
103
|
+
handle,
|
|
104
|
+
skill.into ?? resolveGitSkillDir(root, skill),
|
|
105
|
+
)
|
|
102
106
|
const auth =
|
|
103
107
|
skill.secret !== undefined && workspace.secrets !== undefined
|
|
104
108
|
? { token: resolveSecret(workspace.secrets, skill.secret) }
|
package/src/contracts.ts
CHANGED
|
@@ -30,19 +30,21 @@ export interface SandboxCapabilities {
|
|
|
30
30
|
backgroundProcesses: boolean
|
|
31
31
|
/**
|
|
32
32
|
* A spawned process exposes a writable host→process stdin
|
|
33
|
-
* ({@link SpawnHandle.stdin}). `true` for host
|
|
34
|
-
*
|
|
35
|
-
* harness adapters that feed a prompt over
|
|
36
|
-
* file + shell redirection.
|
|
33
|
+
* ({@link SpawnHandle.stdin}). `true` for host (`localProcessSandbox`).
|
|
34
|
+
* `false` for Docker container, Docker Sandboxes (`sbx`), Daytona, Vercel,
|
|
35
|
+
* and Cloudflare. When `false`, harness adapters that feed a prompt over
|
|
36
|
+
* stdin must instead deliver it via a file + shell redirection.
|
|
37
37
|
*/
|
|
38
38
|
writableStdin: boolean
|
|
39
39
|
/**
|
|
40
40
|
* A spawned process can be forcibly terminated via {@link SpawnHandle.kill}
|
|
41
41
|
* and aborted mid-flight via the {@link ProcessOptions.signal} passed to
|
|
42
|
-
* {@link SandboxProcess.spawn}. `true` for host
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* {@link SandboxProcess.spawn}. `true` for host and Docker container.
|
|
43
|
+
* `false` for Docker Sandboxes (`sbx`) until measured, and for Daytona,
|
|
44
|
+
* Vercel, and Cloudflare. Those providers implement `kill()` as a no-op or
|
|
45
|
+
* have not been measured yet, so a long-running follower process
|
|
46
|
+
* (e.g. `tail -f`) started there can never be stopped by the caller, only
|
|
47
|
+
* polled and abandoned.
|
|
46
48
|
* Callers MUST branch on this before relying on `kill`/abort to reclaim a
|
|
47
49
|
* background process: a bring-your-own provider that omits it would
|
|
48
50
|
* otherwise be silently treated as killable, leaking an unstoppable process
|
|
@@ -220,6 +222,8 @@ export interface SandboxCreateInput {
|
|
|
220
222
|
policy?: SandboxPolicy
|
|
221
223
|
env?: Record<string, string>
|
|
222
224
|
signal?: AbortSignal
|
|
225
|
+
/** Harness adapter name. Optional. Providers that do not use it ignore it. */
|
|
226
|
+
adapterName?: string
|
|
223
227
|
}
|
|
224
228
|
|
|
225
229
|
/** Input passed to {@link SandboxProvider.resume}. */
|
package/src/git-exec.ts
CHANGED
|
@@ -72,6 +72,13 @@ export function createExecBackedGit(
|
|
|
72
72
|
? ''
|
|
73
73
|
: `--depth ${resolvedDepth} --single-branch `
|
|
74
74
|
|
|
75
|
+
// `git clone` does not create missing parents. gitSkill clones into
|
|
76
|
+
// `<root>/.tanstack-skills/<name>`, so create that parent first.
|
|
77
|
+
const parentSlash = target.lastIndexOf('/')
|
|
78
|
+
if (parentSlash > 0) {
|
|
79
|
+
await process.exec(`mkdir -p ${q(target.slice(0, parentSlash))}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
if (auth?.token) {
|
|
76
83
|
await process.exec(
|
|
77
84
|
`git -c credential.helper=${q(CREDENTIAL_HELPER)} clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,
|
package/src/index.ts
CHANGED
|
@@ -130,9 +130,11 @@ export type { BootstrapResult } from './bootstrap'
|
|
|
130
130
|
export {
|
|
131
131
|
writeAgentsFile,
|
|
132
132
|
resolveGitSkillDir,
|
|
133
|
+
discoverSkillDirs,
|
|
133
134
|
formatWorkspaceScriptsSection,
|
|
134
135
|
mergeAgentsContent,
|
|
135
136
|
} from './agents-file'
|
|
137
|
+
export type { DiscoveredSkillDir } from './agents-file'
|
|
136
138
|
|
|
137
139
|
// Exec-backed git helper (for providers without native git)
|
|
138
140
|
export { createExecBackedGit } from './git-exec'
|
package/src/middleware.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
} from './tool-history'
|
|
49
49
|
import { watchWorkspace } from './watch'
|
|
50
50
|
import { DEFAULT_WORKSPACE_ROOT } from './bootstrap'
|
|
51
|
+
import { resolveHarnessCwd } from './harness-cwd'
|
|
51
52
|
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
52
53
|
import type { LockStore } from '@tanstack/ai/locks'
|
|
53
54
|
import type {
|
|
@@ -293,6 +294,7 @@ function buildEnsureCtx(
|
|
|
293
294
|
locks: options?.locks ?? ctx.getOptional(LocksCapability),
|
|
294
295
|
tenant: tenantFrom(ctx.context),
|
|
295
296
|
signal: ctx.signal,
|
|
297
|
+
adapterName: ctx.provider,
|
|
296
298
|
}
|
|
297
299
|
}
|
|
298
300
|
|
|
@@ -570,7 +572,8 @@ export function withSandbox<TOffset extends string = string>(
|
|
|
570
572
|
|
|
571
573
|
const workspace = definition.workspace
|
|
572
574
|
if (workspace !== undefined) {
|
|
573
|
-
const
|
|
575
|
+
const virtualRoot = workspace.root ?? DEFAULT_WORKSPACE_ROOT
|
|
576
|
+
const root = resolveHarnessCwd(handle, virtualRoot)
|
|
574
577
|
const workspaceHash = computeWorkspaceHash(workspace)
|
|
575
578
|
const secrets = workspace.secrets
|
|
576
579
|
provideWorkspaceProjection(ctx, {
|
package/src/sandbox.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface SandboxEnsureContext {
|
|
|
76
76
|
locks?: LockStore
|
|
77
77
|
tenant?: { userId?: string; orgId?: string }
|
|
78
78
|
signal?: AbortSignal
|
|
79
|
+
/** Harness adapter name (`grok-build`, `claude-code`, `codex`, `opencode`). Optional. */
|
|
80
|
+
adapterName?: string
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export interface SandboxDefinition {
|
|
@@ -123,6 +125,24 @@ const DESTROY_TIMEOUT_MS = 60 * 1000
|
|
|
123
125
|
const fallbackStore = new InMemorySandboxInstanceStore()
|
|
124
126
|
const fallbackLocks = new InMemoryLockStore()
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Put workspace secrets onto a live handle. Resume and snapshot restore skip
|
|
130
|
+
* bootstrap, so this is the only path that re-injects them after reconnect.
|
|
131
|
+
* Create injects secrets via `provider.create({ env })`, but resume/restore
|
|
132
|
+
* return a handle whose process env is empty unless we set it here. sbx in
|
|
133
|
+
* particular has no Docker Env on resume, so this is the only way secrets
|
|
134
|
+
* come back for that provider.
|
|
135
|
+
*/
|
|
136
|
+
async function applyWorkspaceSecrets(
|
|
137
|
+
handle: SandboxHandle,
|
|
138
|
+
workspace: WorkspaceDefinition | undefined,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
if (workspace?.secrets === undefined) return
|
|
141
|
+
const resolved = resolveAllSecrets(workspace.secrets)
|
|
142
|
+
if (Object.keys(resolved).length === 0) return
|
|
143
|
+
await handle.env.set(resolved)
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
export function defineSandbox(config: SandboxConfig): SandboxDefinition {
|
|
127
147
|
const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({
|
|
128
148
|
threadId:
|
|
@@ -160,6 +180,7 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
|
|
|
160
180
|
signal: ctx.signal,
|
|
161
181
|
})
|
|
162
182
|
if (resumed) {
|
|
183
|
+
await applyWorkspaceSecrets(resumed, config.workspace)
|
|
163
184
|
await store.upsert({
|
|
164
185
|
...existing,
|
|
165
186
|
latestRunId: ctx.runId,
|
|
@@ -183,6 +204,7 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
|
|
|
183
204
|
: undefined,
|
|
184
205
|
signal: ctx.signal,
|
|
185
206
|
})
|
|
207
|
+
await applyWorkspaceSecrets(restored, config.workspace)
|
|
186
208
|
await store.upsert({
|
|
187
209
|
...existing,
|
|
188
210
|
providerSandboxId: restored.id,
|
|
@@ -208,6 +230,7 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
|
|
|
208
230
|
? resolveAllSecrets(config.workspace.secrets)
|
|
209
231
|
: undefined,
|
|
210
232
|
signal: ctx.signal,
|
|
233
|
+
adapterName: ctx.adapterName,
|
|
211
234
|
})
|
|
212
235
|
|
|
213
236
|
if (config.workspace) {
|
package/src/tool-bridge.ts
CHANGED
|
@@ -38,7 +38,9 @@ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
|
|
|
38
38
|
|
|
39
39
|
/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */
|
|
40
40
|
export function hostForSandbox(provider: string): string {
|
|
41
|
-
return provider === 'docker'
|
|
41
|
+
return provider === 'docker' || provider === 'sbx'
|
|
42
|
+
? 'host.docker.internal'
|
|
43
|
+
: '127.0.0.1'
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** Result of a permission decision returned to the harness's prompt tool. */
|