@tanstack/ai-sandbox 0.3.1 → 0.3.3
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/journal-sweep.js +3 -2
- package/dist/esm/journal-sweep.js.map +1 -1
- package/dist/esm/middleware.js +5 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/reap.js +2 -1
- package/dist/esm/reap.js.map +1 -1
- package/dist/esm/sandbox.d.ts +2 -0
- package/dist/esm/sandbox.js +19 -2
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/shell.js +5 -4
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.js +4 -2
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/testkit/journal-conformance.js +6 -3
- package/dist/esm/testkit/journal-conformance.js.map +1 -1
- package/dist/esm/testkit/reaper-conformance.js +13 -8
- package/dist/esm/testkit/reaper-conformance.js.map +1 -1
- package/dist/esm/testkit/takeover-conformance.js +4 -3
- package/dist/esm/testkit/takeover-conformance.js.map +1 -1
- package/dist/esm/tool-bridge.js +1 -1
- package/dist/esm/tool-bridge.js.map +1 -1
- package/package.json +14 -4
- 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":";;;;;;;;;;;;;;;;AAsBA,IAAM,YAA6D;CACjE,MAAM;CACN,MAAM;CACN,KAAK;CACL,KAAK;AACP;AAEA,IAAa,yBAAyB;;AAGtC,eAAsB,qBACpB,QACA,WACA,MACsD;CACtD,MAAM,KAAK,UAAU,kBAAkB;CACvC,IAAI,OAAO,QAAQ,OAAO;CAC1B,KAAK,MAAM,CAAC,SAAS,aAAa,OAAO,QAAQ,SAAS,GAGxD,IAAI,MAAM,OAAO,GAAG,OAAO,GAAG,KAAK,GAAG,UAAU,GAAG,OAAO;AAG9D;;;;;;AAYA,eAAsB,mBACpB,QACA,WACA,UAAoC,CAAC,GACX;CAC1B,MAAM,OAAO,UAAU,QAAA;CAGvB,IAAI,UAAU,YAAY,KAAA,GAAW;EACnC,MAAM,WAAW,kBAAkB,UAAU,OAAO;EACpD,IAAI,OAAO,KAAK,QAAQ,CAAC,CAAC,SAAS,GACjC,MAAM,OAAO,IAAI,IAAI,QAAQ;CAEjC;CAMA,IAAI,UAAU,OAAO,SAAS,OAExB;MAAA,CAAC,MADuB,OAAO,GAAG,OAAO,GAAG,KAAK,MAAM,GAEzD,MAAM,OAAO,IAAI,MAAM;GACrB,KAAK,UAAU,OAAO;GACtB,KAAK,UAAU,OAAO;GACtB,MAAM,UAAU,OAAO;GACvB,GAAI,UAAU,OAAO,UAAU,KAAA,IAC3B,EAAE,OAAO,UAAU,OAAO,MAAM,IAChC,CAAC;EACP,CAAC;CAAA;CASL,MAAM,SAAS,UAAU,UAAU,CAAC;CACpC,KAAK,MAAM,SAAS,QAClB,IAAI,MAAM,SAAS,OAAO;EACxB,MAAM,MAAM,MAAM,KAAK,WAAW,MAAM,IACpC,MAAM,OACN,sBAAsB,MAAM,KAAK;EACrC,MAAM,MAAM,kBACV,QACA,MAAM,QAAQ,mBAAmB,MAAM,KAAK,CAC9C;EACA,MAAM,OACJ,MAAM,WAAW,KAAA,KAAa,UAAU,YAAY,KAAA,IAChD,EAAE,OAAO,cAAc,UAAU,SAAS,MAAM,MAAM,EAAE,IACxD,KAAA;EACN,MAAM,OAAO,IAAI,MAAM;GACrB;GACA;GACA,GAAI,SAAS,KAAA,IAAY,EAAE,KAAK,IAAI,CAAC;GACrC,OAAO;EACT,CAAC;CACH;CAMF,IAAI;CACJ,IACE,UAAU,iBAAiB,KAAA,KAC3B,UAAU,aAAa,SAAS,GAEhC,gBAAgB,UAAU;MACrB;EACL,MAAM,kBAAkB,OAAO,MAC5B,MACC,EAAE,SAAS,UAAU,EAAE,SAAS,WACpC;EACA,IAAI,oBAAoB,KAAA,GACtB,gBAAgB,gBAAgB;CAEpC;CACA,gBAAgB,mBAAmB,eAAe,UAAU,OAAO;CACnE,IAAI,kBAAkB,KAAA,GACpB,MAAM,gBAAgB,QAAQ,MAAM,aAAa;CAInD,KAAK,MAAM,SAAS,QAClB,IAAI,MAAM,SAAS,UAAU,MAAM,SAAS,aAC1C,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,QAAQ,MAAM,OAAO;CAIhE,MAAM,iBAAiB,MAAM,qBAAqB,QAAQ,WAAW,IAAI;CAKzE,MAAM,WAA0B,CAAC;CACjC,MAAM,OAAO,eAAe,UAAU,KAAK;CAC3C,IAAI,KAAK,SAAS,GAAG;EACnB,MAAM,QAAQ,MAAM,qBAAqB,QAAQ,EAAE,KAAK,KAAK,CAAC;EAC9D,IAAI;GACF,KAAK,MAAM,SAAS,MAClB,IAAI,MAAM,SAAS,UAAU;IAC3B,MAAM,SAAS,MAAM,MAAM,IAAI,MAAM,OAAO;IAC5C,IAAI,OAAO,aAAa,GAAG;KACzB,MAAM,OAAO,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,KAAK;KAC7C,MAAM,IAAI,MACR,sBAAsB,MAAM,QAAQ,SAAS,OAAO,SAAS,GAAG,OAAO,KAAK,SAAS,IACvF;IACF;IACA,SAAS,KAAK,MAAM,OAAO;GAC7B,OAAO;IACL,MAAM,EAAE,KAAK,QAAQ,MAAM,MAAM,UAAU;IAY3C,MAAM,UAAS,MAXO,QAAQ,IAC5B,MAAM,SAAS,KAAK,YAClB,OAAO,QACJ,KAAK,SAAS;KACb;KACA;KACA,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC;IACrD,CAAC,CAAC,CACD,MAAM,SAAS;KAAE;KAAS;IAAI,EAAE,CACrC,CACF,EAAA,CACuB,MAAM,UAAU,MAAM,IAAI,aAAa,CAAC;IAC/D,IAAI,WAAW,KAAA,GAAW;KACxB,MAAM,OAAO,GAAG,OAAO,IAAI,OAAO,IAAI,OAAO,IAAI,SAC9C,KAAK,CAAC,CACN,MAAM,KAAK;KACd,MAAM,IAAI,MACR,sBAAsB,OAAO,QAAQ,SAAS,OAAO,IAAI,SAAS,GAAG,OAAO,KAAK,SAAS,IAC5F;IACF;IACA,SAAS,KAAK,GAAG,MAAM,QAAQ;GACjC;EAEJ,UAAU;GACR,MAAM,MAAM,QAAQ;EACtB;CACF;CAEA,OAAO;EAAE;EAAgB;CAAS;AACpC"}
|
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 };
|
|
@@ -80,7 +80,7 @@ import { isTerminalRunStatus } from "@tanstack/ai";
|
|
|
80
80
|
* sandbox's disk survives. Erring long is the cheap direction: the cost of too
|
|
81
81
|
* long is bytes, the cost of too short is a destroyed live run.
|
|
82
82
|
*/
|
|
83
|
-
var DEFAULT_ORPHAN_TTL_MS =
|
|
83
|
+
var DEFAULT_ORPHAN_TTL_MS = 36e5;
|
|
84
84
|
/**
|
|
85
85
|
* Ceiling on deletions per sweep. A cron-driven sweep runs unattended, so a
|
|
86
86
|
* mistake — a store that answers `terminal` for everything, a misconfigured
|
|
@@ -163,7 +163,8 @@ async function pruneJournals(options) {
|
|
|
163
163
|
let ageGate = "unavailable";
|
|
164
164
|
const mtimes = /* @__PURE__ */ new Map();
|
|
165
165
|
try {
|
|
166
|
-
const
|
|
166
|
+
const probe = await options.handle.process.exec(journalMtimeListCommand(dir));
|
|
167
|
+
const parsed = parseJournalMtimeListing(probe.stdout, dir);
|
|
167
168
|
if (parsed.kind === "listed") {
|
|
168
169
|
ageGate = "listed";
|
|
169
170
|
for (const entry of parsed.entries) mtimes.set(entry.name, entry.mtimeMs);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"journal-sweep.js","names":[],"sources":["../../src/journal-sweep.ts"],"sourcesContent":["/**\n * Bound the journal directory: delete the journals nobody will ever read again,\n * and — far more importantly — refuse to delete anything else.\n *\n * `journalCleanupCommand` already deletes ONE run's journal at the moment its\n * `{\"__exit\":N}` sentinel is observed. That covers every run a host watched to\n * completion and covers nothing else: a run that reaches its sentinel while\n * DETACHED has no host reading its journal, so nothing observes the sentinel and\n * nothing calls the cleanup. Those journals accumulate in\n * {@link DEFAULT_JOURNAL_DIR} until the sandbox dies, which on a `keepAlive`\n * sandbox may be never. This module is the sweep that bounds them, driven from a\n * cron or a reaper rather than from a run.\n *\n * **Why deleting is dangerous, and therefore why almost every branch keeps.**\n * The journal is the ONLY copy of the bytes a successor host needs to replay a\n * run a dead host abandoned mid-flight. Delete a live run's journal and that run\n * becomes unresumable — silently, because the reader will simply deliver nothing.\n * There is no undo and no second copy. So the decision procedure here is not\n * \"delete unless I have a reason to keep\"; it is the opposite, and every arm that\n * is not a PROVEN-safe deletion keeps:\n *\n * | the store says… | action | why |\n * | ---------------------------------- | ------ | --- |\n * | terminal (`isTerminalRunStatus`) | DELETE | the delivery log, not the journal, is the record |\n * | non-terminal, INCLUDING `'interrupted'` | KEEP | an interrupt-resume continues from it |\n * | nothing (unknown runId) | KEEP until `orphanTtlMs` | the reader creates the journal BEFORE the record exists |\n * | the lookup threw | KEEP | never delete on an unanswered question |\n * | (the name did not decode) | KEEP | a truncated name decodes to a plausible WRONG runId |\n * | (no mtime listing) | KEEP every age-gated entry | cannot age-gate ⇒ cannot expire |\n *\n * Deleting a TERMINAL run's journal is safe because a late takeover of a terminal\n * run aligns against the delivery LOG, not the journal: `align.ts`'s\n * `alignToStoredLog` takes a `StreamDurability` plus an\n * `AsyncIterable<StreamChunk>`, has no `SandboxHandle` and no `JournalPaths` in\n * its signature, and reads the already-delivered prefix with\n * `durability.snapshot()`. It *cannot* read a journal, so removing one cannot\n * break it. A non-zero exit is terminal too — `{\"__exit\":7}` is as final as\n * `{\"__exit\":0}`.\n *\n * The unknown-runId arm is the subtle one, and it is why an age gate exists at\n * all. `journalFollowCommand` opens the journal with `: >> file`, which CREATES\n * it; the reader and the run record are written by two independent code paths and\n * nothing orders them. So \"a journal exists whose runId the store has never heard\n * of\" is the NORMAL state of a run that started moments ago, not an anomaly.\n * Treating unknown as deletable would race every single run start. The journal is\n * therefore kept until it has been untouched for `orphanTtlMs`, which is the only\n * evidence available that no one is writing to it.\n *\n * **The fail-closed trap this module exists to not fall into.** BusyBox `find`\n * prints its \"unrecognized option\" diagnostic to *stderr* and exits **1 with\n * empty stdout**. A capability probe that ignores the exit code reads that as \"no\n * files matched\", i.e. \"no file is newer than the cutoff\" — and code that then\n * concludes \"therefore every file is old\" **deletes the entire directory**, live\n * runs included. {@link parseJournalMtimeListing} is built to make that\n * impossible: it passes the directory as `stat`'s own first operand as a\n * self-witness and returns `{ kind: 'unavailable' }` when that witness line is\n * absent, never `[]`. This module's whole obligation on that front is to honor\n * `unavailable` as \"I cannot age-gate, so I keep\" rather than as an empty\n * listing. See the `age-gate-unavailable` reason.\n *\n * **Shell only, never `handle.fs.*`.** On local-process, `fs.*` resolves `/tmp`\n * under the sandbox root while a shell redirect hits the real host `/tmp`, so an\n * `fs.remove` would delete a DIFFERENT path than the one `journaledCommand`\n * wrote — silently doing nothing while reporting success. Every filesystem touch\n * here goes through `handle.process.exec` with a command composed in\n * `journal.ts`.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport {\n DEFAULT_JOURNAL_DIR,\n decodeJournalRunId,\n journalCleanupCommand,\n journalListCommand,\n journalMtimeListCommand,\n journalPaths,\n parseJournalMtimeListing,\n} from './journal'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { RunStore } from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\n\n/**\n * How long a journal whose runId the store does not know must go untouched\n * before the sweep will delete it.\n *\n * One hour, chosen against what the window actually protects: the gap between a\n * reader creating the journal with `: >> file` and the run record appearing in\n * the store. That gap is milliseconds in the normal case and seconds in the worst\n * case (a slow store, a retried write). An hour is three orders of magnitude of\n * headroom on the race, while still bounding a leaked journal to something a\n * sandbox's disk survives. Erring long is the cheap direction: the cost of too\n * long is bytes, the cost of too short is a destroyed live run.\n */\nexport const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000\n\n/**\n * Ceiling on deletions per sweep. A cron-driven sweep runs unattended, so a\n * mistake — a store that answers `terminal` for everything, a misconfigured\n * directory — is bounded by this rather than by how many journals happen to\n * exist. The remainder is reported as kept with reason `max-deletes` and picked\n * up by the next sweep.\n */\nexport const DEFAULT_MAX_DELETES = 200\n\n/** Why {@link pruneJournals} left a journal in place. */\nexport type KeptJournalReason =\n /** The store answered with a non-terminal status (`'running'`, `'interrupted'`). */\n | 'non-terminal'\n /** The store has never heard of this runId and the journal is still fresh. */\n | 'orphan-too-recent'\n /**\n * The store has never heard of this runId and the age gate could not run at\n * all — {@link parseJournalMtimeListing} returned `unavailable`. THE\n * FAIL-CLOSED ARM: an unavailable listing is not an empty one and says nothing\n * about any file's age.\n */\n | 'age-gate-unavailable'\n /**\n * The age gate ran but reported no mtime for this file, so its age is unknown.\n * (A file created between the two `exec`s, or a name the glob missed.)\n */\n | 'age-gate-missing-entry'\n /** {@link decodeJournalRunId} refused the name (`truncated` or `malformed`). */\n | 'undecodable-name'\n /** The store lookup threw. A question that was not answered is not a licence to delete. */\n | 'store-error'\n /** The `rm` itself failed or exited non-zero. */\n | 'delete-failed'\n /** {@link PruneJournalsOptions.maxDeletes} was already reached this sweep. */\n | 'max-deletes'\n\n/** One journal (or one runId's journal + sidecar) the sweep declined to delete. */\nexport interface KeptJournal {\n /** The decoded runId; absent exactly when `reason` is `'undecodable-name'`. */\n runId?: string\n /** Every listed filename this entry covers — the journal and its `.err` sidecar. */\n names: Array<string>\n reason: KeptJournalReason\n}\n\n/** A non-fatal failure the sweep folded into its result instead of throwing. */\nexport interface PruneJournalsFailure {\n stage: 'list' | 'mtime-list' | 'store' | 'delete'\n /** Present when the failure is attributable to one run. */\n runId?: string\n message: string\n}\n\n/** What one {@link pruneJournals} sweep did. */\nexport interface PruneJournalsResult {\n /** Filenames `ls -1` reported, before de-duplication by runId. */\n listed: number\n /** Distinct runIds those filenames decoded to. */\n runIds: number\n /** runIds whose journal AND sidecar were deleted, in the order deleted. */\n deleted: Array<string>\n /** Everything left in place, with the reason. */\n kept: Array<KeptJournal>\n /**\n * Whether the mtime age gate was usable this sweep. `'unavailable'` means no\n * orphan could be expired, by design.\n */\n ageGate: 'listed' | 'unavailable'\n failures: Array<PruneJournalsFailure>\n}\n\nexport interface PruneJournalsOptions {\n /** Sandbox holding the journal directory. Touched only via `process.exec`. */\n handle: SandboxHandle\n /**\n * Run lookup. Only `get` is used: the sweep asks about the runIds it found on\n * disk and never enumerates the store, so no optional `RunStore` method is\n * required of a backend.\n */\n runs: Pick<RunStore, 'get'>\n /** Journal directory. Defaults to {@link DEFAULT_JOURNAL_DIR}. */\n dir?: string\n /** Age-gate reference time. Defaults to `Date.now()`; injectable for tests. */\n now?: number\n /** See {@link DEFAULT_ORPHAN_TTL_MS}. */\n orphanTtlMs?: number\n /** See {@link DEFAULT_MAX_DELETES}. */\n maxDeletes?: number\n logger?: InternalLogger\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\n/**\n * Group listed filenames by the runId they decode to, so a journal and its\n * `.err` sidecar are ONE decision and ONE `rm`, not two.\n *\n * De-duplication is not a tidiness measure: `journalCleanupCommand` deletes both\n * paths for a runId at once, so iterating raw names would ask the store twice per\n * run and then issue a second `rm` for files the first one already removed —\n * doubling the store load and reporting one run as two deletions.\n */\nfunction groupByRunId(names: Array<string>): {\n byRunId: Map<string, Array<string>>\n undecodable: Array<string>\n} {\n const byRunId = new Map<string, Array<string>>()\n const undecodable: Array<string> = []\n for (const name of names) {\n const decoded = decodeJournalRunId(name)\n if (decoded.kind !== 'runId') {\n undecodable.push(name)\n continue\n }\n const existing = byRunId.get(decoded.runId)\n if (existing === undefined) byRunId.set(decoded.runId, [name])\n else existing.push(name)\n }\n return { byRunId, undecodable }\n}\n\n/**\n * Sweep the journal directory, deleting only journals whose runs the store\n * reports terminal (plus orphans that have been untouched past `orphanTtlMs`).\n *\n * **Never rejects.** This runs unattended from a cron, where a rejected promise\n * is an unhandled rejection and, worse, hides which journals were and were not\n * swept. Every failure — a listing that errored, a store that threw, an `rm` that\n * exited non-zero — is folded into\n * {@link PruneJournalsResult.failures} and the sweep continues with the entries\n * it can still decide about.\n */\nexport async function pruneJournals(\n options: PruneJournalsOptions,\n): Promise<PruneJournalsResult> {\n const dir = options.dir ?? DEFAULT_JOURNAL_DIR\n const now = options.now ?? Date.now()\n const orphanTtlMs = options.orphanTtlMs ?? DEFAULT_ORPHAN_TTL_MS\n const maxDeletes = options.maxDeletes ?? DEFAULT_MAX_DELETES\n const logger = options.logger\n\n const deleted: Array<string> = []\n const kept: Array<KeptJournal> = []\n const failures: Array<PruneJournalsFailure> = []\n\n // `ls -1` is the authoritative name list. It is a SEPARATE command from the\n // mtime listing on purpose: `stat -c` may not exist on the provider's\n // busybox, and a sweep that could not enumerate at all when the age gate is\n // unavailable would never delete the terminal journals it is safe to delete.\n let names: Array<string> = []\n try {\n const listing = await options.handle.process.exec(journalListCommand(dir))\n names = listing.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line !== '')\n } catch (error) {\n // Nothing was enumerated, so nothing can be deleted. Report and stop —\n // there is no partial-listing arm, because a partial listing is\n // indistinguishable from a complete one and we only ever DELETE from it.\n failures.push({ stage: 'list', message: errorMessage(error) })\n logger?.warn('journal sweep: listing the journal directory failed', {\n dir,\n error,\n })\n return {\n listed: 0,\n runIds: 0,\n deleted,\n kept,\n ageGate: 'unavailable',\n failures,\n }\n }\n\n // The age gate. `unavailable` is a first-class outcome, NOT an empty listing:\n // see the module doc's BusyBox `find` trap. It disables orphan expiry for this\n // sweep and disables nothing else.\n let ageGate: 'listed' | 'unavailable' = 'unavailable'\n const mtimes = new Map<string, number>()\n try {\n const probe = await options.handle.process.exec(\n journalMtimeListCommand(dir),\n )\n const parsed = parseJournalMtimeListing(probe.stdout, dir)\n if (parsed.kind === 'listed') {\n ageGate = 'listed'\n for (const entry of parsed.entries) mtimes.set(entry.name, entry.mtimeMs)\n } else {\n logger?.warn(\n 'journal sweep: mtime listing unavailable; keeping every orphan',\n { dir },\n )\n }\n } catch (error) {\n failures.push({ stage: 'mtime-list', message: errorMessage(error) })\n logger?.warn('journal sweep: mtime listing failed; keeping every orphan', {\n dir,\n error,\n })\n }\n\n const { byRunId, undecodable } = groupByRunId(names)\n\n // Undecodable names are kept unconditionally and without asking the store.\n // A truncated name decodes to a PLAUSIBLE BUT WRONG runId, so consulting the\n // store about it would answer a question about some other run — possibly a\n // live one — and a `terminal` answer would then delete this run's journal.\n for (const name of undecodable) {\n kept.push({ names: [name], reason: 'undecodable-name' })\n }\n\n const orphanCutoff = now - orphanTtlMs\n\n for (const [runId, runNames] of byRunId) {\n if (deleted.length >= maxDeletes) {\n kept.push({ runId, names: runNames, reason: 'max-deletes' })\n continue\n }\n\n let record: Awaited<ReturnType<RunStore['get']>>\n try {\n record = await options.runs.get(runId)\n } catch (error) {\n failures.push({ stage: 'store', runId, message: errorMessage(error) })\n logger?.warn('journal sweep: run lookup failed; keeping the journal', {\n runId,\n error,\n })\n kept.push({ runId, names: runNames, reason: 'store-error' })\n continue\n }\n\n if (record === null) {\n // Unknown to the store: either the record has not been written yet (the\n // normal case for a run that just started) or it was deleted after the\n // run ended. Only age distinguishes them.\n if (ageGate === 'unavailable') {\n kept.push({ runId, names: runNames, reason: 'age-gate-unavailable' })\n continue\n }\n const observed = runNames.map((name) => mtimes.get(name))\n if (observed.some((mtimeMs) => mtimeMs === undefined)) {\n kept.push({ runId, names: runNames, reason: 'age-gate-missing-entry' })\n continue\n }\n // The NEWEST of the run's files decides: a journal whose sidecar was\n // written a second ago is being written to, whatever the journal's own\n // mtime says.\n const newest = Math.max(...observed.filter(isDefined))\n if (newest > orphanCutoff) {\n kept.push({ runId, names: runNames, reason: 'orphan-too-recent' })\n continue\n }\n } else if (!isTerminalRunStatus(record.status)) {\n // `'interrupted'` lands here, and must: it is a human-in-the-loop PAUSE\n // that interrupt-resume continues from, not an end state.\n kept.push({ runId, names: runNames, reason: 'non-terminal' })\n continue\n }\n\n // Shell `rm`, never `handle.fs.remove`: module doc, and `journalPaths`\n // re-derives byte-identical paths from the runId alone.\n const command = journalCleanupCommand(journalPaths(runId, dir))\n try {\n const result = await options.handle.process.exec(command)\n if (result.exitCode !== 0) {\n failures.push({\n stage: 'delete',\n runId,\n message: `rm exited ${result.exitCode}`,\n })\n kept.push({ runId, names: runNames, reason: 'delete-failed' })\n continue\n }\n } catch (error) {\n // A failed cleanup must never fail the sweep: the journal is still there\n // and the next sweep will see it again.\n failures.push({ stage: 'delete', runId, message: errorMessage(error) })\n logger?.warn('journal sweep: deleting a journal failed', { runId, error })\n kept.push({ runId, names: runNames, reason: 'delete-failed' })\n continue\n }\n deleted.push(runId)\n }\n\n logger?.sandbox('journal sweep complete', {\n dir,\n listed: names.length,\n runIds: byRunId.size,\n deleted: deleted.length,\n kept: kept.length,\n ageGate,\n })\n\n return {\n listed: names.length,\n runIds: byRunId.size,\n deleted,\n kept,\n ageGate,\n failures,\n }\n}\n\n/** Narrowing predicate: `Array<number | undefined>` → `Array<number>`. */\nfunction isDefined(value: number | undefined): value is number {\n return value !== undefined\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"journal-sweep.js","names":[],"sources":["../../src/journal-sweep.ts"],"sourcesContent":["/**\n * Bound the journal directory: delete the journals nobody will ever read again,\n * and — far more importantly — refuse to delete anything else.\n *\n * `journalCleanupCommand` already deletes ONE run's journal at the moment its\n * `{\"__exit\":N}` sentinel is observed. That covers every run a host watched to\n * completion and covers nothing else: a run that reaches its sentinel while\n * DETACHED has no host reading its journal, so nothing observes the sentinel and\n * nothing calls the cleanup. Those journals accumulate in\n * {@link DEFAULT_JOURNAL_DIR} until the sandbox dies, which on a `keepAlive`\n * sandbox may be never. This module is the sweep that bounds them, driven from a\n * cron or a reaper rather than from a run.\n *\n * **Why deleting is dangerous, and therefore why almost every branch keeps.**\n * The journal is the ONLY copy of the bytes a successor host needs to replay a\n * run a dead host abandoned mid-flight. Delete a live run's journal and that run\n * becomes unresumable — silently, because the reader will simply deliver nothing.\n * There is no undo and no second copy. So the decision procedure here is not\n * \"delete unless I have a reason to keep\"; it is the opposite, and every arm that\n * is not a PROVEN-safe deletion keeps:\n *\n * | the store says… | action | why |\n * | ---------------------------------- | ------ | --- |\n * | terminal (`isTerminalRunStatus`) | DELETE | the delivery log, not the journal, is the record |\n * | non-terminal, INCLUDING `'interrupted'` | KEEP | an interrupt-resume continues from it |\n * | nothing (unknown runId) | KEEP until `orphanTtlMs` | the reader creates the journal BEFORE the record exists |\n * | the lookup threw | KEEP | never delete on an unanswered question |\n * | (the name did not decode) | KEEP | a truncated name decodes to a plausible WRONG runId |\n * | (no mtime listing) | KEEP every age-gated entry | cannot age-gate ⇒ cannot expire |\n *\n * Deleting a TERMINAL run's journal is safe because a late takeover of a terminal\n * run aligns against the delivery LOG, not the journal: `align.ts`'s\n * `alignToStoredLog` takes a `StreamDurability` plus an\n * `AsyncIterable<StreamChunk>`, has no `SandboxHandle` and no `JournalPaths` in\n * its signature, and reads the already-delivered prefix with\n * `durability.snapshot()`. It *cannot* read a journal, so removing one cannot\n * break it. A non-zero exit is terminal too — `{\"__exit\":7}` is as final as\n * `{\"__exit\":0}`.\n *\n * The unknown-runId arm is the subtle one, and it is why an age gate exists at\n * all. `journalFollowCommand` opens the journal with `: >> file`, which CREATES\n * it; the reader and the run record are written by two independent code paths and\n * nothing orders them. So \"a journal exists whose runId the store has never heard\n * of\" is the NORMAL state of a run that started moments ago, not an anomaly.\n * Treating unknown as deletable would race every single run start. The journal is\n * therefore kept until it has been untouched for `orphanTtlMs`, which is the only\n * evidence available that no one is writing to it.\n *\n * **The fail-closed trap this module exists to not fall into.** BusyBox `find`\n * prints its \"unrecognized option\" diagnostic to *stderr* and exits **1 with\n * empty stdout**. A capability probe that ignores the exit code reads that as \"no\n * files matched\", i.e. \"no file is newer than the cutoff\" — and code that then\n * concludes \"therefore every file is old\" **deletes the entire directory**, live\n * runs included. {@link parseJournalMtimeListing} is built to make that\n * impossible: it passes the directory as `stat`'s own first operand as a\n * self-witness and returns `{ kind: 'unavailable' }` when that witness line is\n * absent, never `[]`. This module's whole obligation on that front is to honor\n * `unavailable` as \"I cannot age-gate, so I keep\" rather than as an empty\n * listing. See the `age-gate-unavailable` reason.\n *\n * **Shell only, never `handle.fs.*`.** On local-process, `fs.*` resolves `/tmp`\n * under the sandbox root while a shell redirect hits the real host `/tmp`, so an\n * `fs.remove` would delete a DIFFERENT path than the one `journaledCommand`\n * wrote — silently doing nothing while reporting success. Every filesystem touch\n * here goes through `handle.process.exec` with a command composed in\n * `journal.ts`.\n */\nimport { isTerminalRunStatus } from '@tanstack/ai'\nimport {\n DEFAULT_JOURNAL_DIR,\n decodeJournalRunId,\n journalCleanupCommand,\n journalListCommand,\n journalMtimeListCommand,\n journalPaths,\n parseJournalMtimeListing,\n} from './journal'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { RunStore } from '@tanstack/ai'\nimport type { SandboxHandle } from './contracts'\n\n/**\n * How long a journal whose runId the store does not know must go untouched\n * before the sweep will delete it.\n *\n * One hour, chosen against what the window actually protects: the gap between a\n * reader creating the journal with `: >> file` and the run record appearing in\n * the store. That gap is milliseconds in the normal case and seconds in the worst\n * case (a slow store, a retried write). An hour is three orders of magnitude of\n * headroom on the race, while still bounding a leaked journal to something a\n * sandbox's disk survives. Erring long is the cheap direction: the cost of too\n * long is bytes, the cost of too short is a destroyed live run.\n */\nexport const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000\n\n/**\n * Ceiling on deletions per sweep. A cron-driven sweep runs unattended, so a\n * mistake — a store that answers `terminal` for everything, a misconfigured\n * directory — is bounded by this rather than by how many journals happen to\n * exist. The remainder is reported as kept with reason `max-deletes` and picked\n * up by the next sweep.\n */\nexport const DEFAULT_MAX_DELETES = 200\n\n/** Why {@link pruneJournals} left a journal in place. */\nexport type KeptJournalReason =\n /** The store answered with a non-terminal status (`'running'`, `'interrupted'`). */\n | 'non-terminal'\n /** The store has never heard of this runId and the journal is still fresh. */\n | 'orphan-too-recent'\n /**\n * The store has never heard of this runId and the age gate could not run at\n * all — {@link parseJournalMtimeListing} returned `unavailable`. THE\n * FAIL-CLOSED ARM: an unavailable listing is not an empty one and says nothing\n * about any file's age.\n */\n | 'age-gate-unavailable'\n /**\n * The age gate ran but reported no mtime for this file, so its age is unknown.\n * (A file created between the two `exec`s, or a name the glob missed.)\n */\n | 'age-gate-missing-entry'\n /** {@link decodeJournalRunId} refused the name (`truncated` or `malformed`). */\n | 'undecodable-name'\n /** The store lookup threw. A question that was not answered is not a licence to delete. */\n | 'store-error'\n /** The `rm` itself failed or exited non-zero. */\n | 'delete-failed'\n /** {@link PruneJournalsOptions.maxDeletes} was already reached this sweep. */\n | 'max-deletes'\n\n/** One journal (or one runId's journal + sidecar) the sweep declined to delete. */\nexport interface KeptJournal {\n /** The decoded runId; absent exactly when `reason` is `'undecodable-name'`. */\n runId?: string\n /** Every listed filename this entry covers — the journal and its `.err` sidecar. */\n names: Array<string>\n reason: KeptJournalReason\n}\n\n/** A non-fatal failure the sweep folded into its result instead of throwing. */\nexport interface PruneJournalsFailure {\n stage: 'list' | 'mtime-list' | 'store' | 'delete'\n /** Present when the failure is attributable to one run. */\n runId?: string\n message: string\n}\n\n/** What one {@link pruneJournals} sweep did. */\nexport interface PruneJournalsResult {\n /** Filenames `ls -1` reported, before de-duplication by runId. */\n listed: number\n /** Distinct runIds those filenames decoded to. */\n runIds: number\n /** runIds whose journal AND sidecar were deleted, in the order deleted. */\n deleted: Array<string>\n /** Everything left in place, with the reason. */\n kept: Array<KeptJournal>\n /**\n * Whether the mtime age gate was usable this sweep. `'unavailable'` means no\n * orphan could be expired, by design.\n */\n ageGate: 'listed' | 'unavailable'\n failures: Array<PruneJournalsFailure>\n}\n\nexport interface PruneJournalsOptions {\n /** Sandbox holding the journal directory. Touched only via `process.exec`. */\n handle: SandboxHandle\n /**\n * Run lookup. Only `get` is used: the sweep asks about the runIds it found on\n * disk and never enumerates the store, so no optional `RunStore` method is\n * required of a backend.\n */\n runs: Pick<RunStore, 'get'>\n /** Journal directory. Defaults to {@link DEFAULT_JOURNAL_DIR}. */\n dir?: string\n /** Age-gate reference time. Defaults to `Date.now()`; injectable for tests. */\n now?: number\n /** See {@link DEFAULT_ORPHAN_TTL_MS}. */\n orphanTtlMs?: number\n /** See {@link DEFAULT_MAX_DELETES}. */\n maxDeletes?: number\n logger?: InternalLogger\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\n/**\n * Group listed filenames by the runId they decode to, so a journal and its\n * `.err` sidecar are ONE decision and ONE `rm`, not two.\n *\n * De-duplication is not a tidiness measure: `journalCleanupCommand` deletes both\n * paths for a runId at once, so iterating raw names would ask the store twice per\n * run and then issue a second `rm` for files the first one already removed —\n * doubling the store load and reporting one run as two deletions.\n */\nfunction groupByRunId(names: Array<string>): {\n byRunId: Map<string, Array<string>>\n undecodable: Array<string>\n} {\n const byRunId = new Map<string, Array<string>>()\n const undecodable: Array<string> = []\n for (const name of names) {\n const decoded = decodeJournalRunId(name)\n if (decoded.kind !== 'runId') {\n undecodable.push(name)\n continue\n }\n const existing = byRunId.get(decoded.runId)\n if (existing === undefined) byRunId.set(decoded.runId, [name])\n else existing.push(name)\n }\n return { byRunId, undecodable }\n}\n\n/**\n * Sweep the journal directory, deleting only journals whose runs the store\n * reports terminal (plus orphans that have been untouched past `orphanTtlMs`).\n *\n * **Never rejects.** This runs unattended from a cron, where a rejected promise\n * is an unhandled rejection and, worse, hides which journals were and were not\n * swept. Every failure — a listing that errored, a store that threw, an `rm` that\n * exited non-zero — is folded into\n * {@link PruneJournalsResult.failures} and the sweep continues with the entries\n * it can still decide about.\n */\nexport async function pruneJournals(\n options: PruneJournalsOptions,\n): Promise<PruneJournalsResult> {\n const dir = options.dir ?? DEFAULT_JOURNAL_DIR\n const now = options.now ?? Date.now()\n const orphanTtlMs = options.orphanTtlMs ?? DEFAULT_ORPHAN_TTL_MS\n const maxDeletes = options.maxDeletes ?? DEFAULT_MAX_DELETES\n const logger = options.logger\n\n const deleted: Array<string> = []\n const kept: Array<KeptJournal> = []\n const failures: Array<PruneJournalsFailure> = []\n\n // `ls -1` is the authoritative name list. It is a SEPARATE command from the\n // mtime listing on purpose: `stat -c` may not exist on the provider's\n // busybox, and a sweep that could not enumerate at all when the age gate is\n // unavailable would never delete the terminal journals it is safe to delete.\n let names: Array<string> = []\n try {\n const listing = await options.handle.process.exec(journalListCommand(dir))\n names = listing.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line !== '')\n } catch (error) {\n // Nothing was enumerated, so nothing can be deleted. Report and stop —\n // there is no partial-listing arm, because a partial listing is\n // indistinguishable from a complete one and we only ever DELETE from it.\n failures.push({ stage: 'list', message: errorMessage(error) })\n logger?.warn('journal sweep: listing the journal directory failed', {\n dir,\n error,\n })\n return {\n listed: 0,\n runIds: 0,\n deleted,\n kept,\n ageGate: 'unavailable',\n failures,\n }\n }\n\n // The age gate. `unavailable` is a first-class outcome, NOT an empty listing:\n // see the module doc's BusyBox `find` trap. It disables orphan expiry for this\n // sweep and disables nothing else.\n let ageGate: 'listed' | 'unavailable' = 'unavailable'\n const mtimes = new Map<string, number>()\n try {\n const probe = await options.handle.process.exec(\n journalMtimeListCommand(dir),\n )\n const parsed = parseJournalMtimeListing(probe.stdout, dir)\n if (parsed.kind === 'listed') {\n ageGate = 'listed'\n for (const entry of parsed.entries) mtimes.set(entry.name, entry.mtimeMs)\n } else {\n logger?.warn(\n 'journal sweep: mtime listing unavailable; keeping every orphan',\n { dir },\n )\n }\n } catch (error) {\n failures.push({ stage: 'mtime-list', message: errorMessage(error) })\n logger?.warn('journal sweep: mtime listing failed; keeping every orphan', {\n dir,\n error,\n })\n }\n\n const { byRunId, undecodable } = groupByRunId(names)\n\n // Undecodable names are kept unconditionally and without asking the store.\n // A truncated name decodes to a PLAUSIBLE BUT WRONG runId, so consulting the\n // store about it would answer a question about some other run — possibly a\n // live one — and a `terminal` answer would then delete this run's journal.\n for (const name of undecodable) {\n kept.push({ names: [name], reason: 'undecodable-name' })\n }\n\n const orphanCutoff = now - orphanTtlMs\n\n for (const [runId, runNames] of byRunId) {\n if (deleted.length >= maxDeletes) {\n kept.push({ runId, names: runNames, reason: 'max-deletes' })\n continue\n }\n\n let record: Awaited<ReturnType<RunStore['get']>>\n try {\n record = await options.runs.get(runId)\n } catch (error) {\n failures.push({ stage: 'store', runId, message: errorMessage(error) })\n logger?.warn('journal sweep: run lookup failed; keeping the journal', {\n runId,\n error,\n })\n kept.push({ runId, names: runNames, reason: 'store-error' })\n continue\n }\n\n if (record === null) {\n // Unknown to the store: either the record has not been written yet (the\n // normal case for a run that just started) or it was deleted after the\n // run ended. Only age distinguishes them.\n if (ageGate === 'unavailable') {\n kept.push({ runId, names: runNames, reason: 'age-gate-unavailable' })\n continue\n }\n const observed = runNames.map((name) => mtimes.get(name))\n if (observed.some((mtimeMs) => mtimeMs === undefined)) {\n kept.push({ runId, names: runNames, reason: 'age-gate-missing-entry' })\n continue\n }\n // The NEWEST of the run's files decides: a journal whose sidecar was\n // written a second ago is being written to, whatever the journal's own\n // mtime says.\n const newest = Math.max(...observed.filter(isDefined))\n if (newest > orphanCutoff) {\n kept.push({ runId, names: runNames, reason: 'orphan-too-recent' })\n continue\n }\n } else if (!isTerminalRunStatus(record.status)) {\n // `'interrupted'` lands here, and must: it is a human-in-the-loop PAUSE\n // that interrupt-resume continues from, not an end state.\n kept.push({ runId, names: runNames, reason: 'non-terminal' })\n continue\n }\n\n // Shell `rm`, never `handle.fs.remove`: module doc, and `journalPaths`\n // re-derives byte-identical paths from the runId alone.\n const command = journalCleanupCommand(journalPaths(runId, dir))\n try {\n const result = await options.handle.process.exec(command)\n if (result.exitCode !== 0) {\n failures.push({\n stage: 'delete',\n runId,\n message: `rm exited ${result.exitCode}`,\n })\n kept.push({ runId, names: runNames, reason: 'delete-failed' })\n continue\n }\n } catch (error) {\n // A failed cleanup must never fail the sweep: the journal is still there\n // and the next sweep will see it again.\n failures.push({ stage: 'delete', runId, message: errorMessage(error) })\n logger?.warn('journal sweep: deleting a journal failed', { runId, error })\n kept.push({ runId, names: runNames, reason: 'delete-failed' })\n continue\n }\n deleted.push(runId)\n }\n\n logger?.sandbox('journal sweep complete', {\n dir,\n listed: names.length,\n runIds: byRunId.size,\n deleted: deleted.length,\n kept: kept.length,\n ageGate,\n })\n\n return {\n listed: names.length,\n runIds: byRunId.size,\n deleted,\n kept,\n ageGate,\n failures,\n }\n}\n\n/** Narrowing predicate: `Array<number | undefined>` → `Array<number>`. */\nfunction isDefined(value: number | undefined): value is number {\n return value !== undefined\n}\n"],"mappings":"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"}
|
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,8 @@ function withSandbox(definition, options) {
|
|
|
228
230
|
}
|
|
229
231
|
const workspace = definition.workspace;
|
|
230
232
|
if (workspace !== void 0) {
|
|
231
|
-
const
|
|
233
|
+
const virtualRoot = workspace.root ?? "/workspace";
|
|
234
|
+
const root = resolveHarnessCwd(handle, virtualRoot);
|
|
232
235
|
const workspaceHash = computeWorkspaceHash(workspace);
|
|
233
236
|
const secrets = workspace.secrets;
|
|
234
237
|
provideWorkspaceProjection(ctx, {
|