@tealbrick/kit 0.3.0-rc.1 → 0.3.0-rc.10
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/BOOTSTRAP.md +9 -10
- package/BUZZ.md +146 -0
- package/NATIVE.md +10 -4
- package/README.md +263 -2
- package/RUNTIME.md +1 -1
- package/assets/buzz/buzz-backend-provider.py +99 -0
- package/assets/buzz/buzz-plist.py +22 -0
- package/assets/buzz/buzz-run.sh +39 -0
- package/assets/buzz/tb-provider-apply.py +97 -0
- package/dist/buzz.d.ts +63 -0
- package/dist/buzz.js +262 -0
- package/dist/cli.js +9 -4
- package/dist/index.d.ts +15 -14
- package/dist/index.js +1 -1
- package/dist/native-acp.d.ts +40 -0
- package/dist/native-acp.js +224 -0
- package/dist/native-app-capabilities.d.ts +33 -0
- package/dist/native-app-capabilities.js +37 -0
- package/dist/native-buzz.d.ts +161 -0
- package/dist/native-buzz.js +649 -0
- package/dist/native-child-guard.js +7 -2
- package/dist/native-claude.d.ts +99 -0
- package/dist/native-claude.js +584 -0
- package/dist/native-enroll.d.ts +22 -0
- package/dist/native-enroll.js +85 -0
- package/dist/native-runtime-sync.d.ts +49 -0
- package/dist/native-runtime-sync.js +101 -0
- package/dist/native-selection.js +19 -4
- package/dist/native-serve-config.d.ts +286 -0
- package/dist/native-serve-config.js +296 -0
- package/dist/native-serve.d.ts +76 -0
- package/dist/native-serve.js +145 -0
- package/dist/native-setup.js +38 -15
- package/dist/native-standing-grants.d.ts +105 -0
- package/dist/native-standing-grants.js +226 -0
- package/dist/native.d.ts +3 -0
- package/dist/native.js +94 -5
- package/dist/onboarding.d.ts +8 -0
- package/dist/onboarding.js +9 -2
- package/dist/runtime-onboarding.js +2 -2
- package/dist/secrets.d.ts +2 -1
- package/dist/secrets.js +4 -2
- package/package.json +9 -7
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { realpath } from 'node:fs/promises';
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, basename } from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { servePaths } from './native-serve-config.js';
|
|
11
|
+
import { standingGrantFor, standingGrantNote, standingGrantStore } from './native-standing-grants.js';
|
|
12
|
+
export async function loadClaudeSdk(root, sdkModule) {
|
|
13
|
+
let entry;
|
|
14
|
+
try {
|
|
15
|
+
entry = sdkModule ? createRequire(import.meta.url).resolve(sdkModule) : createRequire(join(root, 'package.json')).resolve('@anthropic-ai/claude-agent-sdk');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw Error('kit_native_claude_sdk_missing');
|
|
19
|
+
}
|
|
20
|
+
const sdk = await import(pathToFileURL(entry).href);
|
|
21
|
+
if (typeof sdk.query !== 'function')
|
|
22
|
+
throw Error('kit_native_claude_sdk_invalid');
|
|
23
|
+
return sdk;
|
|
24
|
+
}
|
|
25
|
+
/** Built-in tools Restricted mode exposes: read-only and contained to the workspace. */
|
|
26
|
+
export const readOnlyTools = ['Read', 'Glob', 'Grep'];
|
|
27
|
+
/**
|
|
28
|
+
* Built-in tools of the sandboxed workspace session. Every file operation goes through the OS-sandboxed
|
|
29
|
+
* Bash: the in-process file tools resolve paths outside the sandbox (symlink and check/use races), so
|
|
30
|
+
* the workspace session never gets any of them (see unsandboxedFileTools).
|
|
31
|
+
*/
|
|
32
|
+
/** POSIX single-quote a value for a shell command line. */
|
|
33
|
+
const shq = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
|
|
34
|
+
export const workspaceTools = ['Bash', 'WebSearch', 'WebFetch', 'Agent'];
|
|
35
|
+
/** Claude Code's per-cwd temp folder name: every non-alphanumeric character becomes '-'. */
|
|
36
|
+
export const claudeTmpSlug = (dir) => dir.replace(/[^A-Za-z0-9]/g, '-');
|
|
37
|
+
/** Claude Code's sandbox temp roots for this OS user (sandboxed Bash TMPDIR), shared by every session of the user. */
|
|
38
|
+
export const claudeTmpRoots = (uid) => [`/tmp/claude-${uid}`, `/private/tmp/claude-${uid}`];
|
|
39
|
+
/** In-process (unsandboxed) filesystem tools: never available in the sandboxed workspace session, owner or expert. */
|
|
40
|
+
export const unsandboxedFileTools = ['Read', 'Write', 'Edit', 'MultiEdit', 'Glob', 'Grep', 'LS', 'NotebookEdit', 'NotebookRead'];
|
|
41
|
+
/** Homebrew prefixes the sandboxed shell keeps reading (only those present are listed). Never the whole /opt/homebrew. */
|
|
42
|
+
export const homebrewReadRoots = ['bin', 'lib', 'include', 'share', 'opt', 'Cellar'].map(d => '/opt/homebrew/' + d);
|
|
43
|
+
/** Removed from the model's context unless a wider access mode explicitly lists them. */
|
|
44
|
+
export const restrictedDenied = ['Bash', 'BashOutput', 'KillShell', 'KillBash', 'Monitor', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'WebSearch', 'Agent', 'Task', 'TodoWrite', 'Skill', 'SlashCommand', 'ExitPlanMode', 'EnterWorktree', 'ExitWorktree'];
|
|
45
|
+
const pathTools = { Read: ['file_path'], Write: ['file_path'], Edit: ['file_path'], MultiEdit: ['file_path'], NotebookEdit: ['notebook_path'], Glob: ['path'], Grep: ['path'] };
|
|
46
|
+
const safeEnvNames = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG', 'LC_ALL', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS'];
|
|
47
|
+
/** Chat-relay identity secrets: never forwarded to the SDK process, even if listed in envPassthrough. */
|
|
48
|
+
export const neverForwardedEnv = ['BUZZ_PRIVATE_KEY', 'BUZZ_AUTH_TAG', 'BUZZ_ACP_PRIVATE_KEY', 'BUZZ_API_TOKEN', 'BUZZ_ACP_API_TOKEN', 'NOSTR_PRIVATE_KEY'];
|
|
49
|
+
async function contained(root, input, deny) {
|
|
50
|
+
let path = resolve(root, input), tail = [];
|
|
51
|
+
for (;;) {
|
|
52
|
+
try {
|
|
53
|
+
path = resolve(await realpath(path), ...tail);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
if (e.code !== 'ENOENT')
|
|
58
|
+
return false;
|
|
59
|
+
const parent = dirname(path);
|
|
60
|
+
if (parent === path)
|
|
61
|
+
return false;
|
|
62
|
+
tail.unshift(basename(path));
|
|
63
|
+
path = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const inside = (base) => { const rel = relative(base, path); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
|
67
|
+
if (deny.some(inside))
|
|
68
|
+
return false;
|
|
69
|
+
return inside(await realpath(root));
|
|
70
|
+
}
|
|
71
|
+
const PRIVATE_SUFFIXES = ['.localhost', '.local', '.internal', '.ts.net'];
|
|
72
|
+
function privateIPv4(ip) {
|
|
73
|
+
const [a = 0, b = 0] = ip.split('.').map(Number);
|
|
74
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
|
|
75
|
+
}
|
|
76
|
+
function ipv6Words(ip) {
|
|
77
|
+
let text = ip;
|
|
78
|
+
const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)?.[1];
|
|
79
|
+
if (v4) {
|
|
80
|
+
const [a, b, c, d] = v4.split('.').map(Number);
|
|
81
|
+
text = text.slice(0, -v4.length) + ((a << 8) | b).toString(16) + ':' + ((c << 8) | d).toString(16);
|
|
82
|
+
}
|
|
83
|
+
const halves = text.split('::');
|
|
84
|
+
if (halves.length > 2)
|
|
85
|
+
return undefined;
|
|
86
|
+
const part = (h) => h ? h.split(':').map(x => parseInt(x, 16)) : [];
|
|
87
|
+
const head = part(halves[0]), tail = halves.length === 2 ? part(halves[1]) : [];
|
|
88
|
+
const words = halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill(0), ...tail] : head;
|
|
89
|
+
return words.length === 8 && words.every(w => Number.isInteger(w) && w >= 0 && w <= 0xffff) ? words : undefined;
|
|
90
|
+
}
|
|
91
|
+
function privateIPv6(ip) {
|
|
92
|
+
const w = ipv6Words(ip);
|
|
93
|
+
if (!w)
|
|
94
|
+
return true;
|
|
95
|
+
const v4 = (hi, lo) => privateIPv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
96
|
+
if (w.slice(0, 7).every(x => x === 0))
|
|
97
|
+
return true; // :: and ::1
|
|
98
|
+
if (w.slice(0, 5).every(x => x === 0) && (w[5] === 0xffff || w[5] === 0))
|
|
99
|
+
return v4(w[6], w[7]); // v4-mapped / v4-compatible
|
|
100
|
+
if (w[0] === 0x64 && w[1] === 0xff9b)
|
|
101
|
+
return v4(w[6], w[7]); // NAT64
|
|
102
|
+
return (w[0] & 0xfe00) === 0xfc00 || (w[0] & 0xffc0) === 0xfe80 || (w[0] & 0xffc0) === 0xfec0 || (w[0] & 0xff00) === 0xff00;
|
|
103
|
+
}
|
|
104
|
+
/** WebFetch targets the workspace session may not reach: non-http(s), loopback/private/link-local/CGNAT IPs and local names. */
|
|
105
|
+
export function privateWebTarget(raw) {
|
|
106
|
+
let url;
|
|
107
|
+
try {
|
|
108
|
+
url = new URL(String(raw ?? ''));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return 'not a valid URL';
|
|
112
|
+
}
|
|
113
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
114
|
+
return 'only http(s) URLs';
|
|
115
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, ''), bare = host.replace(/^\[|\]$/g, '');
|
|
116
|
+
if (!host)
|
|
117
|
+
return 'no host';
|
|
118
|
+
const ip = isIP(bare);
|
|
119
|
+
if (ip === 4)
|
|
120
|
+
return privateIPv4(bare) ? 'private or loopback address' : undefined;
|
|
121
|
+
if (ip === 6)
|
|
122
|
+
return privateIPv6(bare) ? 'private or loopback address' : undefined;
|
|
123
|
+
if (host === 'localhost' || PRIVATE_SUFFIXES.some(s => host.endsWith(s)))
|
|
124
|
+
return 'local host name';
|
|
125
|
+
if (!host.includes('.'))
|
|
126
|
+
return 'single-label host name';
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const norm = (op) => op.replaceAll('.', '_');
|
|
130
|
+
const contractFor = (profile, agentType) => agentType !== undefined && Object.hasOwn(profile.contracts, agentType) ? profile.contracts[agentType] : undefined;
|
|
131
|
+
/** Contract experts that may reach the owner's Teal Brick connector (still bounded per call by decideTool). */
|
|
132
|
+
export const contractsWithAppAccess = (profile) => Object.values(profile.contracts).filter(c => c.capabilities.apps.length || c.capabilities.marketplace.length).map(c => c.id);
|
|
133
|
+
/** Payload-bound approval: owner-wide approval operations, plus a contract's own list and any outward write by a contract expert. */
|
|
134
|
+
export function needsApproval(profile, name, input, agentType) {
|
|
135
|
+
if (name !== 'mcp__tealbrick__tealbrick_call' || typeof input.operation !== 'string')
|
|
136
|
+
return false;
|
|
137
|
+
const op = norm(input.operation), contract = contractFor(profile, agentType);
|
|
138
|
+
if (profile.approvalOperations.some(o => norm(o) === op))
|
|
139
|
+
return true;
|
|
140
|
+
if (contract && ((op === 'marketplace_execute' && contract.approval.outwardWrites === 'always') || contract.approval.operations.some(o => norm(o) === op)))
|
|
141
|
+
return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
/** Single decision point for PreToolUse hooks and canUseTool. Names and decisions only are logged. */
|
|
145
|
+
export async function decideTool(policy, name, input, agentId, agentType) {
|
|
146
|
+
const { access, profile } = policy;
|
|
147
|
+
if (policy.ownerTools?.includes(name))
|
|
148
|
+
return agentId !== undefined ? { allow: false, reason: 'Not available to expert agents' } : { allow: true, reason: 'Owner-session tool' };
|
|
149
|
+
if (name.startsWith('mcp__tealbrick__')) {
|
|
150
|
+
if (agentId !== undefined) {
|
|
151
|
+
// A child reaches the owner's connector only under a contract, and only within contract ceiling ∩ owner grants.
|
|
152
|
+
const contract = contractFor(profile, agentType);
|
|
153
|
+
if (!contract || !(contract.capabilities.apps.length || contract.capabilities.marketplace.length))
|
|
154
|
+
return { allow: false, reason: 'Teal Brick authority is not delegated to child agents' };
|
|
155
|
+
if (name === 'mcp__tealbrick__tealbrick_call') {
|
|
156
|
+
const ref = typeof input.registrationId === 'string' ? await policy.resolveApp?.(input.registrationId) : undefined;
|
|
157
|
+
if (!ref)
|
|
158
|
+
return { allow: false, reason: 'Unknown app registration for this expert' };
|
|
159
|
+
if (input.operation === 'marketplace_execute') {
|
|
160
|
+
if (ref.appId !== 'marketplace' || !contract.capabilities.marketplace.some(m => m.plugin === ref.plugin && m.actions.includes(String(ref.action))))
|
|
161
|
+
return { allow: false, reason: 'Outside the expert contract' };
|
|
162
|
+
}
|
|
163
|
+
else if (!contract.capabilities.apps.some(a => a.appId === ref.appId && a.operations.includes(String(input.operation))))
|
|
164
|
+
return { allow: false, reason: 'Outside the expert contract' };
|
|
165
|
+
}
|
|
166
|
+
else if (name !== 'mcp__tealbrick__tealbrick_capabilities')
|
|
167
|
+
return { allow: false, reason: 'Outside the expert contract' };
|
|
168
|
+
}
|
|
169
|
+
if (name === 'mcp__tealbrick__tealbrick_call' && profile.tealbrickCalls.length) {
|
|
170
|
+
const args = input.input;
|
|
171
|
+
const ok = !Object.keys(input).some(k => !['registrationId', 'operation', 'input'].includes(k)) && !!args && typeof args === 'object' && !Array.isArray(args) && profile.tealbrickCalls.some(g => g.registrationId === input.registrationId && g.operation === input.operation && Object.entries(g.inputEquals).every(([k, v]) => Object.hasOwn(args, k) && args[k] === v));
|
|
172
|
+
if (!ok)
|
|
173
|
+
return { allow: false, reason: 'Outside the configured Teal Brick operation ceiling' };
|
|
174
|
+
}
|
|
175
|
+
return { allow: true, reason: 'Portal-governed Teal Brick tool' };
|
|
176
|
+
}
|
|
177
|
+
if (access.mode === 'full')
|
|
178
|
+
return { allow: true, reason: 'Full session access' };
|
|
179
|
+
// The SDK still surfaces the delegation tool under its legacy name Task.
|
|
180
|
+
if (name === 'Task')
|
|
181
|
+
name = 'Agent';
|
|
182
|
+
const sandboxed = access.mode === 'restricted' && access.sandbox === true && !!profile.workspace;
|
|
183
|
+
// In-process file tools would act outside the OS sandbox: the workspace session has none of them.
|
|
184
|
+
if (sandboxed && unsandboxedFileTools.includes(name))
|
|
185
|
+
return { allow: false, reason: 'File tools are not available in the sandboxed workspace; use Bash for file work' };
|
|
186
|
+
const allowed = sandboxed ? [...workspaceTools.filter(t => access.tools.includes(t)), ...profile.readTools]
|
|
187
|
+
: access.mode === 'restricted' ? [...[...readOnlyTools, ...profile.defaultTools].filter(t => access.tools.includes(t)), ...profile.readTools] : [...access.tools, ...profile.readTools];
|
|
188
|
+
if (!allowed.includes(name))
|
|
189
|
+
return { allow: false, reason: `Not available in ${access.mode} session access` };
|
|
190
|
+
if (agentId !== undefined) {
|
|
191
|
+
const contract = contractFor(profile, agentType);
|
|
192
|
+
const tools = contract ? contract.capabilities.builtinTools : (agentType !== undefined && Object.hasOwn(profile.experts, agentType) ? [profile.experts[agentType]] : Object.values(profile.experts)).flatMap(e => e.tools);
|
|
193
|
+
if (!tools.includes(name))
|
|
194
|
+
return { allow: false, reason: 'Not available to expert agents' };
|
|
195
|
+
}
|
|
196
|
+
if (name === 'Agent') {
|
|
197
|
+
const type = String(input.subagent_type ?? '');
|
|
198
|
+
if (!(Object.hasOwn(profile.experts, type) || Object.hasOwn(profile.contracts, type)) || input.run_in_background || input.isolation || input.resume || (input.model !== undefined && input.model !== 'inherit'))
|
|
199
|
+
return { allow: false, reason: 'Only configured experts may be delegated, in the foreground' };
|
|
200
|
+
}
|
|
201
|
+
if (name === 'Bash') {
|
|
202
|
+
// The OS sandbox is the boundary: never let a call opt out of it or detach from the turn.
|
|
203
|
+
if (input.dangerouslyDisableSandbox)
|
|
204
|
+
return { allow: false, reason: 'The sandbox cannot be disabled' };
|
|
205
|
+
if (sandboxed && input.run_in_background)
|
|
206
|
+
return { allow: false, reason: 'Background shell commands are not available' };
|
|
207
|
+
}
|
|
208
|
+
if (name === 'WebFetch' && sandboxed) {
|
|
209
|
+
const why = privateWebTarget(input.url);
|
|
210
|
+
if (why)
|
|
211
|
+
return { allow: false, reason: `WebFetch target refused: ${why}` };
|
|
212
|
+
}
|
|
213
|
+
const fields = pathTools[name];
|
|
214
|
+
if (fields) {
|
|
215
|
+
if (name === 'Glob' && (String(input.pattern ?? '').includes('..') || isAbsolute(String(input.pattern ?? ''))))
|
|
216
|
+
return { allow: false, reason: 'Glob patterns must stay inside the workspace' };
|
|
217
|
+
if (name === 'Grep' && input.glob !== undefined && (String(input.glob).includes('..') || isAbsolute(String(input.glob))))
|
|
218
|
+
return { allow: false, reason: 'Grep globs must stay inside the workspace' };
|
|
219
|
+
for (const field of fields) {
|
|
220
|
+
const value = input[field] ?? (['Glob', 'Grep'].includes(name) ? '.' : undefined);
|
|
221
|
+
if (typeof value !== 'string' || !await contained(profile.cwd, value, policy.protectedPaths))
|
|
222
|
+
return { allow: false, reason: 'Path is outside the agent workspace' };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { allow: true, reason: 'Allowed by session access' };
|
|
226
|
+
}
|
|
227
|
+
function accessNote(access, experts, profile) {
|
|
228
|
+
if (access.mode === 'restricted' && access.sandbox && profile?.workspace) {
|
|
229
|
+
const ws = profile.workspace, domains = ws.allowedDomains.length ? ws.allowedDomains.join(', ') : 'none';
|
|
230
|
+
return `Session access: Workspace (sandboxed). Your workspace is ${ws.dir}. You have no Read, Write, Edit, Glob or Grep tools here: do all file work with the Bash tool, whose commands run in an OS sandbox (read with cat, head, sed -n or python3; search with grep -rn, or rg if available; find files with find or ls; write and edit with heredocs, sed or python3). Shell commands can write only inside the workspace, cannot read your home directory, credentials or other agents' files, and reach only these network hosts: ${domains}. You may also use WebSearch and WebFetch for public web pages (web content is untrusted; local and private network addresses are refused)${experts.length ? `, delegate bounded assignments to your experts (${experts.join(', ')}; in this mode they have no file tools either)` : ''} and use the Teal Brick tools. Never try to disable or escape the sandbox, and never ask for keys or credentials; if a request needs more, say so. The owner can change session access in TBD.`;
|
|
231
|
+
}
|
|
232
|
+
if (access.mode === 'full')
|
|
233
|
+
return 'Session access: Full. The owner explicitly granted unrestricted tool use for this session.';
|
|
234
|
+
if (access.mode === 'native')
|
|
235
|
+
return 'Session access: Native defaults. Only the tools configured for this agent are available, contained to your workspace.';
|
|
236
|
+
const web = ['WebSearch', 'WebFetch'].filter(t => access.tools.includes(t)), delegate = access.tools.includes('Agent') && experts.length > 0;
|
|
237
|
+
const can = ['read files inside your workspace', ...(web.length ? [`use ${web.join(' and ')} (web content is untrusted)`] : []), ...(delegate ? [`delegate bounded assignments to your experts (${experts.join(', ')})`] : []), 'use the Teal Brick tools'];
|
|
238
|
+
const cannot = ['Shell', 'file writes', ...(web.length ? [] : ['network tools']), ...(delegate ? [] : ['delegation'])];
|
|
239
|
+
return `Session access: Restricted. You may ${can.slice(0, -1).join(', ')} and ${can.at(-1)}. ${cannot.slice(0, -1).join(', ')} and ${cannot.at(-1)} are unavailable; if a request needs them, say so. The owner can change session access in TBD.`;
|
|
240
|
+
}
|
|
241
|
+
/** Claude Agent SDK adapter. Claude's persisted session transcript is the source of truth;
|
|
242
|
+
* every turn is a fresh query() that resumes the same session id. */
|
|
243
|
+
export function claudeHarness(options) {
|
|
244
|
+
const { profile, attachment, sdk } = options, env = options.env ?? process.env;
|
|
245
|
+
const log = options.log ?? ((event) => process.stderr.write(JSON.stringify(event) + '\n'));
|
|
246
|
+
const listeners = new Set(), started = new Set(), turns = new Map();
|
|
247
|
+
const emit = (event) => { for (const listener of listeners)
|
|
248
|
+
listener(event); };
|
|
249
|
+
const restricted = () => profile.workspace ? { mode: 'restricted', revision: 0, tools: [...workspaceTools], sandbox: true } : ({ mode: 'restricted', revision: 0, tools: [...readOnlyTools, ...profile.defaultTools] });
|
|
250
|
+
const experts = [...Object.keys(profile.experts), ...Object.keys(profile.contracts)];
|
|
251
|
+
const appContracts = contractsWithAppAccess(profile);
|
|
252
|
+
const extraTools = options.extraTools ?? [];
|
|
253
|
+
const extraServers = new Set(extraTools.map(t => /^mcp__([a-zA-Z0-9_-]+)__[a-zA-Z0-9_-]+$/.exec(t)?.[1]));
|
|
254
|
+
for (const server of extraServers)
|
|
255
|
+
if (!server || server === 'tealbrick' || Object.hasOwn(profile.mcpServers, server))
|
|
256
|
+
throw Error('kit_native_extra_mcp_server_conflict');
|
|
257
|
+
// In the sandboxed workspace session, expert and contract tools are intersected with workspaceTools
|
|
258
|
+
// (they lose Read/Glob/Grep like the owner); decideTool enforces the same intersection per call.
|
|
259
|
+
const expertTools = (tools, sandboxed) => sandboxed ? tools.filter(t => workspaceTools.includes(t)) : [...tools];
|
|
260
|
+
const agentsFor = (sandboxed) => Object.fromEntries([
|
|
261
|
+
...Object.entries(profile.experts).map(([name, e]) => [name, { description: e.description, prompt: e.prompt, tools: expertTools(e.tools, sandboxed), model: 'inherit', maxTurns: e.maxTurns, ...(e.effort ? { effort: e.effort } : {}), omitClaudeMd: true }]),
|
|
262
|
+
...Object.values(profile.contracts).map(c => [c.id, { description: `${c.purpose} (contract ${c.id}@${c.version})`,
|
|
263
|
+
prompt: `${c.instructions}\n\nYou are the ${c.id} expert (contract ${c.id}@${c.version}). The brief you receive should match this JSON schema:\n\`\`\`json\n${JSON.stringify(c.input)}\n\`\`\`\nEnd your final message with one fenced json block that matches this output schema:\n\`\`\`json\n${JSON.stringify(c.output)}\n\`\`\`\nYou hold no credentials and cannot delegate. ${c.approval.outwardWrites === 'always' ? 'Any outward write pauses for the owner\'s approval of the exact payload; report a declined approval as blocked.' : 'Do not attempt outward writes.'}`,
|
|
264
|
+
tools: [...expertTools(c.capabilities.builtinTools, sandboxed), ...(appContracts.includes(c.id) ? ['mcp__tealbrick__tealbrick_capabilities', 'mcp__tealbrick__tealbrick_call'] : [])],
|
|
265
|
+
model: c.model, maxTurns: c.budget.maxTurns, ...(c.effort ? { effort: c.effort } : {}), omitClaudeMd: true }]),
|
|
266
|
+
]);
|
|
267
|
+
// Payload approvals waiting on the owner (bridge input.requested → respond).
|
|
268
|
+
const approvals = new Map();
|
|
269
|
+
const approvalTimeout = options.approvalTimeoutMs ?? 15 * 60_000;
|
|
270
|
+
const requestApproval = (sessionId, prompt) => new Promise(resolve => {
|
|
271
|
+
const requestId = randomUUID(), timer = setTimeout(() => { approvals.delete(requestId); resolve(false); }, approvalTimeout);
|
|
272
|
+
timer.unref?.();
|
|
273
|
+
approvals.set(requestId, { resolve, timer });
|
|
274
|
+
emit({ type: 'input.requested', sessionId, requestId, prompt });
|
|
275
|
+
});
|
|
276
|
+
const trace = async (record) => {
|
|
277
|
+
if (!options.traceDir)
|
|
278
|
+
return;
|
|
279
|
+
try {
|
|
280
|
+
await mkdir(options.traceDir, { recursive: true, mode: 0o700 });
|
|
281
|
+
await appendFile(join(options.traceDir, new Date().toISOString().slice(0, 10) + '.jsonl'), JSON.stringify({ ts: new Date().toISOString(), owner: options.name, ...record }) + '\n', { mode: 0o600 });
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
log({ event: 'claude.trace.failed' });
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const grantStore = options.standingGrants ?? standingGrantStore(undefined);
|
|
288
|
+
const digest = (v) => createHash('sha256').update(typeof v === 'string' ? v : JSON.stringify(v ?? null)).digest('hex').slice(0, 16);
|
|
289
|
+
const clip = (v, n) => { const t = typeof v === 'string' ? v : JSON.stringify(v ?? null); return t.length > n ? t.slice(0, n) + '…' : t; };
|
|
290
|
+
const expertRef = (type) => { const c = type !== undefined && Object.hasOwn(profile.contracts, type) ? profile.contracts[type] : undefined; return c ? `${c.id}@${c.version}` : type; };
|
|
291
|
+
const childEnv = () => {
|
|
292
|
+
const out = { CLAUDE_AGENT_SDK_CLIENT_APP: 'tealbrick-kit/native-serve' };
|
|
293
|
+
// Non-secret: lets the generated child guard admit contract experts; decideTool still bounds every call.
|
|
294
|
+
if (appContracts.length)
|
|
295
|
+
out.TB_CHILD_CONTRACTS = appContracts.join(',');
|
|
296
|
+
for (const name of [...safeEnvNames, ...profile.envPassthrough])
|
|
297
|
+
if (env[name] && !name.startsWith('TEALBRICK_') && !neverForwardedEnv.includes(name))
|
|
298
|
+
out[name] = env[name];
|
|
299
|
+
return out;
|
|
300
|
+
};
|
|
301
|
+
const workspaceTmp = () => join(profile.workspace.dir, '.tmp');
|
|
302
|
+
/** SDK sandbox for the workspace session. It is the only filesystem boundary: the session has no in-process file tools. */
|
|
303
|
+
const sandboxSettings = (childEnvironment) => {
|
|
304
|
+
const ws = profile.workspace, home = childEnvironment.HOME ?? homedir();
|
|
305
|
+
const covers = (base, p) => { const rel = relative(base, p); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
|
306
|
+
// The owners directory (siblings of this kit root: other owners' roots) when the layout makes it identifiable.
|
|
307
|
+
const owners = dirname(ws.root), depth = owners.split('/').filter(Boolean).length;
|
|
308
|
+
const denyRead = [...new Set([home, ...(owners !== home && !covers(owners, home) && (depth >= 3 || covers(home, owners)) ? [owners] : []), join(ws.root, '.tealbrick'), ...options.protectedPaths, '/Volumes', '/private/var/folders'])];
|
|
309
|
+
let nodePrefix;
|
|
310
|
+
try {
|
|
311
|
+
nodePrefix = dirname(dirname(realpathSync(process.execPath)));
|
|
312
|
+
}
|
|
313
|
+
catch { }
|
|
314
|
+
const configDir = childEnvironment.CLAUDE_CONFIG_DIR ?? join(home, '.claude');
|
|
315
|
+
// allowRead takes precedence over denyRead for matching paths (SDK sandbox.filesystem.allowRead), so each
|
|
316
|
+
// extra entry must not contain a denied path: that would re-open it (e.g. a node prefix equal to HOME).
|
|
317
|
+
const extras = [
|
|
318
|
+
nodePrefix, // node and npm for shell commands
|
|
319
|
+
join(configDir, 'shell-snapshots'), // Claude Code sources its shell snapshot inside the sandbox
|
|
320
|
+
...homebrewReadRoots.filter(p => existsSync(p)),
|
|
321
|
+
].filter((p) => !!p && p !== '/' && !denyRead.some(d => covers(p, d)));
|
|
322
|
+
const passthroughSecrets = profile.envPassthrough.filter(n => !ws.envAllow.includes(n));
|
|
323
|
+
const secretEnv = ['BUZZ_PRIVATE_KEY', 'BUZZ_AUTH_TAG', ...['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].filter(n => childEnvironment[n] !== undefined), ...Object.keys(env).filter(n => n.startsWith('TEALBRICK_')), ...passthroughSecrets];
|
|
324
|
+
// Claude Code keeps every session's tool output under /tmp/claude-<uid>/<cwd slug>/ and points sandboxed Bash
|
|
325
|
+
// TMPDIR at that root, shared by every owner running as this OS user. The root is unreadable except this owner's
|
|
326
|
+
// own slug; it stays writable because Claude Code's shell writes its cwd-tracking file there, but the other
|
|
327
|
+
// owners' slugs (siblings in the owners directory) are write-denied. TMPDIR itself is moved into the workspace
|
|
328
|
+
// by the Bash command rewrite.
|
|
329
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
330
|
+
const tmpRoots = uid === undefined ? [] : claudeTmpRoots(uid), ownTmp = tmpRoots.map(t => join(t, claudeTmpSlug(ws.dir)));
|
|
331
|
+
let siblings = [];
|
|
332
|
+
try {
|
|
333
|
+
siblings = readdirSync(owners, { withFileTypes: true }).filter(e => e.isDirectory() && join(owners, e.name) !== ws.root).map(e => join(owners, e.name));
|
|
334
|
+
}
|
|
335
|
+
catch { }
|
|
336
|
+
const otherTmp = tmpRoots.flatMap(t => siblings.flatMap(o => [join(t, claudeTmpSlug(o)), join(t, claudeTmpSlug(join(o, 'work')))]));
|
|
337
|
+
return {
|
|
338
|
+
enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false,
|
|
339
|
+
filesystem: {
|
|
340
|
+
allowWrite: [ws.dir, workspaceTmp(), ...ownTmp],
|
|
341
|
+
denyWrite: otherTmp,
|
|
342
|
+
denyRead: [...denyRead, ...tmpRoots],
|
|
343
|
+
// Re-open only this owner's workspace, the kit-owned attachment inbox (read-only) and toolchain paths.
|
|
344
|
+
allowRead: [...new Set([ws.dir, servePaths(ws.root).inbox, ...ownTmp, ...extras])],
|
|
345
|
+
},
|
|
346
|
+
network: { allowedDomains: [...ws.allowedDomains], strictAllowlist: true },
|
|
347
|
+
credentials: { envVars: [...new Set(secretEnv)].map(name => ({ name, mode: 'deny' })) },
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
const optionsFor = (sessionId, access, abort) => {
|
|
351
|
+
const policy = { access, profile, protectedPaths: options.protectedPaths, ...(options.resolveApp ? { resolveApp: options.resolveApp } : {}), ...(extraTools.length ? { ownerTools: extraTools } : {}) };
|
|
352
|
+
const extra = Object.fromEntries(Object.entries(options.extraMcpServers?.(sessionId) ?? {}).filter(([name]) => extraServers.has(name)));
|
|
353
|
+
const sandboxed = access.mode === 'restricted' && access.sandbox === true && !!profile.workspace;
|
|
354
|
+
// A stale access snapshot never brings unsandboxed file tools back into the workspace session.
|
|
355
|
+
const builtins = access.tools.filter(t => !t.startsWith('mcp__') && (!sandboxed || workspaceTools.includes(t)));
|
|
356
|
+
const servers = access.mode === 'restricted' ? Object.fromEntries(Object.entries(profile.mcpServers).filter(([name]) => profile.readTools.some(t => t.startsWith(`mcp__${name}__`)))) : profile.mcpServers;
|
|
357
|
+
const hook = async (input) => {
|
|
358
|
+
if (input?.hook_event_name !== 'PreToolUse')
|
|
359
|
+
return {};
|
|
360
|
+
const name = String(input.tool_name), args = input.tool_input ?? {}, child = input.agent_id !== undefined;
|
|
361
|
+
let decision = await decideTool(policy, name, args, input.agent_id, input.agent_type);
|
|
362
|
+
let approval;
|
|
363
|
+
if (decision.allow && needsApproval(profile, name, args, input.agent_type)) {
|
|
364
|
+
// Owner standing grants (config, enforced here, never by the model) replace the prompt within their caps.
|
|
365
|
+
const standing = await standingGrantFor({ profile, store: grantStore, ...(options.resolveApp ? { resolveApp: options.resolveApp } : {}), trace: r => void trace({ sessionId, ...(child ? { agentId: input.agent_id, expert: expertRef(input.agent_type) } : {}), ...r }) }, { name, input: args, ...(child ? { agentId: String(input.agent_id), ...(input.agent_type !== undefined ? { agentType: String(input.agent_type) } : {}) } : {}) });
|
|
366
|
+
if (standing) {
|
|
367
|
+
approval = 'standing:' + standing.grant.id;
|
|
368
|
+
void trace({ event: 'owner.approval', sessionId, approval: 'standing', grantId: standing.grant.id, operation: String(args.operation ?? ''), registrationId: String(args.registrationId ?? ''),
|
|
369
|
+
...(standing.ref?.plugin !== undefined ? { plugin: standing.ref.plugin } : {}), ...(standing.ref?.action !== undefined ? { action: standing.ref.action } : {}), ...(child ? { agentId: input.agent_id, expert: expertRef(input.agent_type) } : {}) });
|
|
370
|
+
decision = { allow: true, reason: `Pre-approved by the owner's standing grant ${standing.grant.id}` };
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
const who = child ? `expert ${expertRef(input.agent_type)}` : options.name;
|
|
374
|
+
const ok = await requestApproval(sessionId, `Approve ${who}: ${String(args.operation)} on ${String(args.registrationId)}\n${clip(args.input, 4000)}`);
|
|
375
|
+
approval = ok ? 'approved' : 'declined';
|
|
376
|
+
if (!ok)
|
|
377
|
+
decision = { allow: false, reason: 'The owner declined (or did not approve in time) this exact payload' };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
log({ event: 'claude.tool', sessionId, tool: name.slice(0, 120), allowed: decision.allow, child, ...(approval ? { approval } : {}) });
|
|
381
|
+
if (child)
|
|
382
|
+
void trace({ event: 'expert.tool', sessionId, agentId: input.agent_id, expert: expertRef(input.agent_type), tool: name.slice(0, 120),
|
|
383
|
+
...(name === 'mcp__tealbrick__tealbrick_call' ? { registrationId: String(args.registrationId ?? ''), operation: String(args.operation ?? '') } : {}), allowed: decision.allow, ...(approval ? { approval } : {}) });
|
|
384
|
+
else if ((name === 'Agent' || name === 'Task') && decision.allow)
|
|
385
|
+
void trace({ event: 'delegation.start', sessionId, toolUseId: input.tool_use_id, expert: expertRef(String(args.subagent_type ?? '')), brief: clip(args.prompt, 2000), briefSha: digest(args.prompt) });
|
|
386
|
+
else if (approval && !approval.startsWith('standing:'))
|
|
387
|
+
void trace({ event: 'owner.approval', sessionId, operation: String(args.operation ?? ''), registrationId: String(args.registrationId ?? ''), approval });
|
|
388
|
+
// Sandboxed Bash: temp files go to the workspace, not the shared /tmp/claude-<uid>. zsh puts heredoc temp
|
|
389
|
+
// files under TMPPREFIX (default /tmp/zsh), which the sandbox denies.
|
|
390
|
+
const rewrite = sandboxed && decision.allow && name === 'Bash' && typeof args.command === 'string'
|
|
391
|
+
? { updatedInput: { ...args, command: `export TMPDIR=${shq(workspaceTmp())} TMP=${shq(workspaceTmp())} TEMP=${shq(workspaceTmp())} TMPPREFIX=${shq(join(workspaceTmp(), 'zsh'))}; ${args.command}` } } : {};
|
|
392
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision.allow ? 'allow' : 'deny', permissionDecisionReason: decision.reason, ...rewrite } };
|
|
393
|
+
};
|
|
394
|
+
const lifecycle = async (input) => {
|
|
395
|
+
const e = input?.hook_event_name;
|
|
396
|
+
if (e === 'SubagentStart')
|
|
397
|
+
void trace({ event: 'expert.start', sessionId, agentId: input.agent_id, expert: expertRef(input.agent_type) });
|
|
398
|
+
else if (e === 'SubagentStop')
|
|
399
|
+
void trace({ event: 'expert.stop', sessionId, agentId: input.agent_id, expert: expertRef(input.agent_type), output: clip(input.last_assistant_message, 4000), outputSha: digest(input.last_assistant_message) });
|
|
400
|
+
else if (e === 'PostToolUse' && (input.tool_name === 'Agent' || input.tool_name === 'Task') && input.agent_id === undefined)
|
|
401
|
+
void trace({ event: 'delegation.end', sessionId, toolUseId: input.tool_use_id, outputSha: digest(input.tool_response), outputChars: clip(input.tool_response, 1e9).length });
|
|
402
|
+
return {};
|
|
403
|
+
};
|
|
404
|
+
const append = [profile.instructions?.trim(), `You are ${options.name}, chatting with your owner through TBD. ${accessNote(access, experts, profile)}`, standingGrantNote(profile.standingGrants, grantStore.now()), options.systemNote?.trim()].filter(Boolean).join('\n\n');
|
|
405
|
+
const sdkEnv = childEnv();
|
|
406
|
+
// HOME is unreadable inside the sandbox: temp files and git's global config live in the workspace instead.
|
|
407
|
+
// CLAUDE_CODE_TMPDIR keeps Claude Code's own temp files (screenshots, hook copies) in this owner's workspace.
|
|
408
|
+
// It does not move the sandboxed Bash TMPDIR (/tmp/claude-<uid>), which owners running as the same OS user
|
|
409
|
+
// share; see the README limits. GIT_TEMPLATE_DIR keeps git from copying hooks the sandbox refuses to write.
|
|
410
|
+
if (sandboxed) {
|
|
411
|
+
sdkEnv.TMPDIR = workspaceTmp();
|
|
412
|
+
sdkEnv.CLAUDE_CODE_TMPDIR = workspaceTmp();
|
|
413
|
+
sdkEnv.GIT_CONFIG_GLOBAL = join(workspaceTmp(), 'gitconfig');
|
|
414
|
+
sdkEnv.GIT_TEMPLATE_DIR = join(workspaceTmp(), 'git-template');
|
|
415
|
+
}
|
|
416
|
+
const delegates = experts.length > 0 && (access.mode === 'full' || builtins.includes('Agent'));
|
|
417
|
+
return {
|
|
418
|
+
cwd: profile.cwd, env: sdkEnv,
|
|
419
|
+
...(sandboxed ? { sandbox: sandboxSettings(sdkEnv) } : {}),
|
|
420
|
+
...(profile.model ? { model: profile.model } : {}), ...(profile.effort ? { effort: profile.effort } : {}),
|
|
421
|
+
maxTurns: profile.maxTurns, ...(profile.maxBudgetUsd !== undefined ? { maxBudgetUsd: profile.maxBudgetUsd } : {}),
|
|
422
|
+
settingSources: [], strictMcpConfig: true,
|
|
423
|
+
mcpServers: { ...servers, ...extra, tealbrick: attachment.mcpServers.tealbrick },
|
|
424
|
+
settings: attachment.settings,
|
|
425
|
+
tools: access.mode === 'full' ? { type: 'preset', preset: 'claude_code' } : builtins,
|
|
426
|
+
...(delegates ? { agents: agentsFor(sandboxed) } : {}),
|
|
427
|
+
disallowedTools: access.mode === 'full' ? [] : [...new Set([...restrictedDenied, ...(sandboxed ? unsandboxedFileTools : [])])].filter(t => !builtins.includes(t === 'Task' ? 'Agent' : t)),
|
|
428
|
+
permissionMode: access.mode === 'full' ? 'bypassPermissions' : 'default',
|
|
429
|
+
...(access.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {
|
|
430
|
+
canUseTool: async (name, input) => { const d = await decideTool(policy, name, input); return d.allow ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: d.reason }; },
|
|
431
|
+
}),
|
|
432
|
+
hooks: { PreToolUse: [{ hooks: [hook] }], SubagentStart: [{ hooks: [lifecycle] }], SubagentStop: [{ hooks: [lifecycle] }], PostToolUse: [{ hooks: [lifecycle] }] },
|
|
433
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', append },
|
|
434
|
+
persistSession: true, includePartialMessages: true,
|
|
435
|
+
...(started.has(sessionId) ? { resume: sessionId } : { sessionId }),
|
|
436
|
+
abortController: abort,
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
async function run(sessionId, text, access) {
|
|
440
|
+
const turn = { cancelled: false, timedOut: false, turnId: randomUUID() }, abort = new AbortController();
|
|
441
|
+
turn.abort = abort;
|
|
442
|
+
turns.set(sessionId, turn);
|
|
443
|
+
emit({ type: 'turn.started', sessionId, turnId: turn.turnId });
|
|
444
|
+
let release;
|
|
445
|
+
const gate = new Promise(r => { release = r; });
|
|
446
|
+
async function* prompt() { await gate; if (abort.signal.aborted)
|
|
447
|
+
return; yield { type: 'user', session_id: '', parent_tool_use_id: null, message: { role: 'user', content: text } }; }
|
|
448
|
+
const timer = setTimeout(() => { turn.timedOut = true; abort.abort(); }, profile.timeoutSeconds * 1000);
|
|
449
|
+
let result, assistantError, failure, finalText = false, buffer = '', stopReason = null;
|
|
450
|
+
try {
|
|
451
|
+
if (access.sandbox && profile.workspace) {
|
|
452
|
+
await mkdir(join(workspaceTmp(), 'git-template'), { recursive: true, mode: 0o700 });
|
|
453
|
+
}
|
|
454
|
+
const q = sdk.query({ prompt: prompt(), options: optionsFor(sessionId, access, abort) });
|
|
455
|
+
turn.query = q;
|
|
456
|
+
if (profile.requireSubscription) {
|
|
457
|
+
const account = await q.accountInfo?.();
|
|
458
|
+
if (!account || account.apiProvider !== 'firstParty' || !account.subscriptionType || account.apiKeySource === 'env')
|
|
459
|
+
failure = { code: 'claude_subscription_required', message: 'Claude subscription sign-in is required on this host; API-key fallback is disabled.' };
|
|
460
|
+
}
|
|
461
|
+
if (failure)
|
|
462
|
+
abort.abort();
|
|
463
|
+
else
|
|
464
|
+
release();
|
|
465
|
+
if (!failure)
|
|
466
|
+
for await (const m of q) {
|
|
467
|
+
if (m?.type === 'system' && m.subtype === 'init') {
|
|
468
|
+
if (m.session_id !== sessionId) {
|
|
469
|
+
failure = { code: 'claude_session_mismatch', message: 'The Claude session id did not match this conversation.' };
|
|
470
|
+
abort.abort();
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
started.add(sessionId);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (m?.parent_tool_use_id !== null && m?.parent_tool_use_id !== undefined)
|
|
477
|
+
continue;
|
|
478
|
+
if (m?.type === 'stream_event') {
|
|
479
|
+
const e = m.event ?? {};
|
|
480
|
+
if (e.type === 'message_start') {
|
|
481
|
+
buffer = '';
|
|
482
|
+
stopReason = null;
|
|
483
|
+
}
|
|
484
|
+
else if (e.type === 'content_block_start' && e.content_block?.type === 'tool_use')
|
|
485
|
+
emit({ type: 'action.requested', sessionId, toolName: String(e.content_block.name).slice(0, 120) });
|
|
486
|
+
else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta' && typeof e.delta.text === 'string') {
|
|
487
|
+
buffer += e.delta.text;
|
|
488
|
+
emit({ type: 'message.delta', sessionId, text: e.delta.text });
|
|
489
|
+
}
|
|
490
|
+
else if (e.type === 'message_delta')
|
|
491
|
+
stopReason = e.delta?.stop_reason ?? stopReason;
|
|
492
|
+
else if (e.type === 'message_stop' && buffer.trim()) {
|
|
493
|
+
const final = stopReason !== 'tool_use';
|
|
494
|
+
emit({ type: 'message.completed', sessionId, text: buffer, finishReason: final ? 'stop' : 'tool-calls' });
|
|
495
|
+
finalText ||= final;
|
|
496
|
+
buffer = '';
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else if (m?.type === 'user' && Array.isArray(m.message?.content) && m.message.content.some((b) => b?.type === 'tool_result'))
|
|
500
|
+
emit({ type: 'action.result', sessionId });
|
|
501
|
+
else if (m?.type === 'assistant' && typeof m.error === 'string')
|
|
502
|
+
assistantError = m.error;
|
|
503
|
+
else if (m?.type === 'result')
|
|
504
|
+
result = m;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch { /* classified below; SDK error text is not echoed */ }
|
|
508
|
+
finally {
|
|
509
|
+
clearTimeout(timer);
|
|
510
|
+
release();
|
|
511
|
+
try {
|
|
512
|
+
turn.query?.close?.();
|
|
513
|
+
}
|
|
514
|
+
catch { }
|
|
515
|
+
turns.delete(sessionId);
|
|
516
|
+
}
|
|
517
|
+
if (turn.cancelled) {
|
|
518
|
+
emit({ type: 'turn.ended', sessionId, status: 'cancelled', turnId: turn.turnId });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!failure && turn.timedOut)
|
|
522
|
+
failure = { code: 'claude_turn_timeout', message: `The turn exceeded ${profile.timeoutSeconds} seconds and was stopped.` };
|
|
523
|
+
if (!failure && result?.subtype === 'success' && !result.is_error) {
|
|
524
|
+
if (!finalText && typeof result.result === 'string' && result.result.trim())
|
|
525
|
+
emit({ type: 'message.completed', sessionId, text: result.result, finishReason: 'stop' });
|
|
526
|
+
emit({ type: 'turn.ended', sessionId, status: 'completed', turnId: turn.turnId });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (!failure) {
|
|
530
|
+
const subtype = typeof result?.subtype === 'string' && /^[a-z_]+$/.test(result.subtype) ? result.subtype : undefined;
|
|
531
|
+
const messages = { error_max_turns: `The turn reached its ${profile.maxTurns}-turn limit.`, error_max_budget_usd: 'The turn reached its configured budget limit.' };
|
|
532
|
+
failure = { code: 'claude_' + (subtype && subtype !== 'success' ? subtype : assistantError && /^[a-z_]+$/.test(assistantError) ? assistantError : 'turn_failed'), message: (subtype && messages[subtype]) ?? 'The Claude Agent SDK turn failed; check the host sign-in and logs.' };
|
|
533
|
+
}
|
|
534
|
+
log({ event: 'claude.turn.failed', sessionId, code: failure.code });
|
|
535
|
+
emit({ type: 'turn.ended', sessionId, status: 'failed', code: failure.code, message: failure.message, turnId: turn.turnId });
|
|
536
|
+
}
|
|
537
|
+
const capabilities = ['chat', 'resume', 'cancel', 'mcp', 'session-access', 'approvals', ...(experts.length ? ['subagents'] : []), ...(profile.standingGrants.length ? ['standing-grants'] : [])];
|
|
538
|
+
return {
|
|
539
|
+
info: { harness: 'claude', protocol: 'tealbrick-native', transport: 'claude-agent-sdk', workflowId: 'claude-agent-sdk', ...(profile.model ? { model: profile.model } : {}), capabilities,
|
|
540
|
+
unsupported: ['eve-info', 'eve-workflows', 'eve-schedules', 'file-attachments', 'session-reset', 'session-clear', 'session-compact', 'client-context', 'output-schema'] },
|
|
541
|
+
restricted,
|
|
542
|
+
subscribe: listener => { listeners.add(listener); },
|
|
543
|
+
async create() { return randomUUID(); },
|
|
544
|
+
async resume(sessionId) {
|
|
545
|
+
// Only re-attach a transcript that actually exists; never mint a replacement.
|
|
546
|
+
if (sdk.getSessionInfo && !await sdk.getSessionInfo(sessionId, { dir: profile.cwd }))
|
|
547
|
+
throw Error('claude_session_not_found');
|
|
548
|
+
started.add(sessionId);
|
|
549
|
+
},
|
|
550
|
+
async send(sessionId, message, access) { void run(sessionId, message, access).catch(() => emit({ type: 'turn.ended', sessionId, status: 'failed', code: 'claude_turn_failed', message: 'The Claude Agent SDK turn failed.' })); },
|
|
551
|
+
async cancel(sessionId) {
|
|
552
|
+
const turn = turns.get(sessionId);
|
|
553
|
+
if (!turn)
|
|
554
|
+
return false;
|
|
555
|
+
turn.cancelled = true;
|
|
556
|
+
const interrupted = await Promise.race([Promise.resolve(turn.query?.interrupt?.()).then(() => true, () => false), new Promise(r => setTimeout(() => r(false), 5000))]);
|
|
557
|
+
if (!interrupted)
|
|
558
|
+
turn.abort?.abort();
|
|
559
|
+
else
|
|
560
|
+
setTimeout(() => { if (turns.get(sessionId) === turn)
|
|
561
|
+
turn.abort?.abort(); }, 10000).unref();
|
|
562
|
+
return true;
|
|
563
|
+
},
|
|
564
|
+
access: {
|
|
565
|
+
async select(mode, revision) {
|
|
566
|
+
if (mode === 'restricted')
|
|
567
|
+
return { ...restricted(), revision };
|
|
568
|
+
if (mode === 'full')
|
|
569
|
+
return { mode, revision, tools: ['*'] };
|
|
570
|
+
// Snapshot now: later profile edits never silently widen an existing session.
|
|
571
|
+
return { mode, revision, tools: [...profile.nativeTools] };
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
respond(requestId, optionId) { const p = approvals.get(requestId); if (!p)
|
|
575
|
+
return; approvals.delete(requestId); clearTimeout(p.timer); p.resolve(optionId === 'accept'); },
|
|
576
|
+
async close() { for (const p of approvals.values()) {
|
|
577
|
+
clearTimeout(p.timer);
|
|
578
|
+
p.resolve(false);
|
|
579
|
+
} approvals.clear(); for (const turn of turns.values()) {
|
|
580
|
+
turn.cancelled = true;
|
|
581
|
+
turn.abort?.abort();
|
|
582
|
+
} },
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PortalLogin } from './onboarding.js';
|
|
2
|
+
/** Register this kit root's public chat endpoint on its EXISTING native Portal agent.
|
|
3
|
+
* Never creates or relabels an identity: the URL goes through Portal's owned-connection
|
|
4
|
+
* upsert, which preserves the agent's harness runtime (the Eve registration route would
|
|
5
|
+
* rewrite it to "eve"). The user token stays in memory. */
|
|
6
|
+
export declare function enrollNative(root: string, input: {
|
|
7
|
+
url: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
}, login: PortalLogin, options?: {
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
verifyEndpoint?: boolean;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
endpointReason?: string | undefined;
|
|
14
|
+
enrolled: boolean;
|
|
15
|
+
agentId: string;
|
|
16
|
+
name: string;
|
|
17
|
+
harness: "Claude Agent SDK" | "Codex" | "LangChain";
|
|
18
|
+
endpoint: string;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
portalVerified: boolean;
|
|
21
|
+
endpointVerified: boolean;
|
|
22
|
+
}>;
|