@tanstack/ai-sandbox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -0
- package/dist/esm/agents-file.d.ts +36 -0
- package/dist/esm/agents-file.js +44 -0
- package/dist/esm/agents-file.js.map +1 -0
- package/dist/esm/approvals.d.ts +38 -0
- package/dist/esm/approvals.js +36 -0
- package/dist/esm/approvals.js.map +1 -0
- package/dist/esm/bootstrap.d.ts +17 -0
- package/dist/esm/bootstrap.js +124 -0
- package/dist/esm/bootstrap.js.map +1 -0
- package/dist/esm/bridge-events.d.ts +21 -0
- package/dist/esm/bridge-events.js +76 -0
- package/dist/esm/bridge-events.js.map +1 -0
- package/dist/esm/capabilities.d.ts +26 -0
- package/dist/esm/capabilities.js +29 -0
- package/dist/esm/capabilities.js.map +1 -0
- package/dist/esm/contracts.d.ts +211 -0
- package/dist/esm/errors.d.ts +16 -0
- package/dist/esm/errors.js +25 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/git-exec.d.ts +2 -0
- package/dist/esm/git-exec.js +68 -0
- package/dist/esm/git-exec.js.map +1 -0
- package/dist/esm/harness-cwd.d.ts +2 -0
- package/dist/esm/harness-cwd.js +24 -0
- package/dist/esm/harness-cwd.js.map +1 -0
- package/dist/esm/index.d.ts +39 -0
- package/dist/esm/index.js +103 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/key.d.ts +20 -0
- package/dist/esm/key.js +41 -0
- package/dist/esm/key.js.map +1 -0
- package/dist/esm/middleware.d.ts +5 -0
- package/dist/esm/middleware.js +140 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/ngrok.d.ts +16 -0
- package/dist/esm/ngrok.js +54 -0
- package/dist/esm/ngrok.js.map +1 -0
- package/dist/esm/policy.d.ts +47 -0
- package/dist/esm/policy.js +44 -0
- package/dist/esm/policy.js.map +1 -0
- package/dist/esm/projection.d.ts +31 -0
- package/dist/esm/projection.js +9 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/remote-tools.d.ts +48 -0
- package/dist/esm/remote-tools.js +76 -0
- package/dist/esm/remote-tools.js.map +1 -0
- package/dist/esm/run-log.d.ts +81 -0
- package/dist/esm/run-log.js +107 -0
- package/dist/esm/run-log.js.map +1 -0
- package/dist/esm/run.d.ts +58 -0
- package/dist/esm/run.js +89 -0
- package/dist/esm/run.js.map +1 -0
- package/dist/esm/runner.d.ts +21 -0
- package/dist/esm/runner.js +54 -0
- package/dist/esm/runner.js.map +1 -0
- package/dist/esm/sandbox.d.ts +79 -0
- package/dist/esm/sandbox.js +125 -0
- package/dist/esm/sandbox.js.map +1 -0
- package/dist/esm/secrets.d.ts +37 -0
- package/dist/esm/secrets.js +59 -0
- package/dist/esm/secrets.js.map +1 -0
- package/dist/esm/setup-plan.d.ts +13 -0
- package/dist/esm/setup-plan.js +16 -0
- package/dist/esm/setup-plan.js.map +1 -0
- package/dist/esm/shell.d.ts +45 -0
- package/dist/esm/shell.js +164 -0
- package/dist/esm/shell.js.map +1 -0
- package/dist/esm/store.d.ts +53 -0
- package/dist/esm/store.js +34 -0
- package/dist/esm/store.js.map +1 -0
- package/dist/esm/tool-bridge.d.ts +130 -0
- package/dist/esm/tool-bridge.js +197 -0
- package/dist/esm/tool-bridge.js.map +1 -0
- package/dist/esm/watch.d.ts +36 -0
- package/dist/esm/watch.js +144 -0
- package/dist/esm/watch.js.map +1 -0
- package/dist/esm/workspace.d.ts +128 -0
- package/dist/esm/workspace.js +42 -0
- package/dist/esm/workspace.js.map +1 -0
- package/package.json +72 -0
- package/skills/ai-sandbox/SKILL.md +366 -0
- package/src/agents-file.ts +101 -0
- package/src/approvals.ts +96 -0
- package/src/bootstrap.ts +196 -0
- package/src/bridge-events.ts +112 -0
- package/src/capabilities.ts +47 -0
- package/src/contracts.ts +236 -0
- package/src/errors.ts +31 -0
- package/src/git-exec.ts +114 -0
- package/src/harness-cwd.ts +38 -0
- package/src/index.ts +222 -0
- package/src/key.ts +70 -0
- package/src/middleware.ts +233 -0
- package/src/ngrok.ts +85 -0
- package/src/policy.ts +111 -0
- package/src/projection.ts +46 -0
- package/src/remote-tools.ts +180 -0
- package/src/run-log.ts +224 -0
- package/src/run.ts +167 -0
- package/src/runner.ts +99 -0
- package/src/sandbox.ts +259 -0
- package/src/secrets.ts +101 -0
- package/src/setup-plan.ts +25 -0
- package/src/shell.ts +288 -0
- package/src/store.ts +83 -0
- package/src/tool-bridge.ts +399 -0
- package/src/watch.ts +256 -0
- package/src/workspace.ts +151 -0
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# @tanstack/ai-sandbox
|
|
2
|
+
|
|
3
|
+
Provider-agnostic sandbox layer for [TanStack AI](https://tanstack.com/ai). Run coding-agent harness adapters (Grok Build, Claude Code, Codex, OpenCode, Gemini CLI) **inside** an isolated environment with a real filesystem, shell, and cloned repo — and stream their work back through `chat()`.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { chat } from '@tanstack/ai'
|
|
7
|
+
import { grokBuildText } from '@tanstack/ai-grok-build'
|
|
8
|
+
import {
|
|
9
|
+
createSecrets,
|
|
10
|
+
defineSandbox,
|
|
11
|
+
defineWorkspace,
|
|
12
|
+
githubRepo,
|
|
13
|
+
withSandbox,
|
|
14
|
+
} from '@tanstack/ai-sandbox'
|
|
15
|
+
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
|
|
16
|
+
|
|
17
|
+
const sandbox = defineSandbox({
|
|
18
|
+
id: 'repo-agent',
|
|
19
|
+
provider: dockerSandbox({ image: 'node:22' }),
|
|
20
|
+
workspace: defineWorkspace({
|
|
21
|
+
source: githubRepo({ repo: 'owner/repo', ref: 'main' }),
|
|
22
|
+
packageManager: 'pnpm',
|
|
23
|
+
setup: ['corepack enable', 'pnpm install'],
|
|
24
|
+
scripts: { test: 'pnpm test', build: 'pnpm build' },
|
|
25
|
+
secrets: createSecrets({
|
|
26
|
+
XAI_API_KEY: process.env.XAI_API_KEY ?? '',
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
lifecycle: { reuse: 'thread', snapshot: 'after-setup' },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const stream = chat({
|
|
33
|
+
threadId: 'my-thread',
|
|
34
|
+
adapter: grokBuildText('grok-build'),
|
|
35
|
+
messages: [{ role: 'user', content: 'Fix the failing test.' }],
|
|
36
|
+
middleware: [withSandbox(sandbox)],
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-grok-build
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Pick a **provider** package for where the sandbox runs:
|
|
47
|
+
|
|
48
|
+
| Package | Use when |
|
|
49
|
+
| ------------------------------------ | -------------------------------------- |
|
|
50
|
+
| `@tanstack/ai-sandbox-local-process` | Dev loop on your host (no isolation) |
|
|
51
|
+
| `@tanstack/ai-sandbox-docker` | Isolated containers, snapshots, resume |
|
|
52
|
+
| `@tanstack/ai-sandbox-cloudflare` | Cloudflare Workers + Containers |
|
|
53
|
+
| `@tanstack/ai-sandbox-vercel` | Vercel Sandbox |
|
|
54
|
+
| `@tanstack/ai-sandbox-daytona` | Daytona dev environments |
|
|
55
|
+
|
|
56
|
+
**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.
|
|
57
|
+
|
|
58
|
+
## Three moving parts
|
|
59
|
+
|
|
60
|
+
| Part | What it is | How you configure it |
|
|
61
|
+
| ------------------- | -------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
62
|
+
| **Provider** | Isolation primitive — host, container, cloud VM | `dockerSandbox()`, `localProcessSandbox()`, … |
|
|
63
|
+
| **Workspace** | What the agent boots into — repo, setup, secrets, skills | `defineWorkspace({ … })` |
|
|
64
|
+
| **Harness adapter** | Which agent CLI runs and how output is translated | `grokBuildText()` (default), `claudeCodeText()`, `codexText()`, … |
|
|
65
|
+
|
|
66
|
+
`defineSandbox()` binds provider + workspace (+ optional policy, lifecycle, hooks). `withSandbox(definition)` is the `chat()` middleware that creates or resumes the sandbox for each run.
|
|
67
|
+
|
|
68
|
+
## Core APIs
|
|
69
|
+
|
|
70
|
+
### Workspace
|
|
71
|
+
|
|
72
|
+
Describe the working tree once, portably:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
defineWorkspace({
|
|
76
|
+
source: githubRepo({ repo: 'owner/app' }), // shallow clone by default
|
|
77
|
+
setup: ({ serial, parallel }) => {
|
|
78
|
+
serial('pnpm install')
|
|
79
|
+
parallel(['pnpm build', 'pnpm typecheck'])
|
|
80
|
+
},
|
|
81
|
+
scripts: { test: 'pnpm test' }, // surfaced in AGENTS.md; policy aliases
|
|
82
|
+
instructions: 'Run tests before proposing changes.',
|
|
83
|
+
skills: [
|
|
84
|
+
gitSkill({ repo: 'owner/skills', secret: secrets.GH }),
|
|
85
|
+
mcpSkill('api', {
|
|
86
|
+
url: 'https://mcp.example.com',
|
|
87
|
+
headers: { Authorization: bearer(secrets.TOKEN) },
|
|
88
|
+
}),
|
|
89
|
+
fileSkill({ path: '.hints.md', content: '# Hints\nPrefer pnpm.' }),
|
|
90
|
+
],
|
|
91
|
+
plugins: ['@anthropic/plugin-foo'], // Claude Code only; other harnesses warn+skip
|
|
92
|
+
secrets: createSecrets({ GH: process.env.GH_TOKEN ?? '' }),
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Skills and plugins are **projected** into each harness's native format at run time (`.grok/config.toml`, `.mcp.json`, `.codex/config.toml`, `opencode.json`, …). Bootstrap writes `AGENTS.md` and clones `gitSkill` repos; harness adapters handle the rest.
|
|
97
|
+
|
|
98
|
+
### Policy
|
|
99
|
+
|
|
100
|
+
Guard what the agent may run:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
const policy = defineSandboxPolicy({
|
|
104
|
+
commands: {
|
|
105
|
+
allow: ['pnpm test', 'git diff'],
|
|
106
|
+
ask: ['pnpm install'],
|
|
107
|
+
deny: ['sudo *', 'rm -rf *'],
|
|
108
|
+
},
|
|
109
|
+
capabilities: { fileWrite: 'allow', network: 'ask' },
|
|
110
|
+
default: 'ask',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
defineSandbox({ id: 'agent', provider, workspace, policy })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
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).
|
|
117
|
+
|
|
118
|
+
### Lifecycle
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
lifecycle: {
|
|
122
|
+
reuse: 'thread', // resume one sandbox per threadId
|
|
123
|
+
snapshot: 'after-setup', // skip bootstrap on subsequent runs (when provider supports it)
|
|
124
|
+
keepAlive: '30m',
|
|
125
|
+
destroyOnComplete: false,
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Secrets
|
|
130
|
+
|
|
131
|
+
Use `createSecrets()` so values stay behind opaque `SecretRef` tokens — never written to snapshots, the sandbox store, or event logs:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const secrets = createSecrets({ XAI_API_KEY: process.env.XAI_API_KEY ?? '' })
|
|
135
|
+
// secrets.XAI_API_KEY is a ref, not the string
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Host tool bridge
|
|
139
|
+
|
|
140
|
+
`chat()` server tools can be bridged into the in-sandbox agent over MCP. The agent calls `mcp__tanstack__<tool>`; execution runs back on the host where your closures, DB, and secrets live.
|
|
141
|
+
|
|
142
|
+
## Run flow
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
chat({ adapter: grokBuildText(), middleware: [withSandbox(sandbox)] })
|
|
146
|
+
│
|
|
147
|
+
├─ withSandbox.setup → resume → restore snapshot → create + bootstrap
|
|
148
|
+
├─ adapter.chatStream → spawn `grok` (or other harness CLI) inside sandbox; stream AG-UI chunks
|
|
149
|
+
└─ withSandbox.onFinish → snapshot / destroy per lifecycle
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Subpath exports
|
|
153
|
+
|
|
154
|
+
| Import | Purpose |
|
|
155
|
+
| ---------------------------- | ----------------------------------------------------- |
|
|
156
|
+
| `@tanstack/ai-sandbox` | Core sandbox APIs |
|
|
157
|
+
| `@tanstack/ai-sandbox/ngrok` | Optional ngrok tunnel helper for remote tool bridging |
|
|
158
|
+
|
|
159
|
+
## Documentation
|
|
160
|
+
|
|
161
|
+
Full guides on [tanstack.com/ai](https://tanstack.com/ai/latest/docs/sandbox/overview):
|
|
162
|
+
|
|
163
|
+
- [Quick Start](https://tanstack.com/ai/latest/docs/sandbox/quick-start) — Grok Build in Docker
|
|
164
|
+
- [Providers](https://tanstack.com/ai/latest/docs/sandbox/providers)
|
|
165
|
+
- [Workspace](https://tanstack.com/ai/latest/docs/sandbox/workspace)
|
|
166
|
+
- [Provisioning](https://tanstack.com/ai/latest/docs/sandbox/provisioning) (skills, MCP, plugins)
|
|
167
|
+
- [Policy](https://tanstack.com/ai/latest/docs/sandbox/policy)
|
|
168
|
+
- [Tools](https://tanstack.com/ai/latest/docs/sandbox/tools) (host tool bridge)
|
|
169
|
+
- [Lifecycle & snapshots](https://tanstack.com/ai/latest/docs/sandbox/lifecycle)
|
|
170
|
+
|
|
171
|
+
## Examples
|
|
172
|
+
|
|
173
|
+
| Example | What it demonstrates |
|
|
174
|
+
| ----------------------------- | --------------------------------------------------------------------- |
|
|
175
|
+
| `examples/sandbox-web` | Build-and-preview with harness × provider matrix (Grok default in UI) |
|
|
176
|
+
| `examples/sandbox-cloudflare` | Edge deploy with live preview URL |
|
|
177
|
+
|
|
178
|
+
## When to use a sandbox
|
|
179
|
+
|
|
180
|
+
Use a sandbox when the agent needs to **act on a real codebase** — run commands, edit files, clone repos, start dev servers. For read-only Q&A over code you already have in context, a normal `chat()` with server tools is enough.
|
|
181
|
+
|
|
182
|
+
Persistence (durable `SandboxStore` / `LockStore`, event-log replay) is out of scope for v1 but every seam is persistence-ready via optional capabilities.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SandboxHandle } from './contracts.js';
|
|
2
|
+
import { WorkspaceSkill } from './workspace.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the directory a `gitSkill` repo is cloned into when no explicit
|
|
5
|
+
* `into` override is provided. The convention is:
|
|
6
|
+
*
|
|
7
|
+
* `<root>/.tanstack-skills/<basename>`
|
|
8
|
+
*
|
|
9
|
+
* where `basename` is derived from the `repo` field by taking the last
|
|
10
|
+
* path segment and stripping a trailing `.git` suffix.
|
|
11
|
+
*
|
|
12
|
+
* Per-harness projectors (e.g. the Claude Code adapter) import this helper
|
|
13
|
+
* so they can locate cloned skill repos consistently.
|
|
14
|
+
*
|
|
15
|
+
* @param root - Workspace root inside the sandbox (e.g. `/workspace`).
|
|
16
|
+
* @param skill - A `WorkspaceSkill` of `kind === 'git'`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveGitSkillDir(root: string, skill: Extract<WorkspaceSkill, {
|
|
19
|
+
kind: 'git';
|
|
20
|
+
}>): string;
|
|
21
|
+
/** Format workspace scripts as a `## Workspace scripts` markdown section. */
|
|
22
|
+
export declare function formatWorkspaceScriptsSection(scripts: Record<string, string>): string;
|
|
23
|
+
/**
|
|
24
|
+
* Merge base AGENTS.md content with an optional workspace scripts section.
|
|
25
|
+
* Returns `undefined` when there is nothing to write.
|
|
26
|
+
*/
|
|
27
|
+
export declare function mergeAgentsContent(base: string | undefined, scripts: Record<string, string> | undefined): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a
|
|
30
|
+
* fallback when `ln -s` is unavailable).
|
|
31
|
+
*
|
|
32
|
+
* @param handle - The sandbox handle providing `fs` and `process`.
|
|
33
|
+
* @param root - Absolute path inside the sandbox under which to write.
|
|
34
|
+
* @param content - Markdown content for the instruction file.
|
|
35
|
+
*/
|
|
36
|
+
export declare function writeAgentsFile(handle: SandboxHandle, root: string, content: string): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const SYMLINK_NAMES = ["CLAUDE.md", "GEMINI.md"];
|
|
2
|
+
function resolveGitSkillDir(root, skill) {
|
|
3
|
+
const rawBasename = skill.repo.split("/").pop() ?? skill.repo;
|
|
4
|
+
const basename = rawBasename.endsWith(".git") ? rawBasename.slice(0, -4) : rawBasename;
|
|
5
|
+
return `${root}/.tanstack-skills/${basename}`;
|
|
6
|
+
}
|
|
7
|
+
function formatWorkspaceScriptsSection(scripts) {
|
|
8
|
+
const names = Object.keys(scripts).sort();
|
|
9
|
+
if (names.length === 0) return "";
|
|
10
|
+
const lines = names.map((name) => `- ${name} → ${scripts[name]}`);
|
|
11
|
+
return `## Workspace scripts
|
|
12
|
+
|
|
13
|
+
${lines.join("\n")}`;
|
|
14
|
+
}
|
|
15
|
+
function mergeAgentsContent(base, scripts) {
|
|
16
|
+
const scriptsSection = scripts !== void 0 ? formatWorkspaceScriptsSection(scripts) : "";
|
|
17
|
+
if (base === void 0 && scriptsSection.length === 0) return void 0;
|
|
18
|
+
if (base === void 0) return scriptsSection;
|
|
19
|
+
if (scriptsSection.length === 0) return base;
|
|
20
|
+
return `${base.trimEnd()}
|
|
21
|
+
|
|
22
|
+
${scriptsSection}`;
|
|
23
|
+
}
|
|
24
|
+
function sqEscape(value) {
|
|
25
|
+
return value.replace(/'/g, `'\\''`);
|
|
26
|
+
}
|
|
27
|
+
async function writeAgentsFile(handle, root, content) {
|
|
28
|
+
const agentsPath = `${root}/AGENTS.md`;
|
|
29
|
+
await handle.fs.write(agentsPath, content);
|
|
30
|
+
for (const name of SYMLINK_NAMES) {
|
|
31
|
+
const lnCmd = `ln -s '${sqEscape("AGENTS.md")}' '${sqEscape(name)}'`;
|
|
32
|
+
const result = await handle.process.exec(lnCmd, { cwd: root });
|
|
33
|
+
if (result.exitCode !== 0) {
|
|
34
|
+
await handle.fs.write(`${root}/${name}`, content);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
formatWorkspaceScriptsSection,
|
|
40
|
+
mergeAgentsContent,
|
|
41
|
+
resolveGitSkillDir,
|
|
42
|
+
writeAgentsFile
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=agents-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agents-file.js","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"],"names":[],"mappings":"AAiBA,MAAM,gBAAuC,CAAC,aAAa,WAAW;AAiB/D,SAAS,mBACd,MACA,OACQ;AACR,QAAM,cAAc,MAAM,KAAK,MAAM,GAAG,EAAE,SAAS,MAAM;AACzD,QAAM,WAAW,YAAY,SAAS,MAAM,IACxC,YAAY,MAAM,GAAG,EAAE,IACvB;AACJ,SAAO,GAAG,IAAI,qBAAqB,QAAQ;AAC7C;AAGO,SAAS,8BACd,SACQ;AACR,QAAM,QAAQ,OAAO,KAAK,OAAO,EAAE,KAAA;AACnC,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,QAAQ,MAAM,IAAI,CAAC,SAAS,KAAK,IAAI,MAAM,QAAQ,IAAI,CAAC,EAAE;AAChE,SAAO;AAAA;AAAA,EAA2B,MAAM,KAAK,IAAI,CAAC;AACpD;AAMO,SAAS,mBACd,MACA,SACoB;AACpB,QAAM,iBACJ,YAAY,SAAY,8BAA8B,OAAO,IAAI;AACnE,MAAI,SAAS,UAAa,eAAe,WAAW,EAAG,QAAO;AAC9D,MAAI,SAAS,OAAW,QAAO;AAC/B,MAAI,eAAe,WAAW,EAAG,QAAO;AACxC,SAAO,GAAG,KAAK,QAAA,CAAS;AAAA;AAAA,EAAO,cAAc;AAC/C;AAGA,SAAS,SAAS,OAAuB;AACvC,SAAO,MAAM,QAAQ,MAAM,OAAO;AACpC;AAUA,eAAsB,gBACpB,QACA,MACA,SACe;AACf,QAAM,aAAa,GAAG,IAAI;AAC1B,QAAM,OAAO,GAAG,MAAM,YAAY,OAAO;AAEzC,aAAW,QAAQ,eAAe;AAChC,UAAM,QAAQ,UAAU,SAAS,WAAW,CAAC,MAAM,SAAS,IAAI,CAAC;AACjE,UAAM,SAAS,MAAM,OAAO,QAAQ,KAAK,OAAO,EAAE,KAAK,MAAM;AAC7D,QAAI,OAAO,aAAa,GAAG;AAEzB,YAAM,OAAO,GAAG,MAAM,GAAG,IAAI,IAAI,IAAI,IAAI,OAAO;AAAA,IAClD;AAAA,EACF;AACF;"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SandboxPolicy } from './policy.js';
|
|
2
|
+
import { StreamChunk } from '@tanstack/ai';
|
|
3
|
+
/** CUSTOM event name emitted when a harness action needs client approval. */
|
|
4
|
+
export declare const APPROVAL_REQUESTED_EVENT = "approval-requested";
|
|
5
|
+
/** A stable, opaque approval id for a harness action. */
|
|
6
|
+
export declare function approvalId(input: {
|
|
7
|
+
provider: string;
|
|
8
|
+
kind: 'command' | 'fileWrite' | 'network' | 'tool';
|
|
9
|
+
target: string;
|
|
10
|
+
}): string;
|
|
11
|
+
export interface ResolveApprovalInput {
|
|
12
|
+
policy: SandboxPolicy | undefined;
|
|
13
|
+
/** Client approval decisions, keyed by `approvalId`. */
|
|
14
|
+
approvals: ReadonlyMap<string, boolean> | undefined;
|
|
15
|
+
/** Precomputed approval id for this action. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** A shell command to match against `policy.commands`. */
|
|
18
|
+
command?: string;
|
|
19
|
+
/** Named workspace scripts for policy alias resolution. */
|
|
20
|
+
scripts?: Record<string, string>;
|
|
21
|
+
/** A coarse capability to match against `policy.capabilities`. */
|
|
22
|
+
capability?: 'fileWrite' | 'network';
|
|
23
|
+
}
|
|
24
|
+
export interface ApprovalOutcome {
|
|
25
|
+
decision: 'allow' | 'deny';
|
|
26
|
+
/** True when policy said `ask` and the client hasn't decided yet. */
|
|
27
|
+
needsApproval: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Resolve a harness permission request against policy + client approvals. */
|
|
30
|
+
export declare function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome;
|
|
31
|
+
/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
|
|
32
|
+
export declare function buildApprovalRequestedEvent(input: {
|
|
33
|
+
approvalId: string;
|
|
34
|
+
title: string;
|
|
35
|
+
threadId: string;
|
|
36
|
+
runId: string;
|
|
37
|
+
detail?: Record<string, unknown>;
|
|
38
|
+
}): StreamChunk;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EventType } from "@tanstack/ai";
|
|
2
|
+
import { evaluateCommand } from "./policy.js";
|
|
3
|
+
const APPROVAL_REQUESTED_EVENT = "approval-requested";
|
|
4
|
+
function approvalId(input) {
|
|
5
|
+
return `${input.provider}:${input.kind}:${input.target}`;
|
|
6
|
+
}
|
|
7
|
+
function resolveApproval(input) {
|
|
8
|
+
const base = input.command !== void 0 ? evaluateCommand(input.command, input.policy, input.scripts) : input.capability !== void 0 ? input.policy?.capabilities?.[input.capability] ?? input.policy?.default ?? "ask" : input.policy?.default ?? "ask";
|
|
9
|
+
if (base === "allow") return { decision: "allow", needsApproval: false };
|
|
10
|
+
if (base === "deny") return { decision: "deny", needsApproval: false };
|
|
11
|
+
const granted = input.approvals?.get(input.id);
|
|
12
|
+
if (granted === true) return { decision: "allow", needsApproval: false };
|
|
13
|
+
if (granted === false) return { decision: "deny", needsApproval: false };
|
|
14
|
+
return { decision: "deny", needsApproval: true };
|
|
15
|
+
}
|
|
16
|
+
function buildApprovalRequestedEvent(input) {
|
|
17
|
+
return {
|
|
18
|
+
type: EventType.CUSTOM,
|
|
19
|
+
name: APPROVAL_REQUESTED_EVENT,
|
|
20
|
+
value: {
|
|
21
|
+
approvalId: input.approvalId,
|
|
22
|
+
title: input.title,
|
|
23
|
+
...input.detail ?? {}
|
|
24
|
+
},
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
threadId: input.threadId,
|
|
27
|
+
runId: input.runId
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
APPROVAL_REQUESTED_EVENT,
|
|
32
|
+
approvalId,
|
|
33
|
+
buildApprovalRequestedEvent,
|
|
34
|
+
resolveApproval
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=approvals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approvals.js","sources":["../../src/approvals.ts"],"sourcesContent":["/**\n * Shared interactive-approval logic for harness adapters.\n *\n * Flow (rides chat()'s existing resume-based approval mechanism):\n * 1. The agent (inside the sandbox) asks to run a risky action; the harness's\n * host-side permission callback fires.\n * 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;\n * `ask` consults the client's approval decisions (threaded via\n * `TextOptions.approvals`, keyed by a stable `approvalId`).\n * 3. On `ask` with no decision yet, the adapter emits an `approval-requested`\n * CUSTOM event (carrying the `approvalId`) and denies the action this turn.\n * The client shows UI, then re-runs chat() with the decision in the message;\n * the engine surfaces it as `approvals`, and the next run allows it.\n *\n * `approvalId` is stable for a given (provider, kind, target) so a client grant\n * matches the same action on the resumed run.\n */\nimport { EventType } from '@tanstack/ai'\nimport { evaluateCommand } from './policy'\nimport type { SandboxPolicy } from './policy'\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** CUSTOM event name emitted when a harness action needs client approval. */\nexport const APPROVAL_REQUESTED_EVENT = 'approval-requested'\n\n/** A stable, opaque approval id for a harness action. */\nexport function approvalId(input: {\n provider: string\n kind: 'command' | 'fileWrite' | 'network' | 'tool'\n target: string\n}): string {\n return `${input.provider}:${input.kind}:${input.target}`\n}\n\nexport interface ResolveApprovalInput {\n policy: SandboxPolicy | undefined\n /** Client approval decisions, keyed by `approvalId`. */\n approvals: ReadonlyMap<string, boolean> | undefined\n /** Precomputed approval id for this action. */\n id: string\n /** A shell command to match against `policy.commands`. */\n command?: string\n /** Named workspace scripts for policy alias resolution. */\n scripts?: Record<string, string>\n /** A coarse capability to match against `policy.capabilities`. */\n capability?: 'fileWrite' | 'network'\n}\n\nexport interface ApprovalOutcome {\n decision: 'allow' | 'deny'\n /** True when policy said `ask` and the client hasn't decided yet. */\n needsApproval: boolean\n}\n\n/** Resolve a harness permission request against policy + client approvals. */\nexport function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {\n const base =\n input.command !== undefined\n ? evaluateCommand(input.command, input.policy, input.scripts)\n : input.capability !== undefined\n ? (input.policy?.capabilities?.[input.capability] ??\n input.policy?.default ??\n 'ask')\n : (input.policy?.default ?? 'ask')\n\n if (base === 'allow') return { decision: 'allow', needsApproval: false }\n if (base === 'deny') return { decision: 'deny', needsApproval: false }\n\n // base === 'ask' — consult the client's decision.\n const granted = input.approvals?.get(input.id)\n if (granted === true) return { decision: 'allow', needsApproval: false }\n if (granted === false) return { decision: 'deny', needsApproval: false }\n return { decision: 'deny', needsApproval: true }\n}\n\n/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */\nexport function buildApprovalRequestedEvent(input: {\n approvalId: string\n title: string\n threadId: string\n runId: string\n detail?: Record<string, unknown>\n}): StreamChunk {\n return {\n type: EventType.CUSTOM,\n name: APPROVAL_REQUESTED_EVENT,\n value: {\n approvalId: input.approvalId,\n title: input.title,\n ...(input.detail ?? {}),\n },\n timestamp: Date.now(),\n threadId: input.threadId,\n runId: input.runId,\n }\n}\n"],"names":[],"mappings":";;AAuBO,MAAM,2BAA2B;AAGjC,SAAS,WAAW,OAIhB;AACT,SAAO,GAAG,MAAM,QAAQ,IAAI,MAAM,IAAI,IAAI,MAAM,MAAM;AACxD;AAuBO,SAAS,gBAAgB,OAA8C;AAC5E,QAAM,OACJ,MAAM,YAAY,SACd,gBAAgB,MAAM,SAAS,MAAM,QAAQ,MAAM,OAAO,IAC1D,MAAM,eAAe,SAClB,MAAM,QAAQ,eAAe,MAAM,UAAU,KAC9C,MAAM,QAAQ,WACd,QACC,MAAM,QAAQ,WAAW;AAElC,MAAI,SAAS,QAAS,QAAO,EAAE,UAAU,SAAS,eAAe,MAAA;AACjE,MAAI,SAAS,OAAQ,QAAO,EAAE,UAAU,QAAQ,eAAe,MAAA;AAG/D,QAAM,UAAU,MAAM,WAAW,IAAI,MAAM,EAAE;AAC7C,MAAI,YAAY,KAAM,QAAO,EAAE,UAAU,SAAS,eAAe,MAAA;AACjE,MAAI,YAAY,MAAO,QAAO,EAAE,UAAU,QAAQ,eAAe,MAAA;AACjE,SAAO,EAAE,UAAU,QAAQ,eAAe,KAAA;AAC5C;AAGO,SAAS,4BAA4B,OAM5B;AACd,SAAO;AAAA,IACL,MAAM,UAAU;AAAA,IAChB,MAAM;AAAA,IACN,OAAO;AAAA,MACL,YAAY,MAAM;AAAA,MAClB,OAAO,MAAM;AAAA,MACb,GAAI,MAAM,UAAU,CAAA;AAAA,IAAC;AAAA,IAEvB,WAAW,KAAK,IAAA;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,OAAO,MAAM;AAAA,EAAA;AAEjB;"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { SandboxHandle } from './contracts.js';
|
|
2
|
+
import { PackageManager, WorkspaceDefinition } from './workspace.js';
|
|
3
|
+
export declare const DEFAULT_WORKSPACE_ROOT = "/workspace";
|
|
4
|
+
/** Resolve the package manager, detecting from a lockfile when `'auto'`. */
|
|
5
|
+
export declare function detectPackageManager(handle: SandboxHandle, workspace: WorkspaceDefinition, root: string): Promise<Exclude<PackageManager, 'auto'> | undefined>;
|
|
6
|
+
export interface BootstrapResult {
|
|
7
|
+
packageManager?: Exclude<PackageManager, 'auto'>;
|
|
8
|
+
ranSetup: Array<string>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Bootstrap a freshly created sandbox's workspace. Idempotent enough to be safe
|
|
12
|
+
* on restore: a git clone into a populated dir is skipped by checking for the
|
|
13
|
+
* target dir first.
|
|
14
|
+
*/
|
|
15
|
+
export declare function bootstrapWorkspace(handle: SandboxHandle, workspace: WorkspaceDefinition, options?: {
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}): Promise<BootstrapResult>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { buildSetupPlan } from "./setup-plan.js";
|
|
2
|
+
import { createBootstrapShell } from "./shell.js";
|
|
3
|
+
import { resolveGitSkillDir, mergeAgentsContent, writeAgentsFile } from "./agents-file.js";
|
|
4
|
+
import { resolveAllSecrets, resolveSecret } from "./secrets.js";
|
|
5
|
+
const LOCKFILES = {
|
|
6
|
+
pnpm: "pnpm-lock.yaml",
|
|
7
|
+
yarn: "yarn.lock",
|
|
8
|
+
bun: "bun.lockb",
|
|
9
|
+
npm: "package-lock.json"
|
|
10
|
+
};
|
|
11
|
+
const DEFAULT_WORKSPACE_ROOT = "/workspace";
|
|
12
|
+
async function detectPackageManager(handle, workspace, root) {
|
|
13
|
+
const pm = workspace.packageManager ?? "auto";
|
|
14
|
+
if (pm !== "auto") return pm;
|
|
15
|
+
for (const [manager, lockfile] of Object.entries(LOCKFILES)) {
|
|
16
|
+
if (await handle.fs.exists(`${root}/${lockfile}`)) return manager;
|
|
17
|
+
}
|
|
18
|
+
return void 0;
|
|
19
|
+
}
|
|
20
|
+
async function bootstrapWorkspace(handle, workspace, options = {}) {
|
|
21
|
+
const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT;
|
|
22
|
+
if (workspace.secrets !== void 0) {
|
|
23
|
+
const resolved = resolveAllSecrets(workspace.secrets);
|
|
24
|
+
if (Object.keys(resolved).length > 0) {
|
|
25
|
+
await handle.env.set(resolved);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (workspace.source.type === "git") {
|
|
29
|
+
const alreadyCloned = await handle.fs.exists(`${root}/.git`);
|
|
30
|
+
if (!alreadyCloned) {
|
|
31
|
+
await handle.git.clone({
|
|
32
|
+
url: workspace.source.url,
|
|
33
|
+
ref: workspace.source.ref,
|
|
34
|
+
auth: workspace.source.auth,
|
|
35
|
+
...workspace.source.depth !== void 0 ? { depth: workspace.source.depth } : {}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const skills = workspace.skills ?? [];
|
|
40
|
+
for (const skill of skills) {
|
|
41
|
+
if (skill.kind === "git") {
|
|
42
|
+
const url = skill.repo.startsWith("http") ? skill.repo : `https://github.com/${skill.repo}.git`;
|
|
43
|
+
const dir = skill.into ?? resolveGitSkillDir(root, skill);
|
|
44
|
+
const auth = skill.secret !== void 0 && workspace.secrets !== void 0 ? { token: resolveSecret(workspace.secrets, skill.secret) } : void 0;
|
|
45
|
+
await handle.git.clone({
|
|
46
|
+
url,
|
|
47
|
+
dir,
|
|
48
|
+
...auth !== void 0 ? { auth } : {},
|
|
49
|
+
depth: 1
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let agentsContent;
|
|
54
|
+
if (workspace.instructions !== void 0 && workspace.instructions.length > 0) {
|
|
55
|
+
agentsContent = workspace.instructions;
|
|
56
|
+
} else {
|
|
57
|
+
const agentsFileSkill = skills.find(
|
|
58
|
+
(s) => s.kind === "file" && s.path === "AGENTS.md"
|
|
59
|
+
);
|
|
60
|
+
if (agentsFileSkill !== void 0) {
|
|
61
|
+
agentsContent = agentsFileSkill.content;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
agentsContent = mergeAgentsContent(agentsContent, workspace.scripts);
|
|
65
|
+
if (agentsContent !== void 0) {
|
|
66
|
+
await writeAgentsFile(handle, root, agentsContent);
|
|
67
|
+
}
|
|
68
|
+
for (const skill of skills) {
|
|
69
|
+
if (skill.kind === "file" && skill.path !== "AGENTS.md") {
|
|
70
|
+
await handle.fs.write(`${root}/${skill.path}`, skill.content);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const packageManager = await detectPackageManager(handle, workspace, root);
|
|
74
|
+
const ranSetup = [];
|
|
75
|
+
const plan = buildSetupPlan(workspace.setup);
|
|
76
|
+
if (plan.length > 0) {
|
|
77
|
+
const shell = await createBootstrapShell(handle, { cwd: root });
|
|
78
|
+
try {
|
|
79
|
+
for (const group of plan) {
|
|
80
|
+
if (group.kind === "serial") {
|
|
81
|
+
const result = await shell.run(group.command);
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
const tail = result.stdout.trim().slice(-1500);
|
|
84
|
+
throw new Error(
|
|
85
|
+
`setup step failed: ${group.command} (exit ${result.exitCode})${tail ? `
|
|
86
|
+
${tail}` : ""}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
ranSetup.push(group.command);
|
|
90
|
+
} else {
|
|
91
|
+
const { cwd, env } = await shell.forkState();
|
|
92
|
+
const results = await Promise.all(
|
|
93
|
+
group.commands.map(
|
|
94
|
+
(command) => handle.process.exec(command, {
|
|
95
|
+
cwd,
|
|
96
|
+
env,
|
|
97
|
+
...options.signal ? { signal: options.signal } : {}
|
|
98
|
+
}).then((res) => ({ command, res }))
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
const failed = results.find((entry) => entry.res.exitCode !== 0);
|
|
102
|
+
if (failed !== void 0) {
|
|
103
|
+
const tail = `${failed.res.stdout}
|
|
104
|
+
${failed.res.stderr}`.trim().slice(-1500);
|
|
105
|
+
throw new Error(
|
|
106
|
+
`setup step failed: ${failed.command} (exit ${failed.res.exitCode})${tail ? `
|
|
107
|
+
${tail}` : ""}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
ranSetup.push(...group.commands);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
await shell.dispose();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { packageManager, ranSetup };
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
DEFAULT_WORKSPACE_ROOT,
|
|
121
|
+
bootstrapWorkspace,
|
|
122
|
+
detectPackageManager
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=bootstrap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bootstrap.js","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"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StreamChunk } from '@tanstack/ai';
|
|
2
|
+
export interface BridgeEventChannel {
|
|
3
|
+
/** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */
|
|
4
|
+
emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void;
|
|
5
|
+
/** Live CUSTOM-chunk stream; ends after {@link close} once drained. */
|
|
6
|
+
stream: AsyncIterable<StreamChunk>;
|
|
7
|
+
/** Stop the stream (call when the run's main output is done). */
|
|
8
|
+
close: () => void;
|
|
9
|
+
}
|
|
10
|
+
/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */
|
|
11
|
+
export declare function createBridgeEventChannel(meta: {
|
|
12
|
+
model: string;
|
|
13
|
+
threadId?: string;
|
|
14
|
+
runId?: string;
|
|
15
|
+
}): BridgeEventChannel;
|
|
16
|
+
/**
|
|
17
|
+
* Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever
|
|
18
|
+
* settles first. Terminates when `base` ends (the run is over), then releases the
|
|
19
|
+
* side iterator — so a never-ending channel (until closed) doesn't hang the merge.
|
|
20
|
+
*/
|
|
21
|
+
export declare function mergeChunkStreams(base: AsyncIterable<StreamChunk>, side: AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { EventType } from "@tanstack/ai";
|
|
2
|
+
function createBridgeEventChannel(meta) {
|
|
3
|
+
const buffer = [];
|
|
4
|
+
let notify = null;
|
|
5
|
+
let closed = false;
|
|
6
|
+
async function* stream() {
|
|
7
|
+
for (; ; ) {
|
|
8
|
+
const next = buffer.shift();
|
|
9
|
+
if (next !== void 0) {
|
|
10
|
+
yield next;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (closed) return;
|
|
14
|
+
await new Promise((resolve) => {
|
|
15
|
+
notify = resolve;
|
|
16
|
+
});
|
|
17
|
+
notify = null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
emitCustomEvent(eventName, value) {
|
|
22
|
+
if (closed) return;
|
|
23
|
+
buffer.push({
|
|
24
|
+
type: EventType.CUSTOM,
|
|
25
|
+
name: eventName,
|
|
26
|
+
value,
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
model: meta.model,
|
|
29
|
+
...meta.threadId !== void 0 && { threadId: meta.threadId },
|
|
30
|
+
...meta.runId !== void 0 && { runId: meta.runId }
|
|
31
|
+
});
|
|
32
|
+
notify?.();
|
|
33
|
+
},
|
|
34
|
+
close() {
|
|
35
|
+
closed = true;
|
|
36
|
+
notify?.();
|
|
37
|
+
},
|
|
38
|
+
stream: stream()
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function* mergeChunkStreams(base, side) {
|
|
42
|
+
const baseIt = base[Symbol.asyncIterator]();
|
|
43
|
+
const sideIt = side[Symbol.asyncIterator]();
|
|
44
|
+
let baseNext = baseIt.next().then((r) => ({ from: "base", r }));
|
|
45
|
+
let sideNext = sideIt.next().then((r) => ({ from: "side", r }));
|
|
46
|
+
let sideLive = true;
|
|
47
|
+
try {
|
|
48
|
+
for (; ; ) {
|
|
49
|
+
const winner = await Promise.race(
|
|
50
|
+
sideLive ? [baseNext, sideNext] : [baseNext]
|
|
51
|
+
);
|
|
52
|
+
if (winner.from === "base") {
|
|
53
|
+
if (winner.r.done) return;
|
|
54
|
+
yield winner.r.value;
|
|
55
|
+
baseNext = baseIt.next().then((r) => ({ from: "base", r }));
|
|
56
|
+
} else if (winner.r.done) {
|
|
57
|
+
sideLive = false;
|
|
58
|
+
} else {
|
|
59
|
+
yield winner.r.value;
|
|
60
|
+
sideNext = sideIt.next().then((r) => ({ from: "side", r }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
const baseReturn = baseIt.return?.(void 0);
|
|
65
|
+
if (baseReturn) void baseReturn.catch(() => {
|
|
66
|
+
});
|
|
67
|
+
const sideReturn = sideIt.return?.(void 0);
|
|
68
|
+
if (sideReturn) void sideReturn.catch(() => {
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
createBridgeEventChannel,
|
|
74
|
+
mergeChunkStreams
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=bridge-events.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bridge-events.js","sources":["../../src/bridge-events.ts"],"sourcesContent":["/**\n * Helpers that let a harness adapter surface custom events emitted by BRIDGED\n * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output\n * stream.\n *\n * The bridge runs out-of-band from the harness's own event stream, so a bridged\n * tool's progress/console events have no path to the client on their own. An\n * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to\n * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's\n * stream into its translated output — so events interleave live while the agent\n * runs (e.g. code mode's `code_mode:console` logs during a long execution).\n */\nimport { EventType } from '@tanstack/ai'\nimport type { StreamChunk } from '@tanstack/ai'\n\nexport interface BridgeEventChannel {\n /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */\n emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void\n /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */\n stream: AsyncIterable<StreamChunk>\n /** Stop the stream (call when the run's main output is done). */\n close: () => void\n}\n\n/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */\nexport function createBridgeEventChannel(meta: {\n model: string\n threadId?: string\n runId?: string\n}): BridgeEventChannel {\n const buffer: Array<StreamChunk> = []\n let notify: (() => void) | null = null\n let closed = false\n\n async function* stream(): AsyncIterable<StreamChunk> {\n for (;;) {\n const next = buffer.shift()\n if (next !== undefined) {\n yield next\n continue\n }\n if (closed) return\n await new Promise<void>((resolve) => {\n notify = resolve\n })\n notify = null\n }\n }\n\n return {\n emitCustomEvent(eventName, value) {\n if (closed) return\n buffer.push({\n type: EventType.CUSTOM,\n name: eventName,\n value,\n timestamp: Date.now(),\n model: meta.model,\n ...(meta.threadId !== undefined && { threadId: meta.threadId }),\n ...(meta.runId !== undefined && { runId: meta.runId }),\n })\n notify?.()\n },\n close() {\n closed = true\n notify?.()\n },\n stream: stream(),\n }\n}\n\n/**\n * Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever\n * settles first. Terminates when `base` ends (the run is over), then releases the\n * side iterator — so a never-ending channel (until closed) doesn't hang the merge.\n */\nexport async function* mergeChunkStreams(\n base: AsyncIterable<StreamChunk>,\n side: AsyncIterable<StreamChunk>,\n): AsyncIterable<StreamChunk> {\n const baseIt = base[Symbol.asyncIterator]()\n const sideIt = side[Symbol.asyncIterator]()\n let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n let sideLive = true\n try {\n for (;;) {\n const winner = await Promise.race(\n sideLive ? [baseNext, sideNext] : [baseNext],\n )\n if (winner.from === 'base') {\n if (winner.r.done) return\n yield winner.r.value\n baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n } else if (winner.r.done) {\n sideLive = false\n } else {\n yield winner.r.value\n sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n }\n }\n } finally {\n // Fire-and-forget: do NOT await the side return. The channel generator is\n // suspended on a promise that only `close()` resolves, and `close()` runs in\n // the adapter's `finally` AFTER this merge completes — awaiting here would\n // deadlock. The adapter's `close()` lets the generator unwind afterwards.\n const baseReturn = baseIt.return?.(undefined)\n if (baseReturn) void baseReturn.catch(() => {})\n const sideReturn = sideIt.return?.(undefined)\n if (sideReturn) void sideReturn.catch(() => {})\n }\n}\n"],"names":[],"mappings":";AAyBO,SAAS,yBAAyB,MAIlB;AACrB,QAAM,SAA6B,CAAA;AACnC,MAAI,SAA8B;AAClC,MAAI,SAAS;AAEb,kBAAgB,SAAqC;AACnD,eAAS;AACP,YAAM,OAAO,OAAO,MAAA;AACpB,UAAI,SAAS,QAAW;AACtB,cAAM;AACN;AAAA,MACF;AACA,UAAI,OAAQ;AACZ,YAAM,IAAI,QAAc,CAAC,YAAY;AACnC,iBAAS;AAAA,MACX,CAAC;AACD,eAAS;AAAA,IACX;AAAA,EACF;AAEA,SAAO;AAAA,IACL,gBAAgB,WAAW,OAAO;AAChC,UAAI,OAAQ;AACZ,aAAO,KAAK;AAAA,QACV,MAAM,UAAU;AAAA,QAChB,MAAM;AAAA,QACN;AAAA,QACA,WAAW,KAAK,IAAA;AAAA,QAChB,OAAO,KAAK;AAAA,QACZ,GAAI,KAAK,aAAa,UAAa,EAAE,UAAU,KAAK,SAAA;AAAA,QACpD,GAAI,KAAK,UAAU,UAAa,EAAE,OAAO,KAAK,MAAA;AAAA,MAAM,CACrD;AACD,eAAA;AAAA,IACF;AAAA,IACA,QAAQ;AACN,eAAS;AACT,eAAA;AAAA,IACF;AAAA,IACA,QAAQ,OAAA;AAAA,EAAO;AAEnB;AAOA,gBAAuB,kBACrB,MACA,MAC4B;AAC5B,QAAM,SAAS,KAAK,OAAO,aAAa,EAAA;AACxC,QAAM,SAAS,KAAK,OAAO,aAAa,EAAA;AACxC,MAAI,WAAW,OAAO,KAAA,EAAO,KAAK,CAAC,OAAO,EAAE,MAAM,QAAiB,EAAA,EAAI;AACvE,MAAI,WAAW,OAAO,KAAA,EAAO,KAAK,CAAC,OAAO,EAAE,MAAM,QAAiB,EAAA,EAAI;AACvE,MAAI,WAAW;AACf,MAAI;AACF,eAAS;AACP,YAAM,SAAS,MAAM,QAAQ;AAAA,QAC3B,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC,QAAQ;AAAA,MAAA;AAE7C,UAAI,OAAO,SAAS,QAAQ;AAC1B,YAAI,OAAO,EAAE,KAAM;AACnB,cAAM,OAAO,EAAE;AACf,mBAAW,OAAO,KAAA,EAAO,KAAK,CAAC,OAAO,EAAE,MAAM,QAAiB,EAAA,EAAI;AAAA,MACrE,WAAW,OAAO,EAAE,MAAM;AACxB,mBAAW;AAAA,MACb,OAAO;AACL,cAAM,OAAO,EAAE;AACf,mBAAW,OAAO,KAAA,EAAO,KAAK,CAAC,OAAO,EAAE,MAAM,QAAiB,EAAA,EAAI;AAAA,MACrE;AAAA,IACF;AAAA,EACF,UAAA;AAKE,UAAM,aAAa,OAAO,SAAS,MAAS;AAC5C,QAAI,WAAY,MAAK,WAAW,MAAM,MAAM;AAAA,IAAC,CAAC;AAC9C,UAAM,aAAa,OAAO,SAAS,MAAS;AAC5C,QAAI,WAAY,MAAK,WAAW,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAChD;AACF;"}
|