@byokit/openclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/LICENSE +202 -0
  3. package/README.md +174 -0
  4. package/dist/approvals.d.ts +29 -0
  5. package/dist/approvals.js +200 -0
  6. package/dist/bridge.d.ts +78 -0
  7. package/dist/bridge.js +345 -0
  8. package/dist/classify.d.ts +4 -0
  9. package/dist/classify.js +14 -0
  10. package/dist/config.d.ts +13 -0
  11. package/dist/config.js +74 -0
  12. package/dist/constants.d.ts +3 -0
  13. package/dist/constants.js +13 -0
  14. package/dist/device.d.ts +63 -0
  15. package/dist/device.js +192 -0
  16. package/dist/engine.d.ts +43 -0
  17. package/dist/engine.js +212 -0
  18. package/dist/generated/events.d.ts +61 -0
  19. package/dist/generated/events.js +1 -0
  20. package/dist/generated/methods.d.ts +2368 -0
  21. package/dist/generated/methods.js +1 -0
  22. package/dist/generated/params.d.ts +1717 -0
  23. package/dist/generated/params.js +5 -0
  24. package/dist/generated/report.json +1356 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.js +5 -0
  27. package/dist/kit.d.ts +108 -0
  28. package/dist/kit.js +655 -0
  29. package/dist/link.d.ts +24 -0
  30. package/dist/link.js +273 -0
  31. package/dist/members.d.ts +11 -0
  32. package/dist/members.js +23 -0
  33. package/dist/migrate.d.ts +22 -0
  34. package/dist/migrate.js +118 -0
  35. package/dist/notices.d.ts +9 -0
  36. package/dist/notices.js +75 -0
  37. package/dist/routes.d.ts +9 -0
  38. package/dist/routes.js +15 -0
  39. package/dist/routes.json +398 -0
  40. package/dist/runs.d.ts +14 -0
  41. package/dist/runs.js +110 -0
  42. package/dist/signin.d.ts +24 -0
  43. package/dist/signin.js +251 -0
  44. package/dist/testing/contract.d.ts +12 -0
  45. package/dist/testing/contract.js +250 -0
  46. package/dist/testing/fake-gateway.d.ts +19 -0
  47. package/dist/testing/fake-gateway.js +378 -0
  48. package/dist/testing/index.d.ts +3 -0
  49. package/dist/testing/index.js +4 -0
  50. package/dist/testing/model-stub.d.ts +29 -0
  51. package/dist/testing/model-stub.js +182 -0
  52. package/dist/transport.d.ts +22 -0
  53. package/dist/transport.js +94 -0
  54. package/dist/types.d.ts +125 -0
  55. package/dist/types.js +2 -0
  56. package/dist/words.d.ts +12 -0
  57. package/dist/words.js +26 -0
  58. package/dist/words.json +20 -0
  59. package/engine/package-lock.json +4680 -0
  60. package/engine/package.json +1 -0
  61. package/package.json +68 -0
  62. package/plugin/index.js +183 -0
  63. package/plugin/package.json +1 -0
  64. package/policy/policy.mjs +197 -0
@@ -0,0 +1,378 @@
1
+ // The fake Gateway (5.11): an in-memory transport double driven by default handlers and scripts. Use it as
2
+ // `new OpenClawKit({ transport: fake.factory, spawnEngine: false, ... })` or drive `fake.transport.request`
3
+ // directly. No engine, no network, no account.
4
+ import { connect } from 'node:net';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { readFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { ENGINE_VERSION, PROTOCOL_VERSION } from "../constants.js";
9
+ import { toolCalls } from "./model-stub.js";
10
+ // The device-code script from Crewhouse's openclaw-wizard.test.ts, ported: the step, then progress, then done.
11
+ const DEVICE_STEP = {
12
+ id: 'step-device', type: 'note', executor: 'client',
13
+ deviceCode: { code: 'CREW-2026', expiresInMinutes: 15 }, externalUrl: 'https://auth.openai.com/codex/device',
14
+ };
15
+ const PROGRESS_STEP = { id: 'step-wait', type: 'progress', message: 'Waiting for authorization', executor: 'gateway' };
16
+ const EVENTS = [
17
+ 'agent',
18
+ 'exec.approval.requested', 'exec.approval.resolved',
19
+ 'plugin.approval.requested', 'plugin.approval.resolved',
20
+ 'question.requested', 'question.resolved',
21
+ ];
22
+ /** Window between a run's last event and its completion, so an abort issued on the text event still lands. */
23
+ const ABORT_WINDOW_MS = 25;
24
+ const BRIDGE_TIMEOUT_MS = 30_000;
25
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
26
+ function mergeInto(target, patch) {
27
+ for (const [key, value] of Object.entries(patch)) {
28
+ const current = target[key];
29
+ if (isPlainObject(current) && isPlainObject(value))
30
+ mergeInto(current, value);
31
+ else
32
+ target[key] = value;
33
+ }
34
+ }
35
+ /** The one config invariant the engine itself keeps at runtime (5.6): memory search never leaves the free path. */
36
+ function enforceMemoryInvariants(config) {
37
+ const agents = (config.agents ?? {});
38
+ // A node is whatever holds `.memory.search`: the config itself, an agent default, or one agent entry.
39
+ const nodes = [config, agents.defaults, ...Object.values(agents.entries ?? {})];
40
+ for (const node of nodes) {
41
+ const search = node?.memory?.search;
42
+ if (!isPlainObject(search))
43
+ continue;
44
+ if ('provider' in search)
45
+ search.provider = 'none';
46
+ if ('fallback' in search)
47
+ search.fallback = 'none';
48
+ }
49
+ }
50
+ /**
51
+ * The plugin's tool table, read from beside the kit's socket the way the plugin reads it. Absent (a bare socket with
52
+ * no kit): every tool counts as the app's.
53
+ */
54
+ function pluginTable(sock) {
55
+ try {
56
+ return JSON.parse(readFileSync(join(dirname(sock), 'plugin', 'tools.json'), 'utf8'));
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ /** One newline-framed request over the bridge socket, exactly what the plugin does (5.9). */
63
+ function bridgeRequest(path, message) {
64
+ return new Promise((resolve, reject) => {
65
+ const socket = connect(path);
66
+ let buffer = '';
67
+ let timer;
68
+ const fail = (error) => {
69
+ clearTimeout(timer);
70
+ socket.destroy();
71
+ reject(error);
72
+ };
73
+ socket.once('error', fail);
74
+ socket.on('data', (chunk) => {
75
+ buffer += String(chunk);
76
+ if (buffer.length > 1_000_000)
77
+ return fail(new Error('bridge reply too large'));
78
+ const end = buffer.indexOf('\n');
79
+ if (end < 0)
80
+ return;
81
+ clearTimeout(timer);
82
+ socket.end();
83
+ try {
84
+ resolve(JSON.parse(buffer.slice(0, end)));
85
+ }
86
+ catch (error) {
87
+ fail(error);
88
+ }
89
+ });
90
+ socket.once('connect', () => {
91
+ timer = setTimeout(() => fail(new Error('bridge timed out')), BRIDGE_TIMEOUT_MS);
92
+ socket.write(JSON.stringify(message) + '\n');
93
+ });
94
+ });
95
+ }
96
+ export function fakeGateway(script) {
97
+ const calls = [];
98
+ const failures = new Map();
99
+ const handlers = new Map();
100
+ const agents = new Map();
101
+ const wizards = new Map();
102
+ const runs = new Map();
103
+ const configBox = { hash: 'hash-1', config: {} };
104
+ let hashSeq = 1;
105
+ const ensureAgent = (id, workspace) => {
106
+ let agent = agents.get(id);
107
+ if (!agent) {
108
+ agent = { id, workspace, providers: [] };
109
+ agents.set(id, agent);
110
+ }
111
+ else if (workspace)
112
+ agent.workspace = workspace;
113
+ return agent;
114
+ };
115
+ const signInAgent = (id, provider) => {
116
+ const agent = ensureAgent(id);
117
+ if (!agent.providers.includes(provider))
118
+ agent.providers.push(provider);
119
+ };
120
+ // Every transport the factory made; events fan out to all of them, drop() closes all of them.
121
+ const live = new Set();
122
+ let droppedWhy = '';
123
+ const emit = (event, payload) => {
124
+ for (const transport of live)
125
+ for (const fn of transport.events)
126
+ fn({ event, payload });
127
+ };
128
+ const failNext = (method, message) => {
129
+ const queue = failures.get(method);
130
+ if (queue)
131
+ queue.push(message);
132
+ else
133
+ failures.set(method, [message]);
134
+ };
135
+ const finish = (run, result) => {
136
+ if (run.result)
137
+ return;
138
+ run.result = result;
139
+ for (const waiter of run.waiters.splice(0))
140
+ waiter(result);
141
+ };
142
+ const emitRun = (run, stream, data) => emit('agent', { runId: run.runId, seq: run.seq++, stream, ts: Date.now(), data });
143
+ /** The `agent` handler: the run's scripted turn, its `[tool NAME {json}]` calls through the real bridge. */
144
+ const startRun = (params, bridgeSock) => {
145
+ if (params.agentId != null && !agents.has(String(params.agentId)))
146
+ throw new Error(`unknown agent: ${params.agentId}`);
147
+ const run = { runId: randomUUID(), sessionKey: String(params.sessionKey ?? ''), seq: 0, waiters: [] };
148
+ runs.set(run.runId, run);
149
+ const message = String(params.message ?? '');
150
+ const text = `fake: ${message}`;
151
+ setTimeout(() => {
152
+ void (async () => {
153
+ const table = bridgeSock ? pluginTable(bridgeSock) : undefined;
154
+ for (const call of toolCalls(message)) {
155
+ emitRun(run, 'tool', { name: call.name, phase: 'start' });
156
+ // As the plugin: an engine builtin is gated unless the app opted out, and an allowed one never calls back.
157
+ const builtin = !!table && !table.tools.some((t) => t.name === call.name);
158
+ if (bridgeSock && !(builtin && table?.gateBuiltins === false)) {
159
+ try {
160
+ const gate = await bridgeRequest(bridgeSock, { kind: 'gate', key: run.sessionKey, tool: call.name, input: call.input });
161
+ if (gate.allow && !builtin)
162
+ await bridgeRequest(bridgeSock, { kind: 'call', key: run.sessionKey, permit: gate.permit, tool: call.name, input: call.input });
163
+ }
164
+ catch { /* no bridge or refused: the tool pair still plays, nothing is called */ }
165
+ }
166
+ // Completion rides `phase: 'result'` like the real engine (O11), not `'end'`.
167
+ emitRun(run, 'tool', { name: call.name, phase: 'result' });
168
+ }
169
+ emitRun(run, 'assistant', { text });
170
+ setTimeout(() => finish(run, { status: 'ok', terminalReply: { text } }), ABORT_WINDOW_MS);
171
+ })();
172
+ }, 0);
173
+ return { runId: run.runId };
174
+ };
175
+ const defaults = {
176
+ health: () => ({ ok: true, plugins: { loaded: [] } }),
177
+ 'agents.list': () => ({
178
+ defaultId: 'main', mainKey: 'main', scope: 'global',
179
+ agents: [...agents.values()].map((agent) => ({ id: agent.id })),
180
+ }),
181
+ 'agents.create': (p) => {
182
+ ensureAgent(String(p.name), p.workspace ? String(p.workspace) : undefined);
183
+ return { id: String(p.name) };
184
+ },
185
+ 'models.authStatus': (p) => ({
186
+ providers: (agents.get(String(p?.agentId ?? 'main'))?.providers ?? []).map((provider) => ({ provider })),
187
+ }),
188
+ 'models.authLogout': (p) => {
189
+ const agent = agents.get(String(p?.agentId ?? 'main'));
190
+ if (agent)
191
+ agent.providers = agent.providers.filter((provider) => provider !== p.provider);
192
+ return {};
193
+ },
194
+ 'openclaw.setup.auth.start': (p) => {
195
+ const sessionId = String(p.sessionId ?? `fake-${randomUUID()}`);
196
+ wizards.set(sessionId, { agentId: String(p.agentId ?? 'main'), answered: false, pulls: 0 });
197
+ return { sessionId, done: false, status: 'running' };
198
+ },
199
+ 'wizard.next': (p) => {
200
+ const session = wizards.get(String(p.sessionId));
201
+ if (!session)
202
+ throw new Error(`unknown wizard session: ${p.sessionId}`);
203
+ if (p.answer) {
204
+ session.answered = true;
205
+ return { done: false, step: PROGRESS_STEP };
206
+ }
207
+ if (!session.answered)
208
+ return { done: false, step: DEVICE_STEP };
209
+ if (session.pulls++ === 0)
210
+ return { done: false, step: PROGRESS_STEP };
211
+ wizards.delete(String(p.sessionId));
212
+ signInAgent(session.agentId, 'openai');
213
+ return { done: true, status: 'done', modelActivation: { modelRef: 'openai/gpt-5.1' } };
214
+ },
215
+ 'wizard.cancel': (p) => {
216
+ wizards.delete(String(p.sessionId));
217
+ return { status: 'cancelled' };
218
+ },
219
+ agent: (p, bridgeSock) => startRun(p, bridgeSock),
220
+ 'agent.wait': (p) => new Promise((resolve, reject) => {
221
+ const run = runs.get(String(p.runId));
222
+ if (!run)
223
+ return reject(new Error(`unknown run: ${p.runId}`));
224
+ if (run.result)
225
+ resolve(run.result);
226
+ else
227
+ run.waiters.push(resolve);
228
+ }),
229
+ 'sessions.steer': () => ({}),
230
+ 'chat.abort': (p) => {
231
+ for (const run of runs.values()) {
232
+ if (run.result || run.sessionKey !== String(p.sessionKey ?? ''))
233
+ continue;
234
+ if (p.runId && run.runId !== String(p.runId))
235
+ continue;
236
+ // Engine-shaped abort (O11): the receipt only says rpc-error; the run's lifecycle end carries aborted.
237
+ emitRun(run, 'lifecycle', { phase: 'end', status: 'cancelled', aborted: true, stopReason: 'rpc' });
238
+ finish(run, { status: 'error', stopReason: 'rpc' });
239
+ }
240
+ return {};
241
+ },
242
+ 'config.get': () => ({ hash: configBox.hash, config: configBox.config }),
243
+ 'config.patch': (p) => {
244
+ if (typeof p.raw !== 'string')
245
+ throw new Error('config.patch requires raw');
246
+ if (p.baseHash !== undefined && p.baseHash !== configBox.hash)
247
+ throw new Error('stale config: baseHash does not match');
248
+ const next = JSON.parse(p.raw);
249
+ // Like the real engine (O11) the merged config is validated, so entries added onto an already
250
+ // explicit roster stay valid: a multi-agent roster without explicit ownership is invalid.
251
+ const merged = JSON.parse(JSON.stringify(configBox.config));
252
+ mergeInto(merged, next);
253
+ const agents = merged.agents;
254
+ const roster = Object.keys(agents?.entries ?? {});
255
+ if ((roster.length > 1 || (roster.length === 1 && roster[0] !== 'main')) && agents?.ownership !== 'explicit')
256
+ throw new Error('invalid config: agents.ownership: multi-agent rosters require agents.ownership="explicit"');
257
+ mergeInto(configBox.config, next);
258
+ enforceMemoryInvariants(configBox.config);
259
+ configBox.hash = `hash-${++hashSeq}`;
260
+ return { hash: configBox.hash };
261
+ },
262
+ // Real-shaped requested events: details nest under `request` (B1); question events stay flat upstream.
263
+ 'exec.approval.request': (p) => {
264
+ const id = String(p.id ?? `exec-${randomUUID()}`);
265
+ const now = Date.now();
266
+ emit('exec.approval.requested', {
267
+ approvalKind: 'exec', id, createdAtMs: now, expiresAtMs: now + 180_000,
268
+ request: {
269
+ command: p.command,
270
+ // Like the real engine, which normalizes the exec policy mode into request.ask (B8).
271
+ ask: p.ask ?? 'on-miss',
272
+ agentId: p.agentId ?? p.systemRunPlan?.agentId,
273
+ sessionKey: p.sessionKey ?? p.systemRunPlan?.sessionKey,
274
+ },
275
+ });
276
+ return { id };
277
+ },
278
+ 'plugin.approval.request': (p) => {
279
+ // Like the real engine: title and description are required, the engine mints the id.
280
+ if (typeof p.title !== 'string' || typeof p.description !== 'string')
281
+ throw new Error('plugin.approval.request requires title and description');
282
+ const id = `plugin-${randomUUID()}`;
283
+ const now = Date.now();
284
+ emit('plugin.approval.requested', {
285
+ approvalKind: 'plugin', id, createdAtMs: now, expiresAtMs: now + 180_000,
286
+ request: { title: p.title, description: p.description, agentId: p.agentId, sessionKey: p.sessionKey },
287
+ });
288
+ return { id };
289
+ },
290
+ 'exec.approval.resolve': (p) => {
291
+ emit('exec.approval.resolved', { id: p.id, decision: p.decision });
292
+ return {};
293
+ },
294
+ 'exec.approval.list': () => ({ approvals: [] }),
295
+ 'plugin.approval.list': () => ({ approvals: [] }),
296
+ 'plugin.approval.resolve': (p) => {
297
+ emit('plugin.approval.resolved', { id: p.id, decision: p.decision });
298
+ return {};
299
+ },
300
+ 'question.resolve': (p) => {
301
+ emit('question.resolved', { id: p.id, answers: p.answers });
302
+ return {};
303
+ },
304
+ };
305
+ for (const [method, handler] of Object.entries(defaults))
306
+ handlers.set(method, handler);
307
+ for (const [method, handler] of Object.entries(script ?? {}))
308
+ handlers.set(method, handler);
309
+ const dispatch = async (method, params, bridgeSock) => {
310
+ calls.push({ method, params });
311
+ const queue = failures.get(method);
312
+ if (queue?.length)
313
+ throw new Error(queue.shift());
314
+ const handler = handlers.get(method);
315
+ if (!handler)
316
+ throw new Error(`unknown method: ${method}`);
317
+ return await handler(params ?? {}, bridgeSock);
318
+ };
319
+ const hello = () => ({
320
+ protocol: PROTOCOL_VERSION,
321
+ server: { version: ENGINE_VERSION },
322
+ // The methods the fake serves. The generated tables are type-only until O2 ships runtime names; O11's
323
+ // generated.test.ts cross-checks the real engine's hello against the committed tables.
324
+ methods: [...handlers.keys()].sort(),
325
+ events: [...EVENTS],
326
+ });
327
+ const closedWhy = () => (droppedWhy ? `transport closed: ${droppedWhy}` : 'transport closed');
328
+ const factory = (ctx) => {
329
+ const sock = ctx.bridgeSock;
330
+ const entry = {
331
+ open: true,
332
+ events: new Set(),
333
+ closes: new Set(),
334
+ };
335
+ live.add(entry);
336
+ const transport = {
337
+ start: async () => {
338
+ if (!entry.open)
339
+ throw new Error(closedWhy());
340
+ return hello();
341
+ },
342
+ request: async (method, params) => {
343
+ if (!entry.open)
344
+ throw new Error(closedWhy());
345
+ return dispatch(method, params, sock);
346
+ },
347
+ onEvent: (fn) => (entry.events.add(fn), () => entry.events.delete(fn)),
348
+ onClose: (fn) => (entry.closes.add(fn), () => entry.closes.delete(fn)),
349
+ stop: async () => {
350
+ entry.open = false;
351
+ entry.events.clear();
352
+ entry.closes.clear();
353
+ live.delete(entry);
354
+ },
355
+ };
356
+ return transport;
357
+ };
358
+ const drop = (why) => {
359
+ droppedWhy = why;
360
+ for (const entry of [...live]) {
361
+ entry.open = false;
362
+ const closes = [...entry.closes];
363
+ entry.closes.clear();
364
+ for (const fn of closes)
365
+ fn(why);
366
+ live.delete(entry);
367
+ }
368
+ };
369
+ return {
370
+ factory,
371
+ calls,
372
+ emit,
373
+ failNext,
374
+ drop,
375
+ handle: (method, fn) => { handlers.set(method, fn); },
376
+ transport: factory({ port: 0, token: 'test', identityPath: '', bridgeSock: '' }),
377
+ };
378
+ }
@@ -0,0 +1,3 @@
1
+ export { fakeGateway, type FakeScript } from './fake-gateway.ts';
2
+ export { openclawContract, type ContractFixture } from './contract.ts';
3
+ export { startModelStub, useModelStub, releaseStub, stubHolding, toolCalls, type ModelStub, type ModelStubOptions, type StubCall } from './model-stub.ts';
@@ -0,0 +1,4 @@
1
+ // The kit's fakes, contract suite and scripted model (5.11). Built in O7.
2
+ export { fakeGateway } from "./fake-gateway.js";
3
+ export { openclawContract } from "./contract.js";
4
+ export { startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from "./model-stub.js";
@@ -0,0 +1,29 @@
1
+ import type { OpenClawKit } from '../kit.ts';
2
+ export type StubCall = {
3
+ authorization: string;
4
+ path: string;
5
+ body: any;
6
+ };
7
+ export type ModelStub = {
8
+ port: number;
9
+ url: string;
10
+ calls: StubCall[];
11
+ close(): Promise<void>;
12
+ };
13
+ /** The tool calls in the script, each with its arguments. Braces are balanced, so a script may nest. */
14
+ export declare function toolCalls(text: string): {
15
+ name: string;
16
+ input: Record<string, unknown>;
17
+ }[];
18
+ export declare const releaseStub: (key?: string, reply?: string) => void;
19
+ export declare const stubHolding: (key?: string) => boolean;
20
+ /**
21
+ * One loopback HTTP server that speaks the script. `script` queues plain replies consumed one per completion
22
+ * request before the message-embedded grammar applies.
23
+ */
24
+ export type ModelStubOptions = {
25
+ idPattern?: RegExp;
26
+ routingMarker?: string;
27
+ };
28
+ export declare function startModelStub(script?: string[], o?: ModelStubOptions): Promise<ModelStub>;
29
+ export declare function useModelStub(kit: OpenClawKit, stub: ModelStub): Promise<void>;
@@ -0,0 +1,182 @@
1
+ // The scripted OpenAI-compatible model the contract and engine tests run against (5.11): ported from Crewhouse's
2
+ // test/openclaw-stub.ts with its script grammar unchanged. One loopback HTTP server; sessions, tools, the gate and
3
+ // the run events stay the engine's own code path. No network, no account, no quota, no sign-ins anywhere.
4
+ // Script (read from the latest user message):
5
+ // [tool NAME {json}] call that tool once, then reply with what it returned; several are called in turn, the reply
6
+ // saying what the last one returned (the json may nest: braces are matched, not guessed)
7
+ // hit the limit answer with the account's own usage-limit error (rests it)
8
+ // no helpers in plan answer as a plan without helpers does
9
+ // sign me out answer as an account whose sign-in stopped working does
10
+ // ask permission hold the turn (after its tool calls) until `releaseStub` lets it finish
11
+ // [route ID] asked who should take a request: ID, fairly sure; [route ?]: torn evenly; neither: Chief, sure
12
+ // anything else reply `stub <bot>: done with "<the last line of the message>"`
13
+ // The bot id (`idPattern`, default /Your id is ([a-z0-9-]+)\./) is read from the system prompt, and routing
14
+ // requests start with `routingMarker` (default '[routing]'); pass both explicitly to mirror another app's grammar.
15
+ import { createServer } from 'node:http';
16
+ import { randomUUID } from 'node:crypto';
17
+ /** The tool calls in the script, each with its arguments. Braces are balanced, so a script may nest. */
18
+ export function toolCalls(text) {
19
+ const out = [];
20
+ for (const m of text.matchAll(/\[tool (\w+) \{/g)) {
21
+ const from = m.index + m[0].length - 1;
22
+ let depth = 0, i = from, quote = false, esc = false;
23
+ for (; i < text.length; i++) {
24
+ const c = text[i];
25
+ if (esc) {
26
+ esc = false;
27
+ continue;
28
+ }
29
+ if (c === '\\') {
30
+ esc = true;
31
+ continue;
32
+ }
33
+ if (c === '"') {
34
+ quote = !quote;
35
+ continue;
36
+ }
37
+ if (quote)
38
+ continue;
39
+ if (c === '{')
40
+ depth++;
41
+ else if (c === '}' && !--depth)
42
+ break;
43
+ }
44
+ out.push({ name: m[1], input: JSON.parse(text.slice(from, i + 1)) });
45
+ }
46
+ return out;
47
+ }
48
+ // A turn whose message says `ask permission` holds until released; the key is the request's Authorization header.
49
+ const holds = new Map();
50
+ export const releaseStub = (key, reply = '') => {
51
+ for (const [k, release] of holds)
52
+ if (!key || k === key) {
53
+ release(reply);
54
+ holds.delete(k);
55
+ }
56
+ };
57
+ export const stubHolding = (key) => (key ? holds.has(key) : holds.size > 0);
58
+ const words = (m) => typeof m?.content === 'string' ? m.content : Array.isArray(m?.content) ? m.content.map((c) => c.text ?? '').join('') : '';
59
+ export function startModelStub(script = [], o = {}) {
60
+ const calls = [];
61
+ const queue = [...script];
62
+ const idPattern = o.idPattern ?? /Your id is ([a-z0-9-]+)\./;
63
+ const routingMarker = o.routingMarker ?? '[routing]';
64
+ const server = createServer(async (req, res) => {
65
+ const chunks = [];
66
+ for await (const chunk of req)
67
+ chunks.push(chunk);
68
+ let body;
69
+ try {
70
+ body = JSON.parse(Buffer.concat(chunks).toString());
71
+ }
72
+ catch {
73
+ res.writeHead(400).end();
74
+ return;
75
+ }
76
+ calls.push({ authorization: String(req.headers.authorization ?? ''), path: req.url ?? '', body });
77
+ if (req.url === '/api/embed' || req.url === '/api/embeddings') {
78
+ res.writeHead(200, { 'content-type': 'application/json' });
79
+ return res.end(JSON.stringify({
80
+ embeddings: (Array.isArray(body.input) ? body.input : [body.input]).map(() => [0.1, 0.2, 0.3]),
81
+ }));
82
+ }
83
+ const messages = body.messages ?? [];
84
+ const system = messages.filter((m) => m.role === 'system').map((m) => words(m)).join('\n');
85
+ const bot = idPattern.exec(system)?.[1] ?? 'bot';
86
+ // The engine appends runtime-context user messages after the person's own (O11): the script lives in the
87
+ // last user message that carries a script marker, falling back to the last user message for plain replies.
88
+ // Tool results count from that same message, or the first scripted call replays forever: each follow-up
89
+ // request carries a fresh trailing runtime-context user message with zero tool results after it.
90
+ const users = messages.filter((m) => m.role === 'user');
91
+ const structural = (text) => /\[tool \w+ \{|\[route [\w?-]+\]/.test(text) || text.startsWith(routingMarker);
92
+ const phrased = (text) => /hit the limit|no helpers in plan|sign me out|ask permission/i.test(text);
93
+ const lastUser = [...users].reverse().find((m) => structural(words(m)))
94
+ ?? [...users].reverse().find((m) => phrased(words(m)))
95
+ ?? users.at(-1);
96
+ const said = words(lastUser);
97
+ const anchor = messages.lastIndexOf(lastUser);
98
+ const results = anchor < 0 ? [] : messages.slice(anchor + 1).filter((m) => m.role === 'tool');
99
+ const scripted = toolCalls(said);
100
+ const lastResult = results.at(-1);
101
+ const done = () => {
102
+ if (results.length && lastResult)
103
+ return `stub ${bot}: ${lastResult.name ?? 'tool'} said ${words(lastResult).slice(0, 300)}`;
104
+ const asked = said.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith(routingMarker) && !/^\[route /.test(l)).pop() ?? '';
105
+ return `stub ${bot}: done with "${asked.slice(0, 60)}"`;
106
+ };
107
+ // Routing asks who takes a request; the script answers in the kit's own shape.
108
+ if (said.startsWith(routingMarker)) {
109
+ const options = [...said.matchAll(/^- ([a-z0-9-]+):/gm)].map((m) => m[1]);
110
+ const pick = /\[route ([a-z0-9-]+|\?)\]/.exec(said)?.[1] ?? 'chief';
111
+ return plain(res, body, JSON.stringify(Object.fromEntries(options.map((o) => [o, pick === '?' ? 1 / options.length : o === pick ? 0.9 : 0.1 / (options.length - 1)]))));
112
+ }
113
+ if (/sign me out/i.test(said)) {
114
+ res.writeHead(401, { 'content-type': 'application/json' });
115
+ return res.end(JSON.stringify({ error: { message: '401 Unauthorized: your sign-in has expired' } }));
116
+ }
117
+ if (/hit the limit/i.test(said)) {
118
+ res.writeHead(429, { 'content-type': 'application/json' });
119
+ return res.end(JSON.stringify({ error: { message: 'You have hit your ChatGPT usage limit (plus plan). Try again in ~30 min.' } }));
120
+ }
121
+ if (/no helpers in plan/i.test(said)) {
122
+ res.writeHead(403, { 'content-type': 'application/json' });
123
+ return res.end(JSON.stringify({ error: { message: "Your plan doesn't include this model." } }));
124
+ }
125
+ const next = scripted[results.length];
126
+ const id = randomUUID();
127
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
128
+ const send = (delta, finish = null) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
129
+ if (next) {
130
+ send({ role: 'assistant', tool_calls: [{ index: 0, id: `call_${id}`, type: 'function', function: { name: next.name, arguments: JSON.stringify(next.input) } }] });
131
+ send({}, 'tool_calls');
132
+ return void res.end('data: [DONE]\n\n');
133
+ }
134
+ let text = queue.length ? queue.shift() : done();
135
+ if (/ask permission/i.test(said)) {
136
+ const key = String(req.headers.authorization ?? 'hold');
137
+ text = await new Promise((resolve) => {
138
+ holds.set(key, (reply) => resolve(reply || text));
139
+ res.once('close', () => holds.delete(key));
140
+ });
141
+ }
142
+ send({ role: 'assistant', content: text });
143
+ send({}, 'stop');
144
+ res.end('data: [DONE]\n\n');
145
+ });
146
+ return new Promise((resolve) => {
147
+ server.listen(0, '127.0.0.1', () => {
148
+ const address = server.address();
149
+ const port = address && typeof address !== 'string' ? address.port : 0;
150
+ resolve({
151
+ port,
152
+ url: `http://127.0.0.1:${port}/v1`,
153
+ calls,
154
+ close: () => new Promise((yes) => server.close(() => yes())),
155
+ });
156
+ });
157
+ });
158
+ }
159
+ const plain = (res, body, text) => {
160
+ const id = randomUUID();
161
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
162
+ const chunk = (delta, finish = null) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
163
+ chunk({ role: 'assistant', content: text });
164
+ chunk({}, 'stop');
165
+ res.end('data: [DONE]\n\n');
166
+ };
167
+ // provider id 'byokit-stub', model 'test': the stub becomes every agent's primary model.
168
+ export async function useModelStub(kit, stub) {
169
+ // The generated method table arrives with O2; until then kit.call's typed surface accepts no method names, so
170
+ // configure through this string-typed view of the same runtime path.
171
+ const call = kit.call.bind(kit);
172
+ const current = await call('config.get');
173
+ await call('config.patch', { baseHash: current.hash, raw: JSON.stringify({
174
+ models: { providers: { 'byokit-stub': {
175
+ baseUrl: stub.url, apiKey: 'byokit-stub', api: 'openai-completions',
176
+ models: [{ id: 'test', name: 'Test', reasoning: true, input: ['text'],
177
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, maxTokens: 2048,
178
+ compat: { supportsReasoningEffort: true, supportedReasoningEfforts: ['off', 'low', 'medium', 'high'] } }],
179
+ } } },
180
+ agents: { defaults: { model: { primary: 'byokit-stub/test' } } },
181
+ }) });
182
+ }
@@ -0,0 +1,22 @@
1
+ import type { GatewayTransport } from './types.ts';
2
+ /**
3
+ * Client caps for the operator connection. `approvals` makes this connection an approval client, so the
4
+ * engine delivers exec/plugin.approval.requested to it (B7); without it the broadcast set is empty.
5
+ */
6
+ export declare const GATEWAY_CAPS: readonly ["tool-events", "approvals"];
7
+ /**
8
+ * Read the device key pair from `device.json`. The kit writes `{ privateKey, publicKey }` (PEM strings);
9
+ * existing Crewhouse state carries `{ deviceId, publicKeyPem, privateKeyPem }` instead. Both shapes are
10
+ * accepted and the file is only ever read, never rewritten, so an existing device identity keeps its keys.
11
+ */
12
+ export declare function loadDeviceKeys(identityPath: string): {
13
+ privateKeyPem: string;
14
+ publicKeyPem: string;
15
+ deviceId?: string;
16
+ };
17
+ export declare function gatewayTransport(ctx: {
18
+ port: number;
19
+ token: string;
20
+ identityPath: string;
21
+ bridgeSock: string;
22
+ }): GatewayTransport;