@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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal AGENTS.md writer with per-CLI symlink projection, plus the
|
|
3
|
+
* canonical helper for locating cloned gitSkill repositories inside a sandbox.
|
|
4
|
+
*
|
|
5
|
+
* The known-names set below lists the canonical instruction-file names for
|
|
6
|
+
* each AI coding assistant CLI. Keep the list in one place so it is easy to
|
|
7
|
+
* extend. The copy fallback ensures correctness on platforms without symlink
|
|
8
|
+
* support (e.g. Windows).
|
|
9
|
+
*
|
|
10
|
+
* External per-CLI convention: each assistant looks for its own instruction
|
|
11
|
+
* file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …).
|
|
12
|
+
* We write a single authoritative AGENTS.md and point each name at it.
|
|
13
|
+
*/
|
|
14
|
+
import type { SandboxHandle } from './contracts'
|
|
15
|
+
import type { WorkspaceSkill } from './workspace'
|
|
16
|
+
|
|
17
|
+
/** CLI instruction-file names that should resolve to AGENTS.md. */
|
|
18
|
+
const SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the directory a `gitSkill` repo is cloned into when no explicit
|
|
22
|
+
* `into` override is provided. The convention is:
|
|
23
|
+
*
|
|
24
|
+
* `<root>/.tanstack-skills/<basename>`
|
|
25
|
+
*
|
|
26
|
+
* where `basename` is derived from the `repo` field by taking the last
|
|
27
|
+
* path segment and stripping a trailing `.git` suffix.
|
|
28
|
+
*
|
|
29
|
+
* Per-harness projectors (e.g. the Claude Code adapter) import this helper
|
|
30
|
+
* so they can locate cloned skill repos consistently.
|
|
31
|
+
*
|
|
32
|
+
* @param root - Workspace root inside the sandbox (e.g. `/workspace`).
|
|
33
|
+
* @param skill - A `WorkspaceSkill` of `kind === 'git'`.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveGitSkillDir(
|
|
36
|
+
root: string,
|
|
37
|
+
skill: Extract<WorkspaceSkill, { kind: 'git' }>,
|
|
38
|
+
): string {
|
|
39
|
+
const rawBasename = skill.repo.split('/').pop() ?? skill.repo
|
|
40
|
+
const basename = rawBasename.endsWith('.git')
|
|
41
|
+
? rawBasename.slice(0, -4)
|
|
42
|
+
: rawBasename
|
|
43
|
+
return `${root}/.tanstack-skills/${basename}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Format workspace scripts as a `## Workspace scripts` markdown section. */
|
|
47
|
+
export function formatWorkspaceScriptsSection(
|
|
48
|
+
scripts: Record<string, string>,
|
|
49
|
+
): string {
|
|
50
|
+
const names = Object.keys(scripts).sort()
|
|
51
|
+
if (names.length === 0) return ''
|
|
52
|
+
const lines = names.map((name) => `- ${name} → ${scripts[name]}`)
|
|
53
|
+
return `## Workspace scripts\n\n${lines.join('\n')}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge base AGENTS.md content with an optional workspace scripts section.
|
|
58
|
+
* Returns `undefined` when there is nothing to write.
|
|
59
|
+
*/
|
|
60
|
+
export function mergeAgentsContent(
|
|
61
|
+
base: string | undefined,
|
|
62
|
+
scripts: Record<string, string> | undefined,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
const scriptsSection =
|
|
65
|
+
scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : ''
|
|
66
|
+
if (base === undefined && scriptsSection.length === 0) return undefined
|
|
67
|
+
if (base === undefined) return scriptsSection
|
|
68
|
+
if (scriptsSection.length === 0) return base
|
|
69
|
+
return `${base.trimEnd()}\n\n${scriptsSection}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Escape a string for safe use as a single-quoted shell argument. */
|
|
73
|
+
function sqEscape(value: string): string {
|
|
74
|
+
return value.replace(/'/g, `'\\''`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a
|
|
79
|
+
* fallback when `ln -s` is unavailable).
|
|
80
|
+
*
|
|
81
|
+
* @param handle - The sandbox handle providing `fs` and `process`.
|
|
82
|
+
* @param root - Absolute path inside the sandbox under which to write.
|
|
83
|
+
* @param content - Markdown content for the instruction file.
|
|
84
|
+
*/
|
|
85
|
+
export async function writeAgentsFile(
|
|
86
|
+
handle: SandboxHandle,
|
|
87
|
+
root: string,
|
|
88
|
+
content: string,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const agentsPath = `${root}/AGENTS.md`
|
|
91
|
+
await handle.fs.write(agentsPath, content)
|
|
92
|
+
|
|
93
|
+
for (const name of SYMLINK_NAMES) {
|
|
94
|
+
const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'`
|
|
95
|
+
const result = await handle.process.exec(lnCmd, { cwd: root })
|
|
96
|
+
if (result.exitCode !== 0) {
|
|
97
|
+
// Symlinks are not supported on this platform — fall back to a copy.
|
|
98
|
+
await handle.fs.write(`${root}/${name}`, content)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/approvals.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared interactive-approval logic for harness adapters.
|
|
3
|
+
*
|
|
4
|
+
* Flow (rides chat()'s existing resume-based approval mechanism):
|
|
5
|
+
* 1. The agent (inside the sandbox) asks to run a risky action; the harness's
|
|
6
|
+
* host-side permission callback fires.
|
|
7
|
+
* 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;
|
|
8
|
+
* `ask` consults the client's approval decisions (threaded via
|
|
9
|
+
* `TextOptions.approvals`, keyed by a stable `approvalId`).
|
|
10
|
+
* 3. On `ask` with no decision yet, the adapter emits an `approval-requested`
|
|
11
|
+
* CUSTOM event (carrying the `approvalId`) and denies the action this turn.
|
|
12
|
+
* The client shows UI, then re-runs chat() with the decision in the message;
|
|
13
|
+
* the engine surfaces it as `approvals`, and the next run allows it.
|
|
14
|
+
*
|
|
15
|
+
* `approvalId` is stable for a given (provider, kind, target) so a client grant
|
|
16
|
+
* matches the same action on the resumed run.
|
|
17
|
+
*/
|
|
18
|
+
import { EventType } from '@tanstack/ai'
|
|
19
|
+
import { evaluateCommand } from './policy'
|
|
20
|
+
import type { SandboxPolicy } from './policy'
|
|
21
|
+
import type { StreamChunk } from '@tanstack/ai'
|
|
22
|
+
|
|
23
|
+
/** CUSTOM event name emitted when a harness action needs client approval. */
|
|
24
|
+
export const APPROVAL_REQUESTED_EVENT = 'approval-requested'
|
|
25
|
+
|
|
26
|
+
/** A stable, opaque approval id for a harness action. */
|
|
27
|
+
export function approvalId(input: {
|
|
28
|
+
provider: string
|
|
29
|
+
kind: 'command' | 'fileWrite' | 'network' | 'tool'
|
|
30
|
+
target: string
|
|
31
|
+
}): string {
|
|
32
|
+
return `${input.provider}:${input.kind}:${input.target}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResolveApprovalInput {
|
|
36
|
+
policy: SandboxPolicy | undefined
|
|
37
|
+
/** Client approval decisions, keyed by `approvalId`. */
|
|
38
|
+
approvals: ReadonlyMap<string, boolean> | undefined
|
|
39
|
+
/** Precomputed approval id for this action. */
|
|
40
|
+
id: string
|
|
41
|
+
/** A shell command to match against `policy.commands`. */
|
|
42
|
+
command?: string
|
|
43
|
+
/** Named workspace scripts for policy alias resolution. */
|
|
44
|
+
scripts?: Record<string, string>
|
|
45
|
+
/** A coarse capability to match against `policy.capabilities`. */
|
|
46
|
+
capability?: 'fileWrite' | 'network'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ApprovalOutcome {
|
|
50
|
+
decision: 'allow' | 'deny'
|
|
51
|
+
/** True when policy said `ask` and the client hasn't decided yet. */
|
|
52
|
+
needsApproval: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve a harness permission request against policy + client approvals. */
|
|
56
|
+
export function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {
|
|
57
|
+
const base =
|
|
58
|
+
input.command !== undefined
|
|
59
|
+
? evaluateCommand(input.command, input.policy, input.scripts)
|
|
60
|
+
: input.capability !== undefined
|
|
61
|
+
? (input.policy?.capabilities?.[input.capability] ??
|
|
62
|
+
input.policy?.default ??
|
|
63
|
+
'ask')
|
|
64
|
+
: (input.policy?.default ?? 'ask')
|
|
65
|
+
|
|
66
|
+
if (base === 'allow') return { decision: 'allow', needsApproval: false }
|
|
67
|
+
if (base === 'deny') return { decision: 'deny', needsApproval: false }
|
|
68
|
+
|
|
69
|
+
// base === 'ask' — consult the client's decision.
|
|
70
|
+
const granted = input.approvals?.get(input.id)
|
|
71
|
+
if (granted === true) return { decision: 'allow', needsApproval: false }
|
|
72
|
+
if (granted === false) return { decision: 'deny', needsApproval: false }
|
|
73
|
+
return { decision: 'deny', needsApproval: true }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
|
|
77
|
+
export function buildApprovalRequestedEvent(input: {
|
|
78
|
+
approvalId: string
|
|
79
|
+
title: string
|
|
80
|
+
threadId: string
|
|
81
|
+
runId: string
|
|
82
|
+
detail?: Record<string, unknown>
|
|
83
|
+
}): StreamChunk {
|
|
84
|
+
return {
|
|
85
|
+
type: EventType.CUSTOM,
|
|
86
|
+
name: APPROVAL_REQUESTED_EVENT,
|
|
87
|
+
value: {
|
|
88
|
+
approvalId: input.approvalId,
|
|
89
|
+
title: input.title,
|
|
90
|
+
...(input.detail ?? {}),
|
|
91
|
+
},
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
threadId: input.threadId,
|
|
94
|
+
runId: input.runId,
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace bootstrap engine — provider-agnostic because it only uses the
|
|
3
|
+
* {@link SandboxHandle} contract. Runs once when a sandbox is freshly created
|
|
4
|
+
* (or restored without its working tree): land the source, inject secrets,
|
|
5
|
+
* detect the package manager, and run setup commands.
|
|
6
|
+
*
|
|
7
|
+
* Harness-specific projection (CLAUDE.md, agent skills, MCP config) is NOT done
|
|
8
|
+
* here — that's each adapter's `projectWorkspace()` hook, since the format
|
|
9
|
+
* differs per harness.
|
|
10
|
+
*/
|
|
11
|
+
import { buildSetupPlan } from './setup-plan'
|
|
12
|
+
import { createBootstrapShell } from './shell'
|
|
13
|
+
import {
|
|
14
|
+
mergeAgentsContent,
|
|
15
|
+
resolveGitSkillDir,
|
|
16
|
+
writeAgentsFile,
|
|
17
|
+
} from './agents-file'
|
|
18
|
+
import { resolveAllSecrets, resolveSecret } from './secrets'
|
|
19
|
+
import type { SandboxHandle } from './contracts'
|
|
20
|
+
import type { PackageManager, WorkspaceDefinition } from './workspace'
|
|
21
|
+
|
|
22
|
+
const LOCKFILES: Record<Exclude<PackageManager, 'auto'>, string> = {
|
|
23
|
+
pnpm: 'pnpm-lock.yaml',
|
|
24
|
+
yarn: 'yarn.lock',
|
|
25
|
+
bun: 'bun.lockb',
|
|
26
|
+
npm: 'package-lock.json',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_WORKSPACE_ROOT = '/workspace'
|
|
30
|
+
|
|
31
|
+
/** Resolve the package manager, detecting from a lockfile when `'auto'`. */
|
|
32
|
+
export async function detectPackageManager(
|
|
33
|
+
handle: SandboxHandle,
|
|
34
|
+
workspace: WorkspaceDefinition,
|
|
35
|
+
root: string,
|
|
36
|
+
): Promise<Exclude<PackageManager, 'auto'> | undefined> {
|
|
37
|
+
const pm = workspace.packageManager ?? 'auto'
|
|
38
|
+
if (pm !== 'auto') return pm
|
|
39
|
+
for (const [manager, lockfile] of Object.entries(LOCKFILES) as Array<
|
|
40
|
+
[Exclude<PackageManager, 'auto'>, string]
|
|
41
|
+
>) {
|
|
42
|
+
if (await handle.fs.exists(`${root}/${lockfile}`)) return manager
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BootstrapResult {
|
|
48
|
+
packageManager?: Exclude<PackageManager, 'auto'>
|
|
49
|
+
ranSetup: Array<string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bootstrap a freshly created sandbox's workspace. Idempotent enough to be safe
|
|
54
|
+
* on restore: a git clone into a populated dir is skipped by checking for the
|
|
55
|
+
* target dir first.
|
|
56
|
+
*/
|
|
57
|
+
export async function bootstrapWorkspace(
|
|
58
|
+
handle: SandboxHandle,
|
|
59
|
+
workspace: WorkspaceDefinition,
|
|
60
|
+
options: { signal?: AbortSignal } = {},
|
|
61
|
+
): Promise<BootstrapResult> {
|
|
62
|
+
const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT
|
|
63
|
+
|
|
64
|
+
// Secrets live only in the running sandbox env (never persisted).
|
|
65
|
+
if (workspace.secrets !== undefined) {
|
|
66
|
+
const resolved = resolveAllSecrets(workspace.secrets)
|
|
67
|
+
if (Object.keys(resolved).length > 0) {
|
|
68
|
+
await handle.env.set(resolved)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Land the source. Clone into the handle's own default root (each provider
|
|
73
|
+
// maps the conventional `/workspace` virtual root to its real backing dir),
|
|
74
|
+
// rather than passing a virtual `dir` that can't be remapped inside a shell
|
|
75
|
+
// command string.
|
|
76
|
+
if (workspace.source.type === 'git') {
|
|
77
|
+
const alreadyCloned = await handle.fs.exists(`${root}/.git`)
|
|
78
|
+
if (!alreadyCloned) {
|
|
79
|
+
await handle.git.clone({
|
|
80
|
+
url: workspace.source.url,
|
|
81
|
+
ref: workspace.source.ref,
|
|
82
|
+
auth: workspace.source.auth,
|
|
83
|
+
...(workspace.source.depth !== undefined
|
|
84
|
+
? { depth: workspace.source.depth }
|
|
85
|
+
: {}),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 'local' is provider-pre-populated at create; 'none' starts empty.
|
|
90
|
+
|
|
91
|
+
// Clone git-skill repos so setup steps (and the harness projector) can use
|
|
92
|
+
// them. gitSkill clones are always shallow (depth 1) unless the skill's own
|
|
93
|
+
// repo entry carries a depth override — the WorkspaceSkill `git` variant
|
|
94
|
+
// does not expose one, so depth always defaults to 1 inside git.clone.
|
|
95
|
+
const skills = workspace.skills ?? []
|
|
96
|
+
for (const skill of skills) {
|
|
97
|
+
if (skill.kind === 'git') {
|
|
98
|
+
const url = skill.repo.startsWith('http')
|
|
99
|
+
? skill.repo
|
|
100
|
+
: `https://github.com/${skill.repo}.git`
|
|
101
|
+
const dir = skill.into ?? resolveGitSkillDir(root, skill)
|
|
102
|
+
const auth =
|
|
103
|
+
skill.secret !== undefined && workspace.secrets !== undefined
|
|
104
|
+
? { token: resolveSecret(workspace.secrets, skill.secret) }
|
|
105
|
+
: undefined
|
|
106
|
+
await handle.git.clone({
|
|
107
|
+
url,
|
|
108
|
+
dir,
|
|
109
|
+
...(auth !== undefined ? { auth } : {}),
|
|
110
|
+
depth: 1,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write AGENTS.md (and its per-CLI symlinks) when instructions are provided
|
|
116
|
+
// directly on the workspace, via a fileSkill whose path is `AGENTS.md`, or
|
|
117
|
+
// when named workspace scripts should be surfaced for the agent.
|
|
118
|
+
let agentsContent: string | undefined
|
|
119
|
+
if (
|
|
120
|
+
workspace.instructions !== undefined &&
|
|
121
|
+
workspace.instructions.length > 0
|
|
122
|
+
) {
|
|
123
|
+
agentsContent = workspace.instructions
|
|
124
|
+
} else {
|
|
125
|
+
const agentsFileSkill = skills.find(
|
|
126
|
+
(s): s is Extract<typeof s, { kind: 'file' }> =>
|
|
127
|
+
s.kind === 'file' && s.path === 'AGENTS.md',
|
|
128
|
+
)
|
|
129
|
+
if (agentsFileSkill !== undefined) {
|
|
130
|
+
agentsContent = agentsFileSkill.content
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
agentsContent = mergeAgentsContent(agentsContent, workspace.scripts)
|
|
134
|
+
if (agentsContent !== undefined) {
|
|
135
|
+
await writeAgentsFile(handle, root, agentsContent)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Write all other fileSkills directly into the workspace root.
|
|
139
|
+
for (const skill of skills) {
|
|
140
|
+
if (skill.kind === 'file' && skill.path !== 'AGENTS.md') {
|
|
141
|
+
await handle.fs.write(`${root}/${skill.path}`, skill.content)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const packageManager = await detectPackageManager(handle, workspace, root)
|
|
146
|
+
|
|
147
|
+
// Run setup over a single persistent shell so `cd`/exports persist across
|
|
148
|
+
// serial steps. Parallel groups fork the shell's current cwd+env into
|
|
149
|
+
// concurrent one-shot exec calls.
|
|
150
|
+
const ranSetup: Array<string> = []
|
|
151
|
+
const plan = buildSetupPlan(workspace.setup)
|
|
152
|
+
if (plan.length > 0) {
|
|
153
|
+
const shell = await createBootstrapShell(handle, { cwd: root })
|
|
154
|
+
try {
|
|
155
|
+
for (const group of plan) {
|
|
156
|
+
if (group.kind === 'serial') {
|
|
157
|
+
const result = await shell.run(group.command)
|
|
158
|
+
if (result.exitCode !== 0) {
|
|
159
|
+
const tail = result.stdout.trim().slice(-1500)
|
|
160
|
+
throw new Error(
|
|
161
|
+
`setup step failed: ${group.command} (exit ${result.exitCode})${tail ? `\n${tail}` : ''}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
ranSetup.push(group.command)
|
|
165
|
+
} else {
|
|
166
|
+
const { cwd, env } = await shell.forkState()
|
|
167
|
+
const results = await Promise.all(
|
|
168
|
+
group.commands.map((command) =>
|
|
169
|
+
handle.process
|
|
170
|
+
.exec(command, {
|
|
171
|
+
cwd,
|
|
172
|
+
env,
|
|
173
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
174
|
+
})
|
|
175
|
+
.then((res) => ({ command, res })),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
const failed = results.find((entry) => entry.res.exitCode !== 0)
|
|
179
|
+
if (failed !== undefined) {
|
|
180
|
+
const tail = `${failed.res.stdout}\n${failed.res.stderr}`
|
|
181
|
+
.trim()
|
|
182
|
+
.slice(-1500)
|
|
183
|
+
throw new Error(
|
|
184
|
+
`setup step failed: ${failed.command} (exit ${failed.res.exitCode})${tail ? `\n${tail}` : ''}`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
ranSetup.push(...group.commands)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
await shell.dispose()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { packageManager, ranSetup }
|
|
196
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers that let a harness adapter surface custom events emitted by BRIDGED
|
|
3
|
+
* tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output
|
|
4
|
+
* stream.
|
|
5
|
+
*
|
|
6
|
+
* The bridge runs out-of-band from the harness's own event stream, so a bridged
|
|
7
|
+
* tool's progress/console events have no path to the client on their own. An
|
|
8
|
+
* adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to
|
|
9
|
+
* the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's
|
|
10
|
+
* stream into its translated output — so events interleave live while the agent
|
|
11
|
+
* runs (e.g. code mode's `code_mode:console` logs during a long execution).
|
|
12
|
+
*/
|
|
13
|
+
import { EventType } from '@tanstack/ai'
|
|
14
|
+
import type { StreamChunk } from '@tanstack/ai'
|
|
15
|
+
|
|
16
|
+
export interface BridgeEventChannel {
|
|
17
|
+
/** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */
|
|
18
|
+
emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void
|
|
19
|
+
/** Live CUSTOM-chunk stream; ends after {@link close} once drained. */
|
|
20
|
+
stream: AsyncIterable<StreamChunk>
|
|
21
|
+
/** Stop the stream (call when the run's main output is done). */
|
|
22
|
+
close: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */
|
|
26
|
+
export function createBridgeEventChannel(meta: {
|
|
27
|
+
model: string
|
|
28
|
+
threadId?: string
|
|
29
|
+
runId?: string
|
|
30
|
+
}): BridgeEventChannel {
|
|
31
|
+
const buffer: Array<StreamChunk> = []
|
|
32
|
+
let notify: (() => void) | null = null
|
|
33
|
+
let closed = false
|
|
34
|
+
|
|
35
|
+
async function* stream(): AsyncIterable<StreamChunk> {
|
|
36
|
+
for (;;) {
|
|
37
|
+
const next = buffer.shift()
|
|
38
|
+
if (next !== undefined) {
|
|
39
|
+
yield next
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (closed) return
|
|
43
|
+
await new Promise<void>((resolve) => {
|
|
44
|
+
notify = resolve
|
|
45
|
+
})
|
|
46
|
+
notify = null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
emitCustomEvent(eventName, value) {
|
|
52
|
+
if (closed) return
|
|
53
|
+
buffer.push({
|
|
54
|
+
type: EventType.CUSTOM,
|
|
55
|
+
name: eventName,
|
|
56
|
+
value,
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
model: meta.model,
|
|
59
|
+
...(meta.threadId !== undefined && { threadId: meta.threadId }),
|
|
60
|
+
...(meta.runId !== undefined && { runId: meta.runId }),
|
|
61
|
+
})
|
|
62
|
+
notify?.()
|
|
63
|
+
},
|
|
64
|
+
close() {
|
|
65
|
+
closed = true
|
|
66
|
+
notify?.()
|
|
67
|
+
},
|
|
68
|
+
stream: stream(),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever
|
|
74
|
+
* settles first. Terminates when `base` ends (the run is over), then releases the
|
|
75
|
+
* side iterator — so a never-ending channel (until closed) doesn't hang the merge.
|
|
76
|
+
*/
|
|
77
|
+
export async function* mergeChunkStreams(
|
|
78
|
+
base: AsyncIterable<StreamChunk>,
|
|
79
|
+
side: AsyncIterable<StreamChunk>,
|
|
80
|
+
): AsyncIterable<StreamChunk> {
|
|
81
|
+
const baseIt = base[Symbol.asyncIterator]()
|
|
82
|
+
const sideIt = side[Symbol.asyncIterator]()
|
|
83
|
+
let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))
|
|
84
|
+
let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))
|
|
85
|
+
let sideLive = true
|
|
86
|
+
try {
|
|
87
|
+
for (;;) {
|
|
88
|
+
const winner = await Promise.race(
|
|
89
|
+
sideLive ? [baseNext, sideNext] : [baseNext],
|
|
90
|
+
)
|
|
91
|
+
if (winner.from === 'base') {
|
|
92
|
+
if (winner.r.done) return
|
|
93
|
+
yield winner.r.value
|
|
94
|
+
baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))
|
|
95
|
+
} else if (winner.r.done) {
|
|
96
|
+
sideLive = false
|
|
97
|
+
} else {
|
|
98
|
+
yield winner.r.value
|
|
99
|
+
sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
// Fire-and-forget: do NOT await the side return. The channel generator is
|
|
104
|
+
// suspended on a promise that only `close()` resolves, and `close()` runs in
|
|
105
|
+
// the adapter's `finally` AFTER this merge completes — awaiting here would
|
|
106
|
+
// deadlock. The adapter's `close()` lets the generator unwind afterwards.
|
|
107
|
+
const baseReturn = baseIt.return?.(undefined)
|
|
108
|
+
if (baseReturn) void baseReturn.catch(() => {})
|
|
109
|
+
const sideReturn = sideIt.return?.(undefined)
|
|
110
|
+
if (sideReturn) void sideReturn.catch(() => {})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability tokens the sandbox layer provides/consumes through the
|
|
3
|
+
* `@tanstack/ai` middleware capability system.
|
|
4
|
+
*
|
|
5
|
+
* - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness
|
|
6
|
+
* adapters (`requires: [SandboxCapability]`).
|
|
7
|
+
* - `SandboxStoreCapability` / `LocksCapability` are OPTIONALLY required by
|
|
8
|
+
* `withSandbox`. v1 falls back to in-memory defaults; the future persistence
|
|
9
|
+
* package PROVIDES durable implementations.
|
|
10
|
+
*/
|
|
11
|
+
import { createCapability } from '@tanstack/ai'
|
|
12
|
+
import type { SandboxHandle } from './contracts'
|
|
13
|
+
import type { LockStore, SandboxStore } from './store'
|
|
14
|
+
import type { SandboxPolicy } from './policy'
|
|
15
|
+
import type { ToolBridgeProvisioner } from './tool-bridge'
|
|
16
|
+
|
|
17
|
+
export const SandboxCapability = createCapability<SandboxHandle>()('sandbox')
|
|
18
|
+
|
|
19
|
+
export const SandboxStoreCapability =
|
|
20
|
+
createCapability<SandboxStore>()('sandbox-store')
|
|
21
|
+
|
|
22
|
+
export const LocksCapability = createCapability<LockStore>()('locks')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The active sandbox policy, provided by `withSandbox` from the definition.
|
|
26
|
+
* Harness adapters read it to map allow/ask/deny rules onto their native
|
|
27
|
+
* permission system.
|
|
28
|
+
*/
|
|
29
|
+
export const SandboxPolicyCapability =
|
|
30
|
+
createCapability<SandboxPolicy>()('sandbox-policy')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Provisions the MCP tool-bridge endpoint for a run. OPTIONALLY provided by a
|
|
34
|
+
* serverless/edge orchestrator (e.g. a Durable Object) to override the default
|
|
35
|
+
* `node:http` host transport. Harness adapters read it via `getOptional` and
|
|
36
|
+
* fall back to `nodeHttpBridgeProvisioner` when absent.
|
|
37
|
+
*/
|
|
38
|
+
export const ToolBridgeProvisionerCapability =
|
|
39
|
+
createCapability<ToolBridgeProvisioner>()('tool-bridge-provisioner')
|
|
40
|
+
|
|
41
|
+
/** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */
|
|
42
|
+
export const [getSandbox, provideSandbox] = SandboxCapability
|
|
43
|
+
export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability
|
|
44
|
+
export const [getLocks, provideLocks] = LocksCapability
|
|
45
|
+
export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability
|
|
46
|
+
export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] =
|
|
47
|
+
ToolBridgeProvisionerCapability
|