@tealbrick/kit 0.3.0-rc.3 → 0.3.0-rc.4

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 CHANGED
@@ -1,4 +1,4 @@
1
- > Prerelease 0.3.0-rc.2 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
1
+ > Prerelease 0.3.0-rc.4 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
2
2
 
3
3
  # Deployment guide: Eve agent + Teal Brick kit
4
4
 
@@ -19,7 +19,7 @@ For a fresh machine, the sequence is:
19
19
 
20
20
  1. Install Node 24 and npm.
21
21
  2. Create an Eve 0.70.0 project and configure its primary model credentials.
22
- 3. Install `@tealbrick/kit@0.3.0-rc.2` inside that project.
22
+ 3. Install `@tealbrick/kit@0.3.0-rc.4` inside that project.
23
23
  4. Run `tealbrick setup` to sign in, select capabilities and register the card.
24
24
  5. Build and start Eve, then check a real chat from desktop.
25
25
 
@@ -81,7 +81,7 @@ Run this inside the Eve project created in step 2 (or your existing compatible
81
81
  project), not in an empty directory or as a global install:
82
82
 
83
83
  ```bash
84
- npm install --save-exact @tealbrick/kit@0.3.0-rc.2
84
+ npm install --save-exact @tealbrick/kit@0.3.0-rc.4
85
85
  ```
86
86
 
87
87
  This installs Portal, AVM, Voice, Vision, Deliver and the shared provider transport
package/NATIVE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Native agent setup — Stage A candidate
2
2
 
3
- Prerelease suite **0.3.0-rc.2** targets the `next` tag. The seven scoped `@tealbrick/*` packages keep `latest` at 0.2.7, which lacks this flow. The unscoped `tealbrick` facade currently serves RC1 on both `latest` and `next`; RC2 will initially target `next`, with facade `latest` moved only after registry verification. The `tealbrick` facade forwards the existing kit CLI; it is not another SDK. Matching Portal runtime setup endpoints must be deployed before native onboarding can succeed.
3
+ Prerelease suite **0.3.0-rc.4** targets the `next` tag. The seven scoped `@tealbrick/*` packages keep `latest` at 0.2.7, which lacks this flow; the unscoped `tealbrick` facade keeps `latest` at 0.3.0-rc.1. The `tealbrick` facade forwards the existing kit CLI; it is not another SDK. Matching Portal runtime setup endpoints must be deployed before native onboarding can succeed. RC4 adds `tealbrick native serve` and `native enroll` (see README.md).
4
4
 
5
5
  ## Install and connect
6
6
 
7
- Install the review tarballs with the supplied checksum-verifying `install-candidate.mjs`. Once the release is actually published, the intended installation is `npm install --save-exact tealbrick@0.3.0-rc.2`. RC1 registry installation and live Foxy onboarding have been verified separately. The RC2 command below requires RC2 publication and registry verification; it is not yet a published installation path.
7
+ Install with `npm install --save-exact tealbrick@0.3.0-rc.4` (or `tealbrick@next`) once registry publication is verified. Until then, use the review tarballs with the checksum-verifying `install-candidate.mjs`.
8
8
 
9
9
  From your native agent workspace:
10
10
 
@@ -50,7 +50,7 @@ const adapter = new MCPAdapter(config);
50
50
  const agent = createAgent({model: configuredModel, tools: await adapter.listTools(), checkpointer});
51
51
  ```
52
52
 
53
- Configure provider authentication on the native host through its supported SDK. Kit does not copy login caches, choose providers or change billing. No Eve daemon, AVMM or inbound bridge is required. Native mode emits no Eve mounts and uses the existing kit state and owner-only credential file.
53
+ Configure provider authentication on the native host through its supported SDK. Kit does not copy login caches, choose providers or change billing. No Eve daemon, AVMM or inbound bridge is required. An optional owner-only chat endpoint for Teal Brick Desktop is available through `tealbrick native serve` and `tealbrick native enroll`; see README.md. Native mode emits no Eve mounts and uses the existing kit state and owner-only credential file.
54
54
 
55
55
  ## Children and host authority
56
56
 
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- > Prerelease 0.3.0-rc.2 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
1
+ > Prerelease 0.3.0-rc.4 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
2
2
 
3
3
  # @tealbrick/kit
4
4
 
@@ -9,7 +9,7 @@ availability, not agent permissions. Nothing is mounted by installing the kit.
9
9
  Install inside an existing Eve 0.70.0 project:
10
10
 
11
11
  ```sh
12
- npm install --save-exact @tealbrick/kit@0.3.0-rc.2
12
+ npm install --save-exact @tealbrick/kit@0.3.0-rc.4
13
13
  npx --no-install tealbrick setup
14
14
  ```
15
15
 
@@ -92,3 +92,116 @@ including a single deduplicated provider-transport installation.
92
92
  ## AVMM profiles
93
93
 
94
94
  The `avm` selection accepts the [AVM package connection/policy schema](../avm/README.md): either `{target,sudo}` or `{selectedProfile,profiles}`. Setup can select a saved profile and preserves its grants. New connections can use paired AVMM users or operator SSH. Paired mode requires a dedicated SSH identity path and a resolvable hostname; no credentials are copied. Use `tealbrick apply config.json` for full workspace, ownership, sharing, pool and ingress grants. Disabling AVM removes its entire mount.
95
+
96
+ ## Native chat endpoint: `tealbrick native serve`
97
+
98
+ Optional. Lets the owner chat with a native **Claude Agent SDK** or **Codex**
99
+ agent from TBD. The agent must already be set up with
100
+ `tealbrick setup --harness claude|codex` (kit state, runtime connection and
101
+ `.tealbrick/native-sdk.json`). LangChain is refused with
102
+ `kit_native_serve_langchain_unsupported`.
103
+
104
+ The endpoint is the Eve session subset Desktop uses (`/eve/v1/health`,
105
+ `/eve/v1/session` create/send, NDJSON stream with cursor catch-up, cancel,
106
+ `/eve/v1/session/:id/access`) plus `GET /tealbrick/v1/info`, which reports
107
+ `harness`, `protocol: "tealbrick-native"`, `model`, `capabilities` and
108
+ `unsupported` honestly. `/eve/v1/info`, reset/clear/compact, attachments,
109
+ approvals, subagents, workflows and schedules return 404/400. The native
110
+ harness keeps the conversation: Claude turns resume the same SDK session id,
111
+ and a restarted server only re-attaches transcripts that still exist.
112
+
113
+ ### Security posture
114
+
115
+ - Binds `127.0.0.1` only. Reach it from the tailnet through a TLS-terminating
116
+ proxy (preferred: `tailscale serve`, including for customer deployments).
117
+ - Every request except `/healthz` is verified against Portal (identity token
118
+ plus policy bundle for this agent) and must come from the enrolled owner.
119
+ - The `Host` header must be loopback, the enrolled `publicUrl` host or an
120
+ explicit `allowedHosts` entry. Any `Origin` header is rejected. Bodies are
121
+ limited to 128 KiB and two concurrent turns by default (`maxActiveTurns`).
122
+ - New sessions are **Restricted**: `Read`/`Glob`/`Grep` contained to the
123
+ profile `cwd` (never the kit's `.tealbrick`), operator-vetted `readTools`, and
124
+ `mcp__tealbrick__*` only. Shell, file writes, network tools, delegation and
125
+ project/user Claude settings are unavailable; a PreToolUse hook and
126
+ `canUseTool` enforce the same decision. The kit's child-agent guard still
127
+ denies Teal Brick tools to subagents. Optional `tealbrickCalls` adds a
128
+ local operation ceiling on top of Portal grants.
129
+ - A per-agent default can widen Restricted with `defaultTools` (only
130
+ `WebSearch`, `WebFetch`, `Agent`, `TodoWrite`; never shell or writes).
131
+ `Agent` delegates only to the configured `experts`, in the foreground with
132
+ the session model; each expert runs with its own `tools` (workspace reads,
133
+ `WebSearch`, `WebFetch`) and never receives Teal Brick tools.
134
+ - Elevation only through Desktop's session-access control (owner, explicit
135
+ confirmation, optimistic revision, no turn in flight). **Native defaults**
136
+ snapshots `nativeTools` (still workspace-contained); **Full** uses the
137
+ Claude Code tool preset with permission bypass. Full is not a security
138
+ boundary against the local OS user.
139
+ - The SDK process receives an allowlisted environment (`PATH`, `HOME`, locale,
140
+ CA settings, `envPassthrough`) and never `TEALBRICK_*` values. Logs carry ids,
141
+ tool names and status codes, never prompts, tool inputs or results.
142
+
143
+ ### Configure
144
+
145
+ `.tealbrick/native-serve.json` (owner-only, `chmod 600`) holds no secrets:
146
+
147
+ ```json
148
+ {
149
+ "version": 1,
150
+ "port": 5331,
151
+ "claude": {
152
+ "cwd": "/Users/you/agents/henry-serve/workspace",
153
+ "instructionsFile": "/Users/you/agents/henry-calendar/agents/henry/INSTRUCTIONS.md",
154
+ "recipe": {"path": "/Users/you/.config/tealbrick-native/config.json", "agent": "henry"},
155
+ "requireSubscription": true,
156
+ "defaultTools": ["WebSearch", "WebFetch", "Agent"],
157
+ "experts": {
158
+ "source-researcher": {"promptFile": "/Users/you/agents/henry-calendar/experts/source-researcher.md", "tools": ["WebSearch", "WebFetch"]},
159
+ "independent-reviewer": {"promptFile": "/Users/you/agents/henry-calendar/experts/independent-reviewer.md"}
160
+ },
161
+ "nativeTools": ["Read", "Glob", "Grep", "Write", "Edit", "TodoWrite", "WebSearch", "WebFetch"]
162
+ }
163
+ }
164
+ ```
165
+
166
+ `recipe` imports `model`, `effort`, `maxTurns`, `maxBudgetUsd`,
167
+ `timeoutSeconds` and the agent's `mcpServers`, `readTools` and
168
+ `tealbrickCalls` from an existing tealbrick-native recipe; its
169
+ `tealbrickSdkConfig` must point at this kit root's `native-sdk.json`. Any of
170
+ those keys set directly under `claude` override the recipe. Keep `cwd` stable:
171
+ Claude session transcripts are stored per working directory. `requireSubscription`
172
+ refuses turns unless the SDK reports first-party subscription sign-in.
173
+ Codex roots use `"codex": {"codexBin": "/abs/codex", "cwd": "/abs/dir"}`.
174
+
175
+ Install the Claude Agent SDK next to the kit (or set `claude.sdkModule` to an
176
+ absolute SDK package directory):
177
+
178
+ ```sh
179
+ npm install --save-exact @anthropic-ai/claude-agent-sdk@0.3.287
180
+ ```
181
+
182
+ ### Run, expose, enroll
183
+
184
+ ```sh
185
+ # 1. Tailnet HTTPS in front of the loopback port (pick a free HTTPS port).
186
+ tailscale serve --bg --https=8444 http://127.0.0.1:5331
187
+ # 2. Record the Portal identity and register the URL on the EXISTING agent.
188
+ npx --no-install tealbrick native enroll --url https://HOST.TAILNET.ts.net:8444
189
+ # 3. Start the endpoint (foreground) ...
190
+ npx --no-install tealbrick native serve
191
+ # ... or supervise it with a user LaunchAgent (macOS, opt-in).
192
+ npx --no-install tealbrick native serve --install-launchd
193
+ npx --no-install tealbrick native serve --uninstall
194
+ # 4. Re-run enroll to confirm /healthz and authenticated /tealbrick/v1/info.
195
+ npx --no-install tealbrick native enroll --url https://HOST.TAILNET.ts.net:8444
196
+ ```
197
+
198
+ `enroll` signs in with Portal device approval, requires the kit root's owned
199
+ agent and canvas card to carry the native harness label, registers the origin
200
+ through Portal's owned-connection API (which keeps the harness label; it never
201
+ uses the Eve registration route, which relabels agents as Eve), records
202
+ `agent` and `publicUrl` in `native-serve.json`, updates the card endpoint with
203
+ a revision-checked draft write and verifies the sidebar URL. It then probes the
204
+ endpoint; exit code 2 means Portal is updated but the endpoint was not yet
205
+ reachable or authenticated. The user token is never written to disk.
206
+ LaunchAgent logs go to `.tealbrick/native-serve/logs/`; session projections
207
+ (customer data) to `.tealbrick/native-serve/sessions/`.
package/dist/cli.js CHANGED
@@ -127,4 +127,4 @@ async function main() {
127
127
  console.log('Portal agent card saved and confirmed in workspace discovery. Rebuild/restart Eve to connect.');
128
128
  console.log('Local credentials stay in .tealbrick/kit.credentials.json (owner-only). Supply them as runtime secrets when deploying. Provider connectivity has not been tested.');
129
129
  }
130
- main().catch(error => { const message = error instanceof Error ? error.message : ''; console.error(/^kit_[a-z_]+(?::agent\/[a-zA-Z0-9_./-]+)?$/.test(message) ? message : 'Kit setup failed; check configuration and permissions. Credentials omitted.'); process.exitCode = 1; });
130
+ main().catch(error => { const message = error instanceof Error ? error.message : ''; console.error(/^(?:kit_[a-z_]+(?::agent\/[a-zA-Z0-9_./-]+|:[a-zA-Z0-9_.,]+)?|runtime_product_inactive)$/.test(message) ? message : 'Kit setup failed; check configuration and permissions. Credentials omitted.'); process.exitCode = 1; });
@@ -0,0 +1,49 @@
1
+ import type { AccessMode, HarnessAdapter } from '@tealbrick/portal/native-bridge';
2
+ import type { ClaudeAttachment, ClaudeProfile } from './native-serve-config.js';
3
+ /** Minimal structural view of @anthropic-ai/claude-agent-sdk (validated against 0.3.287). */
4
+ export interface ClaudeQuery extends AsyncIterable<any> {
5
+ interrupt?(): Promise<unknown>;
6
+ close?(): void;
7
+ accountInfo?(): Promise<any>;
8
+ }
9
+ export interface ClaudeSdk {
10
+ query(params: {
11
+ prompt: AsyncIterable<any>;
12
+ options: Record<string, any>;
13
+ }): ClaudeQuery;
14
+ getSessionInfo?(sessionId: string, options?: {
15
+ dir?: string;
16
+ }): Promise<unknown>;
17
+ }
18
+ export declare function loadClaudeSdk(root: string, sdkModule?: string): Promise<ClaudeSdk>;
19
+ export type ClaudeAccess = {
20
+ mode: AccessMode;
21
+ revision: number;
22
+ tools: string[];
23
+ };
24
+ /** Built-in tools Restricted mode exposes: read-only and contained to the workspace. */
25
+ export declare const readOnlyTools: string[];
26
+ /** Removed from the model's context unless a wider access mode explicitly lists them. */
27
+ export declare const restrictedDenied: string[];
28
+ export interface ToolPolicy {
29
+ access: ClaudeAccess;
30
+ profile: ClaudeProfile;
31
+ protectedPaths: string[];
32
+ }
33
+ /** Single decision point for PreToolUse hooks and canUseTool. Names and decisions only are logged. */
34
+ export declare function decideTool(policy: ToolPolicy, name: string, input: Record<string, unknown>, agentId?: string, agentType?: string): Promise<{
35
+ allow: boolean;
36
+ reason: string;
37
+ }>;
38
+ export interface ClaudeHarnessOptions {
39
+ name: string;
40
+ profile: ClaudeProfile;
41
+ attachment: ClaudeAttachment;
42
+ sdk: ClaudeSdk;
43
+ protectedPaths: string[];
44
+ log?: (event: Record<string, unknown>) => void;
45
+ env?: NodeJS.ProcessEnv;
46
+ }
47
+ /** Claude Agent SDK adapter. Claude's persisted session transcript is the source of truth;
48
+ * every turn is a fresh query() that resumes the same session id. */
49
+ export declare function claudeHarness(options: ClaudeHarnessOptions): HarnessAdapter<ClaudeAccess>;
@@ -0,0 +1,287 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve, basename } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ export async function loadClaudeSdk(root, sdkModule) {
7
+ let entry;
8
+ try {
9
+ entry = sdkModule ? createRequire(import.meta.url).resolve(sdkModule) : createRequire(join(root, 'package.json')).resolve('@anthropic-ai/claude-agent-sdk');
10
+ }
11
+ catch {
12
+ throw Error('kit_native_claude_sdk_missing');
13
+ }
14
+ const sdk = await import(pathToFileURL(entry).href);
15
+ if (typeof sdk.query !== 'function')
16
+ throw Error('kit_native_claude_sdk_invalid');
17
+ return sdk;
18
+ }
19
+ /** Built-in tools Restricted mode exposes: read-only and contained to the workspace. */
20
+ export const readOnlyTools = ['Read', 'Glob', 'Grep'];
21
+ /** Removed from the model's context unless a wider access mode explicitly lists them. */
22
+ export const restrictedDenied = ['Bash', 'BashOutput', 'KillShell', 'KillBash', 'Monitor', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'WebSearch', 'Agent', 'Task', 'TodoWrite', 'Skill', 'SlashCommand', 'ExitPlanMode', 'EnterWorktree', 'ExitWorktree'];
23
+ const pathTools = { Read: ['file_path'], Write: ['file_path'], Edit: ['file_path'], MultiEdit: ['file_path'], NotebookEdit: ['notebook_path'], Glob: ['path'], Grep: ['path'] };
24
+ const safeEnvNames = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG', 'LC_ALL', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS'];
25
+ async function contained(root, input, deny) {
26
+ let path = resolve(root, input), tail = [];
27
+ for (;;) {
28
+ try {
29
+ path = resolve(await realpath(path), ...tail);
30
+ break;
31
+ }
32
+ catch (e) {
33
+ if (e.code !== 'ENOENT')
34
+ return false;
35
+ const parent = dirname(path);
36
+ if (parent === path)
37
+ return false;
38
+ tail.unshift(basename(path));
39
+ path = parent;
40
+ }
41
+ }
42
+ const inside = (base) => { const rel = relative(base, path); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
43
+ return inside(await realpath(root)) && !deny.some(inside);
44
+ }
45
+ /** Single decision point for PreToolUse hooks and canUseTool. Names and decisions only are logged. */
46
+ export async function decideTool(policy, name, input, agentId, agentType) {
47
+ const { access, profile } = policy;
48
+ if (name.startsWith('mcp__tealbrick__')) {
49
+ if (agentId !== undefined)
50
+ return { allow: false, reason: 'Teal Brick authority is not delegated to child agents' };
51
+ if (name === 'mcp__tealbrick__tealbrick_call' && profile.tealbrickCalls.length) {
52
+ const args = input.input;
53
+ 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));
54
+ if (!ok)
55
+ return { allow: false, reason: 'Outside the configured Teal Brick operation ceiling' };
56
+ }
57
+ return { allow: true, reason: 'Portal-governed Teal Brick tool' };
58
+ }
59
+ if (access.mode === 'full')
60
+ return { allow: true, reason: 'Full session access' };
61
+ // The SDK still surfaces the delegation tool under its legacy name Task.
62
+ if (name === 'Task')
63
+ name = 'Agent';
64
+ const allowed = access.mode === 'restricted' ? [...[...readOnlyTools, ...profile.defaultTools].filter(t => access.tools.includes(t)), ...profile.readTools] : [...access.tools, ...profile.readTools];
65
+ if (!allowed.includes(name))
66
+ return { allow: false, reason: `Not available in ${access.mode} session access` };
67
+ if (agentId !== undefined && !(agentType !== undefined && Object.hasOwn(profile.experts, agentType) ? [profile.experts[agentType]] : Object.values(profile.experts)).some(e => e.tools.includes(name)))
68
+ return { allow: false, reason: 'Not available to expert agents' };
69
+ if (name === 'Agent') {
70
+ const type = String(input.subagent_type ?? '');
71
+ if (!Object.hasOwn(profile.experts, type) || input.run_in_background || input.isolation || input.resume || (input.model !== undefined && input.model !== 'inherit'))
72
+ return { allow: false, reason: 'Only configured experts may be delegated, in the foreground' };
73
+ }
74
+ const fields = pathTools[name];
75
+ if (fields) {
76
+ if (name === 'Glob' && (String(input.pattern ?? '').includes('..') || isAbsolute(String(input.pattern ?? ''))))
77
+ return { allow: false, reason: 'Glob patterns must stay inside the workspace' };
78
+ if (name === 'Grep' && input.glob !== undefined && (String(input.glob).includes('..') || isAbsolute(String(input.glob))))
79
+ return { allow: false, reason: 'Grep globs must stay inside the workspace' };
80
+ for (const field of fields) {
81
+ const value = input[field] ?? (['Glob', 'Grep'].includes(name) ? '.' : undefined);
82
+ if (typeof value !== 'string' || !await contained(profile.cwd, value, policy.protectedPaths))
83
+ return { allow: false, reason: 'Path is outside the agent workspace' };
84
+ }
85
+ }
86
+ return { allow: true, reason: 'Allowed by session access' };
87
+ }
88
+ function accessNote(access, experts) {
89
+ if (access.mode === 'full')
90
+ return 'Session access: Full. The owner explicitly granted unrestricted tool use for this session.';
91
+ if (access.mode === 'native')
92
+ return 'Session access: Native defaults. Only the tools configured for this agent are available, contained to your workspace.';
93
+ const web = ['WebSearch', 'WebFetch'].filter(t => access.tools.includes(t)), delegate = access.tools.includes('Agent') && experts.length > 0;
94
+ 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'];
95
+ const cannot = ['Shell', 'file writes', ...(web.length ? [] : ['network tools']), ...(delegate ? [] : ['delegation'])];
96
+ 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.`;
97
+ }
98
+ /** Claude Agent SDK adapter. Claude's persisted session transcript is the source of truth;
99
+ * every turn is a fresh query() that resumes the same session id. */
100
+ export function claudeHarness(options) {
101
+ const { profile, attachment, sdk } = options, env = options.env ?? process.env;
102
+ const log = options.log ?? ((event) => process.stderr.write(JSON.stringify(event) + '\n'));
103
+ const listeners = new Set(), started = new Set(), turns = new Map();
104
+ const emit = (event) => { for (const listener of listeners)
105
+ listener(event); };
106
+ const restricted = () => ({ mode: 'restricted', revision: 0, tools: [...readOnlyTools, ...profile.defaultTools] });
107
+ const experts = Object.keys(profile.experts);
108
+ const agents = Object.fromEntries(Object.entries(profile.experts).map(([name, e]) => [name, { description: e.description, prompt: e.prompt, tools: e.tools, model: 'inherit', maxTurns: e.maxTurns, ...(e.effort ? { effort: e.effort } : {}), omitClaudeMd: true }]));
109
+ const childEnv = () => {
110
+ const out = { CLAUDE_AGENT_SDK_CLIENT_APP: 'tealbrick-kit/native-serve' };
111
+ for (const name of [...safeEnvNames, ...profile.envPassthrough])
112
+ if (env[name] && !name.startsWith('TEALBRICK_'))
113
+ out[name] = env[name];
114
+ return out;
115
+ };
116
+ const optionsFor = (sessionId, access, abort) => {
117
+ const policy = { access, profile, protectedPaths: options.protectedPaths };
118
+ const builtins = access.tools.filter(t => !t.startsWith('mcp__'));
119
+ const servers = access.mode === 'restricted' ? Object.fromEntries(Object.entries(profile.mcpServers).filter(([name]) => profile.readTools.some(t => t.startsWith(`mcp__${name}__`)))) : profile.mcpServers;
120
+ const hook = async (input) => {
121
+ if (input?.hook_event_name !== 'PreToolUse')
122
+ return {};
123
+ const decision = await decideTool(policy, String(input.tool_name), input.tool_input ?? {}, input.agent_id, input.agent_type);
124
+ log({ event: 'claude.tool', sessionId, tool: String(input.tool_name).slice(0, 120), allowed: decision.allow, child: input.agent_id !== undefined });
125
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision.allow ? 'allow' : 'deny', permissionDecisionReason: decision.reason } };
126
+ };
127
+ const append = [profile.instructions?.trim(), `You are ${options.name}, chatting with your owner through TBD. ${accessNote(access, experts)}`].filter(Boolean).join('\n\n');
128
+ const delegates = experts.length > 0 && (access.mode === 'full' || builtins.includes('Agent'));
129
+ return {
130
+ cwd: profile.cwd, env: childEnv(),
131
+ ...(profile.model ? { model: profile.model } : {}), ...(profile.effort ? { effort: profile.effort } : {}),
132
+ maxTurns: profile.maxTurns, ...(profile.maxBudgetUsd !== undefined ? { maxBudgetUsd: profile.maxBudgetUsd } : {}),
133
+ settingSources: [], strictMcpConfig: true,
134
+ mcpServers: { ...servers, tealbrick: attachment.mcpServers.tealbrick },
135
+ settings: attachment.settings,
136
+ tools: access.mode === 'full' ? { type: 'preset', preset: 'claude_code' } : builtins,
137
+ ...(delegates ? { agents } : {}),
138
+ disallowedTools: access.mode === 'full' ? [] : restrictedDenied.filter(t => !builtins.includes(t === 'Task' ? 'Agent' : t)),
139
+ permissionMode: access.mode === 'full' ? 'bypassPermissions' : 'default',
140
+ ...(access.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {
141
+ canUseTool: async (name, input) => { const d = await decideTool(policy, name, input); return d.allow ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: d.reason }; },
142
+ }),
143
+ hooks: { PreToolUse: [{ hooks: [hook] }] },
144
+ systemPrompt: { type: 'preset', preset: 'claude_code', append },
145
+ persistSession: true, includePartialMessages: true,
146
+ ...(started.has(sessionId) ? { resume: sessionId } : { sessionId }),
147
+ abortController: abort,
148
+ };
149
+ };
150
+ async function run(sessionId, text, access) {
151
+ const turn = { cancelled: false, timedOut: false, turnId: randomUUID() }, abort = new AbortController();
152
+ turn.abort = abort;
153
+ turns.set(sessionId, turn);
154
+ emit({ type: 'turn.started', sessionId, turnId: turn.turnId });
155
+ let release;
156
+ const gate = new Promise(r => { release = r; });
157
+ async function* prompt() { await gate; if (abort.signal.aborted)
158
+ return; yield { type: 'user', session_id: '', parent_tool_use_id: null, message: { role: 'user', content: text } }; }
159
+ const timer = setTimeout(() => { turn.timedOut = true; abort.abort(); }, profile.timeoutSeconds * 1000);
160
+ let result, assistantError, failure, finalText = false, buffer = '', stopReason = null;
161
+ try {
162
+ const q = sdk.query({ prompt: prompt(), options: optionsFor(sessionId, access, abort) });
163
+ turn.query = q;
164
+ if (profile.requireSubscription) {
165
+ const account = await q.accountInfo?.();
166
+ if (!account || account.apiProvider !== 'firstParty' || !account.subscriptionType || account.apiKeySource === 'env')
167
+ failure = { code: 'claude_subscription_required', message: 'Claude subscription sign-in is required on this host; API-key fallback is disabled.' };
168
+ }
169
+ if (failure)
170
+ abort.abort();
171
+ else
172
+ release();
173
+ if (!failure)
174
+ for await (const m of q) {
175
+ if (m?.type === 'system' && m.subtype === 'init') {
176
+ if (m.session_id !== sessionId) {
177
+ failure = { code: 'claude_session_mismatch', message: 'The Claude session id did not match this conversation.' };
178
+ abort.abort();
179
+ break;
180
+ }
181
+ started.add(sessionId);
182
+ continue;
183
+ }
184
+ if (m?.parent_tool_use_id !== null && m?.parent_tool_use_id !== undefined)
185
+ continue;
186
+ if (m?.type === 'stream_event') {
187
+ const e = m.event ?? {};
188
+ if (e.type === 'message_start') {
189
+ buffer = '';
190
+ stopReason = null;
191
+ }
192
+ else if (e.type === 'content_block_start' && e.content_block?.type === 'tool_use')
193
+ emit({ type: 'action.requested', sessionId, toolName: String(e.content_block.name).slice(0, 120) });
194
+ else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta' && typeof e.delta.text === 'string') {
195
+ buffer += e.delta.text;
196
+ emit({ type: 'message.delta', sessionId, text: e.delta.text });
197
+ }
198
+ else if (e.type === 'message_delta')
199
+ stopReason = e.delta?.stop_reason ?? stopReason;
200
+ else if (e.type === 'message_stop' && buffer.trim()) {
201
+ const final = stopReason !== 'tool_use';
202
+ emit({ type: 'message.completed', sessionId, text: buffer, finishReason: final ? 'stop' : 'tool-calls' });
203
+ finalText ||= final;
204
+ buffer = '';
205
+ }
206
+ }
207
+ else if (m?.type === 'user' && Array.isArray(m.message?.content) && m.message.content.some((b) => b?.type === 'tool_result'))
208
+ emit({ type: 'action.result', sessionId });
209
+ else if (m?.type === 'assistant' && typeof m.error === 'string')
210
+ assistantError = m.error;
211
+ else if (m?.type === 'result')
212
+ result = m;
213
+ }
214
+ }
215
+ catch { /* classified below; SDK error text is not echoed */ }
216
+ finally {
217
+ clearTimeout(timer);
218
+ release();
219
+ try {
220
+ turn.query?.close?.();
221
+ }
222
+ catch { }
223
+ turns.delete(sessionId);
224
+ }
225
+ if (turn.cancelled) {
226
+ emit({ type: 'turn.ended', sessionId, status: 'cancelled', turnId: turn.turnId });
227
+ return;
228
+ }
229
+ if (!failure && turn.timedOut)
230
+ failure = { code: 'claude_turn_timeout', message: `The turn exceeded ${profile.timeoutSeconds} seconds and was stopped.` };
231
+ if (!failure && result?.subtype === 'success' && !result.is_error) {
232
+ if (!finalText && typeof result.result === 'string' && result.result.trim())
233
+ emit({ type: 'message.completed', sessionId, text: result.result, finishReason: 'stop' });
234
+ emit({ type: 'turn.ended', sessionId, status: 'completed', turnId: turn.turnId });
235
+ return;
236
+ }
237
+ if (!failure) {
238
+ const subtype = typeof result?.subtype === 'string' && /^[a-z_]+$/.test(result.subtype) ? result.subtype : undefined;
239
+ const messages = { error_max_turns: `The turn reached its ${profile.maxTurns}-turn limit.`, error_max_budget_usd: 'The turn reached its configured budget limit.' };
240
+ 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.' };
241
+ }
242
+ log({ event: 'claude.turn.failed', sessionId, code: failure.code });
243
+ emit({ type: 'turn.ended', sessionId, status: 'failed', code: failure.code, message: failure.message, turnId: turn.turnId });
244
+ }
245
+ const capabilities = ['chat', 'resume', 'cancel', 'mcp', 'session-access'];
246
+ return {
247
+ info: { harness: 'claude', protocol: 'tealbrick-native', transport: 'claude-agent-sdk', workflowId: 'claude-agent-sdk', ...(profile.model ? { model: profile.model } : {}), capabilities,
248
+ unsupported: ['eve-info', 'eve-workflows', 'eve-schedules', 'file-attachments', 'approvals', 'subagents', 'session-reset', 'session-clear', 'session-compact', 'client-context', 'output-schema'] },
249
+ restricted,
250
+ subscribe: listener => { listeners.add(listener); },
251
+ async create() { return randomUUID(); },
252
+ async resume(sessionId) {
253
+ // Only re-attach a transcript that actually exists; never mint a replacement.
254
+ if (sdk.getSessionInfo && !await sdk.getSessionInfo(sessionId, { dir: profile.cwd }))
255
+ throw Error('claude_session_not_found');
256
+ started.add(sessionId);
257
+ },
258
+ 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.' })); },
259
+ async cancel(sessionId) {
260
+ const turn = turns.get(sessionId);
261
+ if (!turn)
262
+ return false;
263
+ turn.cancelled = true;
264
+ const interrupted = await Promise.race([Promise.resolve(turn.query?.interrupt?.()).then(() => true, () => false), new Promise(r => setTimeout(() => r(false), 5000))]);
265
+ if (!interrupted)
266
+ turn.abort?.abort();
267
+ else
268
+ setTimeout(() => { if (turns.get(sessionId) === turn)
269
+ turn.abort?.abort(); }, 10000).unref();
270
+ return true;
271
+ },
272
+ access: {
273
+ async select(mode, revision) {
274
+ if (mode === 'restricted')
275
+ return { ...restricted(), revision };
276
+ if (mode === 'full')
277
+ return { mode, revision, tools: ['*'] };
278
+ // Snapshot now: later profile edits never silently widen an existing session.
279
+ return { mode, revision, tools: [...profile.nativeTools] };
280
+ },
281
+ },
282
+ async close() { for (const turn of turns.values()) {
283
+ turn.cancelled = true;
284
+ turn.abort?.abort();
285
+ } },
286
+ };
287
+ }
@@ -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
+ }>;
@@ -0,0 +1,85 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { PortalHttpError } from '@tealbrick/portal';
3
+ import { readKit } from './index.js';
4
+ import { harnessLabels } from './native-selection.js';
5
+ import { normalizeOrigin, readServeConfig, writeServeConfig } from './native-serve-config.js';
6
+ /** Register this kit root's public chat endpoint on its EXISTING native Portal agent.
7
+ * Never creates or relabels an identity: the URL goes through Portal's owned-connection
8
+ * upsert, which preserves the agent's harness runtime (the Eve registration route would
9
+ * rewrite it to "eve"). The user token stays in memory. */
10
+ export async function enrollNative(root, input, login, options = {}) {
11
+ root = await realpath(root);
12
+ const kit = await readKit(root);
13
+ if (!kit.native || !kit.runtime)
14
+ throw Error('kit_native_setup_required');
15
+ const { runtime } = kit, label = harnessLabels[kit.native.harness];
16
+ if (login.issuer !== runtime.issuer || login.org !== runtime.org)
17
+ throw Error('kit_runtime_existing_binding_mismatch');
18
+ const url = normalizeOrigin(input.url), old = await readServeConfig(root), port = input.port ?? old?.port ?? 5331;
19
+ if (!Number.isInteger(port) || port < 1024 || port > 65535)
20
+ throw Error('kit_native_serve_invalid_port');
21
+ const path = `/api/workspace?workspaceId=${encodeURIComponent(runtime.workspaceId)}`;
22
+ const owned = (snapshot) => {
23
+ const agent = snapshot.agents?.find(a => a.id === runtime.agentId && a.bindable);
24
+ const nodes = snapshot.draft?.nodes?.filter(n => n.data?.kind === 'agent' && n.data.agentId === runtime.agentId) ?? [];
25
+ if (snapshot.workspace?.id !== runtime.workspaceId || !agent || nodes.length !== 1)
26
+ throw Error('kit_owned_agent_card_required');
27
+ if (agent.runtime !== label || nodes[0].data.harness !== label)
28
+ throw Error('kit_native_portal_harness_mismatch');
29
+ return { agent, node: nodes[0] };
30
+ };
31
+ const { agent } = owned(await login.client.request(path));
32
+ let registered;
33
+ try {
34
+ registered = await login.client.request('/api/connections', { name: agent.name, runtime: label, url });
35
+ }
36
+ catch (error) {
37
+ if (error instanceof PortalHttpError && [404, 405].includes(error.status))
38
+ throw Error('kit_portal_connections_unsupported');
39
+ throw error;
40
+ }
41
+ const c = registered.connection;
42
+ if (c?.id !== agent.id || c.url !== url || c.runtime !== label)
43
+ throw Error('kit_portal_registration_mismatch');
44
+ await writeServeConfig(root, { ...(old ?? { version: 1, port }), version: 1, port, publicUrl: url, agent: { id: agent.id, name: agent.name, ownerId: login.userId } });
45
+ // Re-read after registration: preserve concurrent canvas changes and revision checks.
46
+ for (let attempt = 0;; attempt++) {
47
+ const fresh = await login.client.request(path), { node } = owned(fresh);
48
+ if (node.data.endpoint === url && node.data.authMethod === 'portal-pairing')
49
+ break;
50
+ const nodes = fresh.draft.nodes.map(n => n.id === node.id ? { ...n, data: { ...n.data, endpoint: url, authMethod: 'portal-pairing' } } : n);
51
+ try {
52
+ await login.client.request('/api/workspace/draft', { workspaceId: runtime.workspaceId, draftRevision: fresh.draftRevision, draft: { ...fresh.draft, nodes } });
53
+ break;
54
+ }
55
+ catch (error) {
56
+ if (error instanceof PortalHttpError && error.code === 'draft_changed_reload' && attempt < 2)
57
+ continue;
58
+ throw error;
59
+ }
60
+ }
61
+ const directory = await login.client.request(`/api/me/agents?workspaceId=${encodeURIComponent(runtime.workspaceId)}`);
62
+ if (directory.workspaceId !== runtime.workspaceId || !directory.agents?.some(a => a.id === agent.id && a.url === url))
63
+ throw Error('kit_portal_sidebar_verification_failed');
64
+ const endpoint = { healthy: false, authenticated: false, reason: undefined };
65
+ if (options.verifyEndpoint !== false) {
66
+ const f = options.fetch ?? fetch, get = (route, headers = {}) => f(url + route, { headers, redirect: 'error', signal: AbortSignal.timeout(10000) });
67
+ try {
68
+ const health = await get('/healthz'), body = health.ok ? await health.json() : undefined;
69
+ endpoint.healthy = body?.harness === kit.native.harness;
70
+ if (!endpoint.healthy)
71
+ endpoint.reason = 'health_check_failed';
72
+ else if (login.identity) {
73
+ const info = await get('/tealbrick/v1/info', { authorization: `Bearer ${login.identity.token}`, 'x-tealbrick-bundle': login.identity.bundle });
74
+ const value = info.ok ? await info.json() : undefined;
75
+ endpoint.authenticated = value?.harness === kit.native.harness && value.name === agent.name;
76
+ if (!endpoint.authenticated)
77
+ endpoint.reason = 'authenticated_info_failed_' + info.status;
78
+ }
79
+ }
80
+ catch {
81
+ endpoint.reason = 'endpoint_unreachable';
82
+ }
83
+ }
84
+ return { enrolled: true, agentId: agent.id, name: agent.name, harness: label, endpoint: url, workspaceId: runtime.workspaceId, portalVerified: true, endpointVerified: endpoint.healthy && endpoint.authenticated, ...(endpoint.reason ? { endpointReason: endpoint.reason } : {}) };
85
+ }