@tealbrick/kit 0.3.0-rc.1 → 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.
@@ -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
+ }
@@ -37,6 +37,8 @@ export async function selectNativeIdentity(root, flags, login, prompts) {
37
37
  if (workspaceName && flags.node)
38
38
  throw Error('kit_native_existing_node_requires_workspace');
39
39
  const cards = directory.agents.filter(a => a.harness === harness).map(agent => ({ agent }));
40
+ const unavailable = directory.agents.filter(a => a.harness !== harness).map(a => `${a.name} (${a.harness})`);
41
+ const context = unavailable.length ? ` Unavailable for ${harness}: ${unavailable.join(', ')}. Existing cards cannot be converted.` : '';
40
42
  let agentId = flags.agent;
41
43
  if (flags['agent-name']) {
42
44
  const matches = cards.filter((c) => c.agent.name === flags['agent-name']);
@@ -48,9 +50,9 @@ export async function selectNativeIdentity(root, flags, login, prompts) {
48
50
  agentId = old.runtime.agentId;
49
51
  let name = flags['create-agent'];
50
52
  if (!agentId && !name) {
51
- const choice = cards.length ? await prompts.choose('Choose an agent to attach', [...cards.map((c) => ({ name: `${c.agent.name} (${c.agent.id})`, value: c.agent.id })), { name: 'Create an agent', value: '__create__' }]) : '__create__';
53
+ const choice = cards.length ? await prompts.choose('Choose an agent to attach.' + context, [...cards.map((c) => ({ name: `${c.agent.name} — ${c.agent.harness} (${c.agent.id})`, value: c.agent.id })), { name: 'Create an agent', value: '__create__' }]) : '__create__';
52
54
  if (choice === '__create__')
53
- name = await prompts.name('New agent name');
55
+ name = await prompts.name('New agent name.' + context);
54
56
  else {
55
57
  const card = cards.find((c) => c.agent.id === choice);
56
58
  if (!card)
@@ -58,8 +60,21 @@ export async function selectNativeIdentity(root, flags, login, prompts) {
58
60
  agentId = card.agent.id;
59
61
  }
60
62
  }
61
- if (name)
62
- return { workspace, agent: { name: validName(name) } };
63
+ while (name) {
64
+ name = validName(name);
65
+ const conflicts = directory.agents.filter(a => a.name === name);
66
+ if (!conflicts.length)
67
+ return { workspace, agent: { name } };
68
+ const choice = await prompts.choose(`The name "${name}" already belongs to ${conflicts.map(a => `${a.name} (${a.harness}, ${a.id})`).join(', ')}. Select a compatible ${harness} card or choose another name.`, [...cards.map(c => ({ name: `${c.agent.name} — ${c.agent.harness} (${c.agent.id})`, value: c.agent.id })), { name: 'Choose another name', value: '__create__' }]);
69
+ if (choice === '__create__') {
70
+ name = await prompts.name('Different agent name.' + context);
71
+ continue;
72
+ }
73
+ if (!cards.some(c => c.agent.id === choice))
74
+ throw Error('kit_owned_agent_card_required');
75
+ agentId = choice;
76
+ name = undefined;
77
+ }
63
78
  const matches = cards.filter((c) => c.agent.id === agentId);
64
79
  if (matches.length !== 1)
65
80
  throw Error('kit_owned_agent_card_required');
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ export declare const hostPattern: RegExp;
3
+ declare const stdio: z.ZodObject<{
4
+ type: z.ZodOptional<z.ZodLiteral<"stdio">>;
5
+ command: z.ZodString;
6
+ args: z.ZodOptional<z.ZodArray<z.ZodString>>;
7
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
8
+ }, z.core.$strict>;
9
+ export declare const tealbrickCallGrant: z.ZodObject<{
10
+ registrationId: z.ZodString;
11
+ operation: z.ZodString;
12
+ inputEquals: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
13
+ }, z.core.$strict>;
14
+ declare const effort: z.ZodEnum<{
15
+ low: "low";
16
+ high: "high";
17
+ medium: "medium";
18
+ xhigh: "xhigh";
19
+ max: "max";
20
+ }>;
21
+ /** Tools an agent may add to its default (Restricted) session access: research and expert delegation, never shell or writes. */
22
+ export declare const defaultAccessTools: readonly ["WebSearch", "WebFetch", "Agent", "TodoWrite"];
23
+ export declare const serveConfigSchema: z.ZodObject<{
24
+ version: z.ZodLiteral<1>;
25
+ port: z.ZodNumber;
26
+ publicUrl: z.ZodOptional<z.ZodString>;
27
+ allowedHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
+ maxActiveTurns: z.ZodOptional<z.ZodNumber>;
29
+ agent: z.ZodOptional<z.ZodObject<{
30
+ id: z.ZodString;
31
+ name: z.ZodString;
32
+ ownerId: z.ZodString;
33
+ }, z.core.$strict>>;
34
+ claude: z.ZodOptional<z.ZodObject<{
35
+ sdkModule: z.ZodOptional<z.ZodString>;
36
+ cwd: z.ZodString;
37
+ instructionsFile: z.ZodOptional<z.ZodString>;
38
+ recipe: z.ZodOptional<z.ZodObject<{
39
+ path: z.ZodString;
40
+ agent: z.ZodString;
41
+ }, z.core.$strict>>;
42
+ model: z.ZodOptional<z.ZodString>;
43
+ effort: z.ZodOptional<z.ZodEnum<{
44
+ low: "low";
45
+ high: "high";
46
+ medium: "medium";
47
+ xhigh: "xhigh";
48
+ max: "max";
49
+ }>>;
50
+ maxTurns: z.ZodOptional<z.ZodNumber>;
51
+ maxBudgetUsd: z.ZodOptional<z.ZodNumber>;
52
+ timeoutSeconds: z.ZodOptional<z.ZodNumber>;
53
+ defaultTools: z.ZodOptional<z.ZodArray<z.ZodEnum<{
54
+ WebSearch: "WebSearch";
55
+ WebFetch: "WebFetch";
56
+ Agent: "Agent";
57
+ TodoWrite: "TodoWrite";
58
+ }>>>;
59
+ experts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
60
+ promptFile: z.ZodString;
61
+ description: z.ZodOptional<z.ZodString>;
62
+ tools: z.ZodOptional<z.ZodArray<z.ZodEnum<{
63
+ WebSearch: "WebSearch";
64
+ WebFetch: "WebFetch";
65
+ Read: "Read";
66
+ Glob: "Glob";
67
+ Grep: "Grep";
68
+ }>>>;
69
+ maxTurns: z.ZodOptional<z.ZodNumber>;
70
+ effort: z.ZodOptional<z.ZodEnum<{
71
+ low: "low";
72
+ high: "high";
73
+ medium: "medium";
74
+ xhigh: "xhigh";
75
+ max: "max";
76
+ }>>;
77
+ }, z.core.$strict>>>;
78
+ nativeTools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>>;
79
+ readTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
80
+ mcpServers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
81
+ type: z.ZodOptional<z.ZodLiteral<"stdio">>;
82
+ command: z.ZodString;
83
+ args: z.ZodOptional<z.ZodArray<z.ZodString>>;
84
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
85
+ }, z.core.$strict>>>;
86
+ tealbrickCalls: z.ZodOptional<z.ZodArray<z.ZodObject<{
87
+ registrationId: z.ZodString;
88
+ operation: z.ZodString;
89
+ inputEquals: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
90
+ }, z.core.$strict>>>;
91
+ requireSubscription: z.ZodOptional<z.ZodBoolean>;
92
+ envPassthrough: z.ZodOptional<z.ZodArray<z.ZodString>>;
93
+ }, z.core.$strict>>;
94
+ codex: z.ZodOptional<z.ZodObject<{
95
+ codexBin: z.ZodString;
96
+ cwd: z.ZodString;
97
+ }, z.core.$strict>>;
98
+ }, z.core.$strict>;
99
+ export type ServeConfig = z.infer<typeof serveConfigSchema>;
100
+ export interface ClaudeProfile {
101
+ cwd: string;
102
+ sdkModule?: string;
103
+ instructions?: string;
104
+ model?: string;
105
+ effort?: z.infer<typeof effort>;
106
+ maxTurns: number;
107
+ maxBudgetUsd?: number;
108
+ timeoutSeconds: number;
109
+ defaultTools: string[];
110
+ experts: Record<string, ClaudeExpert>;
111
+ nativeTools: string[];
112
+ readTools: string[];
113
+ mcpServers: Record<string, z.infer<typeof stdio>>;
114
+ tealbrickCalls: Array<z.infer<typeof tealbrickCallGrant>>;
115
+ requireSubscription: boolean;
116
+ envPassthrough: string[];
117
+ }
118
+ export interface ClaudeExpert {
119
+ description: string;
120
+ prompt: string;
121
+ tools: string[];
122
+ maxTurns: number;
123
+ effort?: z.infer<typeof effort>;
124
+ }
125
+ export declare const servePaths: (root: string) => {
126
+ config: string;
127
+ state: string;
128
+ sdk: string;
129
+ };
130
+ export declare function normalizeOrigin(value: string): string;
131
+ /** Host header values a TLS-terminating proxy for `publicUrl` may present. */
132
+ export declare function publicHosts(config: ServeConfig): string[];
133
+ export declare function readServeConfig(root: string): Promise<ServeConfig | undefined>;
134
+ export declare function writeServeConfig(root: string, config: ServeConfig): Promise<void>;
135
+ /** The kit-generated Claude attachment: tealbrick MCP server plus the child guard hook. */
136
+ declare const generatedClaude: z.ZodObject<{
137
+ settings: z.ZodObject<{
138
+ hooks: z.ZodObject<{
139
+ PreToolUse: z.ZodArray<z.ZodObject<{
140
+ matcher: z.ZodLiteral<"mcp__tealbrick__.*">;
141
+ hooks: z.ZodArray<z.ZodObject<{
142
+ type: z.ZodLiteral<"command">;
143
+ command: z.ZodString;
144
+ args: z.ZodArray<z.ZodString>;
145
+ }, z.core.$strict>>;
146
+ }, z.core.$strict>>;
147
+ }, z.core.$strict>;
148
+ }, z.core.$strict>;
149
+ mcpServers: z.ZodObject<{
150
+ tealbrick: z.ZodObject<{
151
+ command: z.ZodString;
152
+ args: z.ZodArray<z.ZodString>;
153
+ }, z.core.$strict>;
154
+ }, z.core.$strict>;
155
+ }, z.core.$strict>;
156
+ export type ClaudeAttachment = z.infer<typeof generatedClaude>;
157
+ export declare function readClaudeAttachment(root: string): Promise<ClaudeAttachment>;
158
+ /** Resolve the effective Claude profile. Explicit serve fields override the imported recipe. */
159
+ export declare function resolveClaudeProfile(root: string, config: ServeConfig): Promise<ClaudeProfile>;
160
+ export {};
@@ -0,0 +1,190 @@
1
+ import { z } from 'zod';
2
+ import { lstat, readFile, realpath, writeFile, rename, mkdir } from 'node:fs/promises';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ /** Operator configuration for `tealbrick native serve`. Contains no credentials. */
6
+ const absolute = z.string().min(1).refine(v => isAbsolute(v) && !v.includes('\0'), 'absolute path required');
7
+ export const hostPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*(?::\d{1,5})?$/i;
8
+ const mcpTool = z.string().regex(/^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$/);
9
+ const tool = z.union([z.string().regex(/^[A-Z][A-Za-z]{1,40}$/), mcpTool]);
10
+ const serverName = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/).refine(n => n !== 'tealbrick', 'the tealbrick MCP server comes from .tealbrick/native-sdk.json');
11
+ const stdio = z.object({ type: z.literal('stdio').optional(), command: z.string().min(1), args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional() }).strict();
12
+ export const tealbrickCallGrant = z.object({ registrationId: z.string().min(1), operation: z.string().min(1), inputEquals: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).default({}) }).strict();
13
+ const effort = z.enum(['low', 'medium', 'high', 'xhigh', 'max']);
14
+ /** Tools an agent may add to its default (Restricted) session access: research and expert delegation, never shell or writes. */
15
+ export const defaultAccessTools = ['WebSearch', 'WebFetch', 'Agent', 'TodoWrite'];
16
+ /** Tools an expert subagent may hold; workspace reads stay contained and network content stays untrusted. */
17
+ const expertTool = z.enum(['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
18
+ const expertName = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/);
19
+ const expert = z.object({ promptFile: absolute, description: z.string().min(1).max(300).optional(), tools: z.array(expertTool).max(8).optional(), maxTurns: z.number().int().min(1).max(50).optional(), effort: effort.optional() }).strict();
20
+ const claude = z.object({
21
+ /** Package directory or entry file of @anthropic-ai/claude-agent-sdk. Default: resolved from the kit root. */
22
+ sdkModule: absolute.optional(),
23
+ /** Working directory. Claude session transcripts are keyed by it; keep it stable. */
24
+ cwd: absolute,
25
+ instructionsFile: absolute.optional(),
26
+ /** Import model/limits/MCP/read tools/app-call ceilings from an existing tealbrick-native recipe file. */
27
+ recipe: z.object({ path: absolute, agent: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }).strict().optional(),
28
+ model: z.string().min(1).max(100).optional(),
29
+ effort: effort.optional(),
30
+ maxTurns: z.number().int().min(1).max(200).optional(),
31
+ maxBudgetUsd: z.number().positive().max(100).optional(),
32
+ timeoutSeconds: z.number().int().min(10).max(7200).optional(),
33
+ /** Extra tools in this agent's default (Restricted) session access. */
34
+ defaultTools: z.array(z.enum(defaultAccessTools)).max(4).optional(),
35
+ /** Expert subagents the Agent tool may delegate to, foreground only, inheriting the session model. */
36
+ experts: z.record(expertName, expert).optional(),
37
+ /** Tool allowlist for the opt-in "Native defaults" session access mode. */
38
+ nativeTools: z.array(tool).max(64).optional(),
39
+ /** Operator-vetted read-only MCP tools, also available in Restricted mode. */
40
+ readTools: z.array(mcpTool).max(32).optional(),
41
+ mcpServers: z.record(serverName, stdio).optional(),
42
+ tealbrickCalls: z.array(tealbrickCallGrant).max(64).optional(),
43
+ /** Refuse turns unless the SDK reports first-party subscription sign-in (no API-key fallback). */
44
+ requireSubscription: z.boolean().optional(),
45
+ /** Extra environment variable names passed to the SDK process. TEALBRICK_* is never passed. */
46
+ envPassthrough: z.array(z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/).refine(n => !n.startsWith('TEALBRICK_'), 'Teal Brick credentials are never passed to the harness')).max(32).optional(),
47
+ }).strict();
48
+ const codex = z.object({ codexBin: absolute, cwd: absolute }).strict();
49
+ export const serveConfigSchema = z.object({
50
+ version: z.literal(1),
51
+ port: z.number().int().min(1024).max(65535),
52
+ /** Public HTTPS origin registered on Portal (for example a tailscale serve URL). */
53
+ publicUrl: z.string().url().optional(),
54
+ /** Extra exact Host header values. The public URL host and loopback are added automatically. */
55
+ allowedHosts: z.array(z.string().regex(hostPattern)).max(8).optional(),
56
+ maxActiveTurns: z.number().int().min(1).max(16).optional(),
57
+ /** Written by `native enroll`: Portal identity used to verify every request. */
58
+ agent: z.object({ id: z.string().min(1), name: z.string().min(1).max(80), ownerId: z.string().min(1) }).strict().optional(),
59
+ claude: claude.optional(),
60
+ codex: codex.optional(),
61
+ }).strict();
62
+ export const servePaths = (root) => ({ config: join(root, '.tealbrick', 'native-serve.json'), state: join(root, '.tealbrick', 'native-serve'), sdk: join(root, '.tealbrick', 'native-sdk.json') });
63
+ async function regularFile(path, { ownerOnly }) {
64
+ const stat = await lstat(path);
65
+ if (!stat.isFile() || stat.isSymbolicLink())
66
+ throw Error('kit_native_serve_unsafe_file');
67
+ if (process.platform !== 'win32' && (stat.mode & (ownerOnly ? 0o077 : 0o022)))
68
+ throw Error('kit_native_serve_file_permissions');
69
+ }
70
+ /** Stable, value-free error codes for operator files; never echo file contents. */
71
+ function parsed(schema, text, code) {
72
+ let value;
73
+ try {
74
+ value = JSON.parse(text);
75
+ }
76
+ catch {
77
+ throw Error(code);
78
+ }
79
+ const result = schema.safeParse(value);
80
+ if (!result.success)
81
+ throw Error(code + ':' + result.error.issues.map(i => i.path.join('.') || 'root').slice(0, 5).join(','));
82
+ return result.data;
83
+ }
84
+ export function normalizeOrigin(value) {
85
+ const url = new URL(value);
86
+ const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
87
+ if (!(url.protocol === 'https:' || url.protocol === 'http:' && loopback) || url.username || url.password || url.search || url.hash || url.pathname !== '/')
88
+ throw Error('kit_native_public_url_https_origin_required');
89
+ return url.origin;
90
+ }
91
+ /** Host header values a TLS-terminating proxy for `publicUrl` may present. */
92
+ export function publicHosts(config) {
93
+ const hosts = [...(config.allowedHosts ?? [])];
94
+ if (config.publicUrl) {
95
+ const url = new URL(config.publicUrl);
96
+ hosts.push(url.host);
97
+ if (!url.port)
98
+ hosts.push(url.hostname + ':' + (url.protocol === 'https:' ? 443 : 80));
99
+ }
100
+ return [...new Set(hosts.map(h => h.toLowerCase()))];
101
+ }
102
+ export async function readServeConfig(root) {
103
+ const path = servePaths(root).config;
104
+ try {
105
+ await regularFile(path, { ownerOnly: true });
106
+ }
107
+ catch (e) {
108
+ if (e.code === 'ENOENT')
109
+ return undefined;
110
+ throw e;
111
+ }
112
+ const config = parsed(serveConfigSchema, await readFile(path, 'utf8'), 'kit_native_serve_config_invalid');
113
+ if (config.publicUrl && normalizeOrigin(config.publicUrl) !== config.publicUrl)
114
+ throw Error('kit_native_public_url_https_origin_required');
115
+ return config;
116
+ }
117
+ export async function writeServeConfig(root, config) {
118
+ const value = serveConfigSchema.parse(config), { config: path } = servePaths(root);
119
+ await mkdir(join(root, '.tealbrick'), { recursive: true, mode: 0o700 });
120
+ try {
121
+ if ((await lstat(path)).isSymbolicLink())
122
+ throw Error('kit_symlink_path');
123
+ }
124
+ catch (e) {
125
+ if (e.code !== 'ENOENT')
126
+ throw e;
127
+ }
128
+ const tmp = path + '.' + randomUUID();
129
+ await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
130
+ await rename(tmp, path);
131
+ }
132
+ /** The kit-generated Claude attachment: tealbrick MCP server plus the child guard hook. */
133
+ const generatedClaude = z.object({
134
+ settings: z.object({ hooks: z.object({ PreToolUse: z.array(z.object({ matcher: z.literal('mcp__tealbrick__.*'), hooks: z.array(z.object({ type: z.literal('command'), command: z.string().min(1), args: z.array(z.string()).min(1) }).strict()).min(1) }).strict()).min(1) }).strict() }).strict(),
135
+ mcpServers: z.object({ tealbrick: z.object({ command: z.string().min(1), args: z.array(z.string()).min(1) }).strict() }).strict(),
136
+ }).strict();
137
+ export async function readClaudeAttachment(root) {
138
+ const path = servePaths(root).sdk;
139
+ await regularFile(path, { ownerOnly: true });
140
+ const value = parsed(generatedClaude, await readFile(path, 'utf8'), 'kit_native_sdk_config_invalid');
141
+ const args = value.mcpServers.tealbrick.args, at = args.indexOf('--root');
142
+ if (args.slice(-4, -2).join(' ') !== 'native mcp' || at < 0 || resolve(args[at + 1] ?? '') !== await realpath(root))
143
+ throw Error('kit_native_sdk_config_root_mismatch');
144
+ return value;
145
+ }
146
+ const recipeSchema = z.object({
147
+ model: z.string().min(1).optional(), effort: effort.optional(), maxTurns: z.number().int().min(1).max(200).optional(),
148
+ maxBudgetUsd: z.number().positive().max(100).optional(), timeoutSeconds: z.number().int().min(10).max(7200).optional(),
149
+ agents: z.record(z.string(), z.object({ mcpServers: z.record(serverName, stdio).optional(), readTools: z.array(mcpTool).max(32).optional(), tealbrickCalls: z.array(tealbrickCallGrant).max(64).optional(), tealbrickSdkConfig: absolute.optional() }).passthrough()),
150
+ }).passthrough();
151
+ /** Resolve the effective Claude profile. Explicit serve fields override the imported recipe. */
152
+ export async function resolveClaudeProfile(root, config) {
153
+ const c = config.claude;
154
+ if (!c)
155
+ throw Error('kit_native_serve_claude_profile_required');
156
+ let recipe, agent;
157
+ if (c.recipe) {
158
+ await regularFile(c.recipe.path, { ownerOnly: false });
159
+ recipe = parsed(recipeSchema, await readFile(c.recipe.path, 'utf8'), 'kit_native_serve_recipe_invalid');
160
+ agent = recipe.agents[c.recipe.agent];
161
+ if (!agent)
162
+ throw Error('kit_native_serve_recipe_agent_missing');
163
+ // The recipe must describe THIS kit root's Portal connection, never another agent's.
164
+ if (agent.tealbrickSdkConfig && await realpath(agent.tealbrickSdkConfig) !== await realpath(servePaths(root).sdk))
165
+ throw Error('kit_native_serve_recipe_connection_mismatch');
166
+ }
167
+ const cwd = resolve(c.cwd), protectedRoot = join(await realpath(root), '.tealbrick');
168
+ if (protectedRoot.startsWith(cwd + '/') || cwd === protectedRoot || cwd.startsWith(protectedRoot + '/'))
169
+ throw Error('kit_native_serve_cwd_contains_credentials');
170
+ if (!(await lstat(cwd).catch(() => undefined))?.isDirectory())
171
+ throw Error('kit_native_serve_cwd_missing');
172
+ const instructions = c.instructionsFile ? (await regularFile(c.instructionsFile, { ownerOnly: false }), await readFile(c.instructionsFile, 'utf8')) : undefined;
173
+ if (instructions && instructions.length > 200000)
174
+ throw Error('kit_native_serve_instructions_too_large');
175
+ const readTools = c.readTools ?? agent?.readTools ?? [];
176
+ const experts = {};
177
+ for (const [name, e] of Object.entries(c.experts ?? {})) {
178
+ await regularFile(e.promptFile, { ownerOnly: false });
179
+ const prompt = await readFile(e.promptFile, 'utf8');
180
+ if (!prompt.trim() || prompt.length > 50000)
181
+ throw Error('kit_native_serve_expert_prompt_invalid:' + name);
182
+ experts[name] = { description: e.description ?? name.replaceAll('-', ' ') + ' for a bounded assignment', prompt, tools: [...new Set(e.tools ?? [])], maxTurns: e.maxTurns ?? 8, ...(e.effort ? { effort: e.effort } : {}) };
183
+ }
184
+ return { cwd, sdkModule: c.sdkModule, instructions, model: c.model ?? recipe?.model, effort: c.effort ?? recipe?.effort,
185
+ maxTurns: c.maxTurns ?? recipe?.maxTurns ?? 24, maxBudgetUsd: c.maxBudgetUsd ?? recipe?.maxBudgetUsd, timeoutSeconds: c.timeoutSeconds ?? recipe?.timeoutSeconds ?? 900,
186
+ defaultTools: [...new Set(c.defaultTools ?? [])], experts,
187
+ nativeTools: c.nativeTools ?? ['Read', 'Glob', 'Grep', 'TodoWrite'], readTools: readTools.filter(t => !t.startsWith('mcp__tealbrick__')),
188
+ mcpServers: c.mcpServers ?? agent?.mcpServers ?? {}, tealbrickCalls: c.tealbrickCalls ?? agent?.tealbrickCalls ?? [],
189
+ requireSubscription: c.requireSubscription ?? false, envPassthrough: c.envPassthrough ?? [] };
190
+ }
@@ -0,0 +1,50 @@
1
+ import { type AuthResult } from '@tealbrick/portal';
2
+ import { type ClaudeSdk } from './native-claude.js';
3
+ /** LangChain has no stable session/stream/cancel contract to drive from here yet. */
4
+ export declare function langchainHarness(): never;
5
+ export interface ServeDependencies {
6
+ authenticate?: (request: Request) => Promise<AuthResult>;
7
+ claudeSdk?: ClaudeSdk;
8
+ log?: (event: Record<string, unknown>) => void;
9
+ env?: NodeJS.ProcessEnv;
10
+ }
11
+ /** Build (but do not bind) the owner-authenticated chat endpoint for this kit root. */
12
+ export declare function createNativeServe(root: string, deps?: ServeDependencies): Promise<{
13
+ port: number;
14
+ harness: "claude" | "codex" | "langchain";
15
+ close: () => Promise<void>;
16
+ server: import("http").Server;
17
+ sessions: Map<string, import("@tealbrick/portal/native-bridge").BridgeSession<any>>;
18
+ }>;
19
+ export declare function serveNative(root: string): Promise<void>;
20
+ export declare function launchdLabel(root: string): string;
21
+ /** User LaunchAgent: restarts on crash, logs to the kit root's private state directory. */
22
+ export declare function launchdPlist(root: string, options?: {
23
+ node?: string;
24
+ cli?: string;
25
+ }): string;
26
+ type Exec = (file: string, args: string[]) => Promise<unknown>;
27
+ export declare function installLaunchd(root: string, options?: {
28
+ home?: string;
29
+ exec?: Exec;
30
+ load?: boolean;
31
+ uid?: number;
32
+ platform?: string;
33
+ }): Promise<{
34
+ installed: boolean;
35
+ label: string;
36
+ plist: string;
37
+ loaded: boolean;
38
+ logs: string;
39
+ }>;
40
+ export declare function uninstallLaunchd(root: string, options?: {
41
+ home?: string;
42
+ exec?: Exec;
43
+ uid?: number;
44
+ platform?: string;
45
+ }): Promise<{
46
+ uninstalled: boolean;
47
+ label: string;
48
+ plist: string;
49
+ }>;
50
+ export {};
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { mkdir, realpath, writeFile, rename, unlink, lstat } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { promisify } from 'node:util';
8
+ import { createPortalVerifier } from '@tealbrick/portal';
9
+ import { createNativeBridge } from '@tealbrick/portal/native-bridge';
10
+ import { CodexAppServer, codexHarness } from '@tealbrick/portal/native-codex';
11
+ import { readKit } from './index.js';
12
+ import { nativeSdkConfig } from './native-setup.js';
13
+ import { claudeHarness, loadClaudeSdk } from './native-claude.js';
14
+ import { publicHosts, readClaudeAttachment, readServeConfig, resolveClaudeProfile, servePaths } from './native-serve-config.js';
15
+ /** LangChain has no stable session/stream/cancel contract to drive from here yet. */
16
+ export function langchainHarness() { throw Error('kit_native_serve_langchain_unsupported'); }
17
+ /** Build (but do not bind) the owner-authenticated chat endpoint for this kit root. */
18
+ export async function createNativeServe(root, deps = {}) {
19
+ root = await realpath(root);
20
+ const kit = await readKit(root);
21
+ if (!kit.native || !kit.runtime)
22
+ throw Error('kit_native_setup_required');
23
+ if (!kit.native.active)
24
+ throw Error('runtime_product_inactive');
25
+ const config = await readServeConfig(root);
26
+ if (!config)
27
+ throw Error('kit_native_serve_config_required');
28
+ if (!config.agent)
29
+ throw Error('kit_native_serve_enroll_required');
30
+ if (config.agent.id !== kit.runtime.agentId)
31
+ throw Error('kit_native_serve_identity_mismatch');
32
+ const paths = servePaths(root), log = deps.log;
33
+ await mkdir(paths.state, { recursive: true, mode: 0o700 });
34
+ let adapter;
35
+ let codex;
36
+ if (kit.native.harness === 'claude') {
37
+ const profile = await resolveClaudeProfile(root, config);
38
+ adapter = claudeHarness({ name: config.agent.name, profile, attachment: await readClaudeAttachment(root), sdk: deps.claudeSdk ?? await loadClaudeSdk(root, profile.sdkModule), protectedPaths: [join(root, '.tealbrick')], log, env: deps.env });
39
+ }
40
+ else if (kit.native.harness === 'codex') {
41
+ if (!config.codex)
42
+ throw Error('kit_native_serve_codex_profile_required');
43
+ codex = new CodexAppServer(config.codex.codexBin, config.codex.cwd);
44
+ await codex.start();
45
+ const mcp = nativeSdkConfig('codex', root, { codexDelegationDisabled: true }).config.mcp_servers.tealbrick;
46
+ adapter = codexHarness(codex, { name: config.agent.name, cwd: config.codex.cwd, mcp, features: { multi_agent_v2: false }, log });
47
+ }
48
+ else
49
+ adapter = langchainHarness();
50
+ const authenticate = deps.authenticate ?? createPortalVerifier({ issuer: kit.runtime.issuer, org: kit.runtime.org, agent: config.agent.name }).authenticate;
51
+ const bridge = await createNativeBridge({ name: config.agent.name, ownerId: config.agent.ownerId, root: paths.state, port: config.port, allowedHosts: publicHosts(config), authenticate, adapter, maxActiveTurns: config.maxActiveTurns ?? 2, log });
52
+ return { ...bridge, port: config.port, harness: kit.native.harness, close: async () => { await bridge.close(); await codex?.close(); } };
53
+ }
54
+ export async function serveNative(root) {
55
+ const serve = await createNativeServe(root);
56
+ // Loopback only. Remote reachability is a TLS-terminating proxy such as tailscale serve.
57
+ await new Promise((resolve, reject) => { serve.server.once('error', reject); serve.server.listen(serve.port, '127.0.0.1', () => resolve()); });
58
+ process.stderr.write(JSON.stringify({ event: 'native.serve.ready', harness: serve.harness, port: serve.port }) + '\n');
59
+ for (const signal of ['SIGTERM', 'SIGINT'])
60
+ process.once(signal, () => { void serve.close().finally(() => process.exit(0)); });
61
+ }
62
+ const xml = (v) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
63
+ export function launchdLabel(root) { return 'com.tealbrick.native-serve.' + createHash('sha256').update(root).digest('hex').slice(0, 12); }
64
+ /** User LaunchAgent: restarts on crash, logs to the kit root's private state directory. */
65
+ export function launchdPlist(root, options = {}) {
66
+ const node = options.node ?? process.execPath, cli = options.cli ?? fileURLToPath(new URL('./cli.js', import.meta.url)), logs = join(servePaths(root).state, 'logs');
67
+ const args = [node, cli, 'native', 'serve', '--root', root];
68
+ return `<?xml version="1.0" encoding="UTF-8"?>
69
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
70
+ <plist version="1.0">
71
+ <dict>
72
+ <key>Label</key><string>${xml(launchdLabel(root))}</string>
73
+ <key>ProgramArguments</key><array>${args.map(a => `<string>${xml(a)}</string>`).join('')}</array>
74
+ <key>WorkingDirectory</key><string>${xml(root)}</string>
75
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(dirname(node))}:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
76
+ <key>RunAtLoad</key><true/>
77
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
78
+ <key>ThrottleInterval</key><integer>30</integer>
79
+ <key>ProcessType</key><string>Background</string>
80
+ <key>Umask</key><integer>63</integer>
81
+ <key>StandardOutPath</key><string>${xml(join(logs, 'serve.out.log'))}</string>
82
+ <key>StandardErrorPath</key><string>${xml(join(logs, 'serve.err.log'))}</string>
83
+ </dict>
84
+ </plist>
85
+ `;
86
+ }
87
+ export async function installLaunchd(root, options = {}) {
88
+ if ((options.platform ?? process.platform) !== 'darwin')
89
+ throw Error('kit_launchd_macos_only');
90
+ root = await realpath(root);
91
+ const config = await readServeConfig(root);
92
+ if (!config?.agent)
93
+ throw Error('kit_native_serve_enroll_required');
94
+ const label = launchdLabel(root), dir = join(options.home ?? homedir(), 'Library', 'LaunchAgents'), path = join(dir, label + '.plist');
95
+ await mkdir(join(servePaths(root).state, 'logs'), { recursive: true, mode: 0o700 });
96
+ await mkdir(dir, { recursive: true });
97
+ try {
98
+ if ((await lstat(path)).isSymbolicLink())
99
+ throw Error('kit_symlink_path');
100
+ }
101
+ catch (e) {
102
+ if (e.code !== 'ENOENT')
103
+ throw e;
104
+ }
105
+ await writeFile(path + '.tmp', launchdPlist(root), { mode: 0o600 });
106
+ await rename(path + '.tmp', path);
107
+ const exec = options.exec ?? promisify(execFile), domain = 'gui/' + (options.uid ?? process.getuid?.() ?? 0);
108
+ if (options.load !== false) {
109
+ await exec('launchctl', ['bootout', domain + '/' + label]).catch(() => { });
110
+ await exec('launchctl', ['bootstrap', domain, path]);
111
+ }
112
+ return { installed: true, label, plist: path, loaded: options.load !== false, logs: join(servePaths(root).state, 'logs') };
113
+ }
114
+ export async function uninstallLaunchd(root, options = {}) {
115
+ if ((options.platform ?? process.platform) !== 'darwin')
116
+ throw Error('kit_launchd_macos_only');
117
+ root = await realpath(root);
118
+ const label = launchdLabel(root), path = join(options.home ?? homedir(), 'Library', 'LaunchAgents', label + '.plist');
119
+ const exec = options.exec ?? promisify(execFile);
120
+ await exec('launchctl', ['bootout', 'gui/' + (options.uid ?? process.getuid?.() ?? 0) + '/' + label]).catch(() => { });
121
+ await unlink(path).catch(e => { if (e.code !== 'ENOENT')
122
+ throw e; });
123
+ return { uninstalled: true, label, plist: path };
124
+ }