@byokit/openclaw 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runs.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { authStatus } from "./auth-status.js";
5
+ import { outputSchema } from "./output.js";
6
+ import { words } from "./words.js";
5
7
  import { classify } from "./classify.js";
6
8
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
7
9
  const WAIT_MS = 3_600_000;
@@ -78,17 +80,20 @@ export function createRuns(ctx) {
78
80
  return undefined;
79
81
  }
80
82
  };
81
- const run = async (spec, on) => {
83
+ const run = async (spec, on, keyAgent, preparedOutput) => {
82
84
  // Member boundary first: a member never speaks in another member's session, refused before any request.
83
85
  if (!spec.sessionKey.startsWith(`agent:${spec.member}:`))
84
86
  throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
87
+ const output = preparedOutput ?? (spec.schema === undefined ? undefined : outputSchema(spec.schema));
88
+ const system = [spec.system, output?.prompt].filter(Boolean).join('\n\n');
85
89
  const picked = spec.model === undefined ? undefined : account(spec.model);
86
90
  for (const tool of spec.tools ?? [])
87
91
  if (!ctx.tools.has(tool))
88
92
  throw new Error(`refused: "${tool}" is not one of this kit's tools`);
89
- const { agentId } = await ctx.ensure(spec.member);
90
- const release = spec.register !== false ? ctx.bridge.register({ sessionKey: spec.sessionKey, member: spec.member }, spec.tools) : undefined;
91
- let last = '';
93
+ const agentId = keyAgent ?? (await ctx.ensure(spec.member)).agentId;
94
+ const sessionKey = keyAgent ? `agent:${keyAgent}:${spec.sessionKey.slice(`agent:${spec.member}:`.length)}` : spec.sessionKey;
95
+ const release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools) : undefined;
96
+ let last;
92
97
  let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
93
98
  let ended = false;
94
99
  // The engine flags an aborted run on its lifecycle end event; the wait receipt itself only says
@@ -148,11 +153,11 @@ export function createRuns(ctx) {
148
153
  };
149
154
  const final = ctx.request('agent', {
150
155
  agentId,
151
- sessionKey: spec.sessionKey,
156
+ sessionKey,
152
157
  message: spec.message,
153
158
  idempotencyKey: randomUUID(),
154
159
  ...(picked ?? {}),
155
- ...(spec.system ? { extraSystemPrompt: spec.system } : {}),
160
+ ...(system ? { extraSystemPrompt: system } : {}),
156
161
  ...(spec.images ? { attachments: spec.images.map((image) => ({ mimeType: image.mimeType, content: image.data })) } : {}),
157
162
  ...(spec.thinking ? { thinking: spec.thinking } : {}),
158
163
  }, { expectFinal: true, timeoutMs: WAIT_CLIENT_MS, onAccepted });
@@ -161,15 +166,28 @@ export function createRuns(ctx) {
161
166
  runId = started.runId;
162
167
  const result = await ctx.request('agent.wait', { runId, timeoutMs: WAIT_MS }, { timeoutMs: WAIT_CLIENT_MS });
163
168
  if (result.status === 'ok') {
164
- const text = typeof result.terminalReply?.text === 'string' ? result.terminalReply.text : last;
165
- on?.({ type: 'text', text }); // the final cumulative text
166
169
  const done = await Promise.race([final.catch(() => undefined), delay(FINAL_GRACE_MS, undefined, { ref: false })]);
167
- const meta = isRecord(done) && isRecord(done.result) && isRecord(done.result.meta) ? done.result.meta : {};
170
+ ended = true; // no later stream event may supersede the final callback
171
+ const frame = isRecord(done) && isRecord(done.result) ? done.result : {};
172
+ const payloadText = Array.isArray(frame.payloads)
173
+ ? frame.payloads.filter((p) => isRecord(p) && typeof p.text === 'string')
174
+ .map((p) => p.text) : [];
175
+ // The pin's terminal snapshot is capped display evidence, not the complete generated answer.
176
+ // Its silent/empty disposition still controls visibility, even after transient streamed text.
177
+ const terminal = result.terminalReply;
178
+ const text = terminal?.disposition === 'silent' || terminal?.disposition === 'empty' ? ''
179
+ : payloadText.length ? payloadText.join('\n\n')
180
+ : last ?? (typeof terminal?.text === 'string' ? terminal.text : '');
181
+ on?.({ type: 'text', text }); // the final cumulative text (unvalidated)
182
+ const parsed = output?.parse(text);
183
+ if (output && !parsed)
184
+ return { ok: false, kind: 'output', message: words('member.output') };
185
+ const meta = isRecord(frame.meta) ? frame.meta : {};
168
186
  const agentMeta = isRecord(meta.agentMeta) ? meta.agentMeta : {};
169
187
  const usage = usageOf(agentMeta);
170
188
  const planWindow = typeof agentMeta.provider === 'string'
171
189
  ? await planWindowOf(agentId, agentMeta.provider.toLowerCase()) : undefined;
172
- return { ok: true, text, ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
190
+ return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
173
191
  }
174
192
  if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
175
193
  return { ok: false, aborted: true };
@@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { ENGINE_VERSION, PROTOCOL_VERSION } from "../constants.js";
9
9
  import { toolCalls } from "./model-stub.js";
10
+ import { routes } from "../routes.js";
10
11
  // The device-code script from Crewhouse's openclaw-wizard.test.ts, ported: the step, then progress, then done.
11
12
  const DEVICE_STEP = {
12
13
  id: 'step-device', type: 'note', executor: 'client',
@@ -98,6 +99,7 @@ export function fakeGateway(script) {
98
99
  const failures = new Map();
99
100
  const handlers = new Map();
100
101
  const agents = new Map();
102
+ const keys = new Map();
101
103
  const wizards = new Map();
102
104
  const runs = new Map();
103
105
  const configBox = { hash: 'hash-1', config: {} };
@@ -220,8 +222,37 @@ export function fakeGateway(script) {
220
222
  const agent = agents.get(String(p?.agentId ?? 'main'));
221
223
  if (agent)
222
224
  agent.providers = agent.providers.filter((provider) => provider !== p.provider);
225
+ if (keys.get(String(p.agentId))?.provider === p.provider)
226
+ keys.delete(String(p.agentId));
223
227
  return {};
224
228
  },
229
+ 'byokit.keys': (p) => {
230
+ const id = `byokit-key-${p.member}`;
231
+ if (p.action === 'prepare') {
232
+ keys.delete(id);
233
+ ensureAgent(id).providers = [];
234
+ return { ok: true };
235
+ }
236
+ const key = keys.get(id);
237
+ if (!key)
238
+ return { ok: false };
239
+ if (p.action === 'seal') {
240
+ key.sealed = true;
241
+ return { ok: true };
242
+ }
243
+ return { ok: key.sealed, ...(key.sealed ? { model: key.model } : {}) };
244
+ },
245
+ 'openclaw.setup.activate': (p) => {
246
+ if (p.kind !== 'api-key')
247
+ throw new Error('only the API-key setup path is simulated');
248
+ const route = routes().find((r) => r.choice === p.authChoice && r.keyEntry);
249
+ if (!route)
250
+ return { ok: false, status: 'unavailable' };
251
+ const model = `${route.provider}/fake-key-model`;
252
+ keys.set(p.agentId, { provider: route.provider, key: p.apiKey, model, sealed: false });
253
+ signInAgent(p.agentId, route.provider);
254
+ return { ok: true, status: 'ok', modelRef: model };
255
+ },
225
256
  'openclaw.setup.auth.start': (p) => {
226
257
  const sessionId = String(p.sessionId ?? `fake-${randomUUID()}`);
227
258
  wizards.set(sessionId, { agentId: String(p.agentId ?? 'main'), answered: false, pulls: 0 });
@@ -338,7 +369,8 @@ export function fakeGateway(script) {
338
369
  for (const [method, handler] of Object.entries(script ?? {}))
339
370
  handlers.set(method, handler);
340
371
  const dispatch = async (method, params, bridgeSock) => {
341
- calls.push({ method, params });
372
+ calls.push({ method, params: method === 'openclaw.setup.activate' && isPlainObject(params)
373
+ ? { ...params, ...(params.apiKey === undefined ? {} : { apiKey: '[redacted]' }) } : params });
342
374
  const queue = failures.get(method);
343
375
  if (queue?.length)
344
376
  throw new Error(queue.shift());
package/dist/types.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { OutputSchema } from './output.ts';
2
+ export type { OutputSchema, SchemaOutput } from './output.ts';
1
3
  export type Member = string;
2
4
  export interface ToolSpec {
3
5
  name: string;
@@ -25,8 +27,9 @@ export interface ToolHost {
25
27
  }): Promise<GateResult>;
26
28
  call(run: RunRef, tool: string, input: Record<string, unknown>, signal: AbortSignal): Promise<string>;
27
29
  }
28
- export interface RunSpec extends RunRef {
30
+ export interface RunSpec<S extends OutputSchema | undefined = OutputSchema | undefined> extends RunRef {
29
31
  message: string;
32
+ schema?: S;
30
33
  system?: string;
31
34
  images?: {
32
35
  data: string;
@@ -34,6 +37,8 @@ export interface RunSpec extends RunRef {
34
37
  }[];
35
38
  thinking?: 'off' | 'low' | 'medium' | 'high';
36
39
  model?: string;
40
+ /** Explicit paid key choice. Uses a separate member-owned agent and separate session history. */
41
+ auth?: 'apiKey';
37
42
  tools?: string[];
38
43
  register?: boolean;
39
44
  }
@@ -69,9 +74,10 @@ export type PlanWindow = {
69
74
  resetAt?: number;
70
75
  }[];
71
76
  };
72
- export type RunEnd = {
77
+ export type RunEnd<T = unknown> = {
73
78
  ok: true;
74
79
  text: string;
80
+ data?: T;
75
81
  usage?: RunUsage;
76
82
  planWindow?: PlanWindow;
77
83
  } | {
@@ -79,7 +85,7 @@ export type RunEnd = {
79
85
  aborted: true;
80
86
  } | {
81
87
  ok: false;
82
- kind: 'signed-out' | 'resting' | 'plan' | 'network' | 'other';
88
+ kind: 'signed-out' | 'resting' | 'plan' | 'network' | 'other' | 'output';
83
89
  until?: number;
84
90
  message: string;
85
91
  };
@@ -139,6 +145,14 @@ export type Route = {
139
145
  offer: boolean;
140
146
  reason: string;
141
147
  source: string;
148
+ revision: string;
149
+ checked: string;
150
+ label: string;
151
+ keyEntry: boolean;
152
+ keyErrors: {
153
+ invalid: 'key.invalid';
154
+ not_included: 'key.notIncluded';
155
+ } | null;
142
156
  };
143
157
  export interface GatewayTransport {
144
158
  start(): Promise<Hello>;
package/dist/types.js CHANGED
@@ -1,2 +1 @@
1
- // Public types for the OpenClaw runtime kit (docs/runtime-kits.md 5.2). Types only; behavior lives in the modules.
2
1
  export {};
package/dist/words.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "member.signedOut": "Sign in with {name} to start.",
12
12
  "member.resting": "{name} needs a break until {time}.",
13
13
  "member.plan": "Your {name} plan doesn't include this.",
14
+ "member.output": "The answer did not match the requested format.",
14
15
  "member.network": "Can't reach {name} right now. This keeps trying by itself.",
15
16
  "signin.returned": "Thanks. Finishing the sign-in — you can go back to the app now.",
16
17
  "signin.busy": "Another sign-in is already in progress. Finish or cancel it, then try again.",
@@ -19,5 +20,12 @@
19
20
  "approval.ask": "{helper} wants to {summary}. Allow it?",
20
21
  "approval.expired": "Nobody answered in time, so this wasn't allowed.",
21
22
  "approval.notice": "Something is waiting for your yes.",
22
- "link.notAllowed": "This device can't do that. Ask the person at the computer."
23
+ "link.notAllowed": "This device can't do that. Ask the person at the computer.",
24
+ "key.label": "API key (billed per use)",
25
+ "key.entry": "Paste your API key. You pay the provider for each use.",
26
+ "key.checking": "Checking your key…",
27
+ "key.ok": "Your key is ready. Use it only when you choose this option.",
28
+ "key.invalid": "This key could not be saved. Check it and try again.",
29
+ "key.notIncluded": "This option is not available here.",
30
+ "key.missing": "Add an API key to use this option."
23
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -53,8 +53,8 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@byokit/link": "0.7.0",
56
- "@byokit/relay": "0.5.0",
57
- "@byokit/reach": "0.5.0",
56
+ "@byokit/relay": "0.5.1",
57
+ "@byokit/reach": "0.6.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "@byokit/secrets": "0.5.0",
60
60
  "@openclaw/gateway-client": "2026.8.1",
@@ -62,7 +62,7 @@
62
62
  "ws": "8.21.3"
63
63
  },
64
64
  "devDependencies": {
65
- "@byokit/ui-core": "0.5.0"
65
+ "@byokit/ui-core": "0.6.0"
66
66
  },
67
67
  "publishConfig": {
68
68
  "access": "public"
package/plugin/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // schemas and descriptions stay app data. No imports beyond node builtins: nothing here needs resolving in-gateway.
4
4
  import { connect } from 'node:net';
5
5
  import { readFileSync } from 'node:fs';
6
+ import { registerKeys } from './keys.js';
6
7
 
7
8
  /** TypeBox's Kind is a global symbol, so these schemas are valid TypeBox without importing typebox. */
8
9
  const Kind = Symbol.for('TypeBox.Kind');
@@ -114,6 +115,7 @@ export default {
114
115
  jsonSchema: { type: 'object', additionalProperties: false, properties: {} },
115
116
  },
116
117
  register(api) {
118
+ if (api.registerGatewayMethod) registerKeys(api);
117
119
  for (const spec of table.tools) {
118
120
  api.registerTool({
119
121
  name: spec.name,
package/plugin/keys.js ADDED
@@ -0,0 +1,101 @@
1
+ // Agent-local key isolation. The engine owns the SQLite transaction and publishes the changed runtime snapshot.
2
+ // No key value crosses this method. Only the typed setup.activate request ever carries a secret.
3
+ export function registerKeys(api) {
4
+ api.registerGatewayMethod('byokit.keys', async ({ params, respond }) => {
5
+ const member = params?.member;
6
+ if (typeof member !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(member) || member.startsWith('byokit-key-')
7
+ || !['prepare', 'seal', 'ready'].includes(params.action)) {
8
+ respond(true, { ok: false });
9
+ return;
10
+ }
11
+ const diagnostics = params.action === 'ready' && params.diagnostics === true ? { stage: 'imports' } : undefined;
12
+ const reply = (value) => respond(true, { ...value, ...(diagnostics ? { diagnostics } : {}) });
13
+ try {
14
+ const { readFile, writeFile, rm } = await import('node:fs/promises');
15
+ const { join } = await import('node:path');
16
+ const { resolveAgentDir } = await import('openclaw/plugin-sdk/agent-runtime');
17
+ const { updateAuthProfileStoreWithLock } = await import('openclaw/plugin-sdk/provider-auth');
18
+ // Read the current config: activation changes the roster and selection during the same gateway lifetime.
19
+ if (diagnostics) diagnostics.stage = 'config';
20
+ const config = JSON.parse(await readFile(process.env.OPENCLAW_CONFIG_PATH, 'utf8'));
21
+ const keyId = `byokit-key-${member}`;
22
+ if (!config.agents?.entries?.[member] || !config.agents?.entries?.[keyId]) {
23
+ reply({ ok: false });
24
+ return;
25
+ }
26
+ const keyDir = resolveAgentDir(config, keyId);
27
+ const marker = join(keyDir, 'byokit-key-ready.json');
28
+ if (params.action === 'ready') {
29
+ if (diagnostics) diagnostics.stage = 'marker';
30
+ const selected = JSON.parse(await readFile(marker, 'utf8'));
31
+ let ready = false;
32
+ if (diagnostics) diagnostics.stage = 'store';
33
+ await updateAuthProfileStoreWithLock({ agentDir: keyDir, updater(store) {
34
+ if (diagnostics) {
35
+ diagnostics.profiles = Object.entries(store.profiles).map(([id, p]) => ({ id, apiKey: p.type === 'api_key', local: p.copyToAgents === false }));
36
+ diagnostics.order = store.order;
37
+ diagnostics.selected = selected.profileId;
38
+ diagnostics.stage = 'checked';
39
+ }
40
+ const profile = store.profiles[selected.profileId];
41
+ ready = profile?.type === 'api_key' && profile.copyToAgents === false
42
+ && store.order?.[profile.provider]?.length === 1 && store.order[profile.provider][0] === selected.profileId;
43
+ return false;
44
+ } });
45
+ reply({ ok: ready, ...(ready ? { model: selected.model } : {}) });
46
+ return;
47
+ }
48
+ await rm(marker, { force: true });
49
+ if (params.action === 'prepare') {
50
+ // Only local profiles are loaded by this transaction: no shared-store read-through or CLI sync.
51
+ const normal = await updateAuthProfileStoreWithLock({
52
+ agentDir: resolveAgentDir(config, member),
53
+ saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
54
+ updater(store) {
55
+ if (Object.values(store.profiles).some((p) => p.type === 'api_key'))
56
+ throw new Error('The normal agent already has a key.');
57
+ const providers = new Set([params.provider, ...Object.values(store.profiles).map((p) => p.provider)].filter((p) => typeof p === 'string'));
58
+ store.order ??= {};
59
+ for (const provider of providers) store.order[provider] = Object.entries(store.profiles)
60
+ .filter(([, p]) => p.provider === provider && p.type === 'oauth').map(([id]) => id);
61
+ return true;
62
+ },
63
+ });
64
+ if (!normal) { respond(true, { ok: false }); return; }
65
+ // A replacement starts empty. During activation this agent is unavailable to kit.run.
66
+ const cleared = await updateAuthProfileStoreWithLock({ agentDir: keyDir,
67
+ saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
68
+ updater(store) { store.profiles = {}; store.order = {}; return true; } });
69
+ respond(true, { ok: Boolean(cleared) });
70
+ return;
71
+ }
72
+ const ref = config.agents.entries[keyId].model;
73
+ const primary = typeof ref === 'string' ? ref : ref?.primary;
74
+ const at = typeof primary === 'string' ? primary.lastIndexOf('@') : -1;
75
+ if (at < 1) { respond(true, { ok: false }); return; }
76
+ const profileId = primary.slice(at + 1);
77
+ const model = primary.slice(0, at);
78
+ let found = false;
79
+ const sealed = await updateAuthProfileStoreWithLock({ agentDir: keyDir,
80
+ saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
81
+ updater(store) {
82
+ const profile = store.profiles[profileId];
83
+ if (profile?.type !== 'api_key') return false;
84
+ found = true;
85
+ profile.copyToAgents = false;
86
+ store.profiles = { [profileId]: profile };
87
+ // A stored agent-local order wins over global config and contains exactly this key, never a sign-in.
88
+ store.order = { [profile.provider]: [profileId] };
89
+ store.lastGood = {};
90
+ return true;
91
+ },
92
+ });
93
+ if (!sealed || !found) { respond(true, { ok: false }); return; }
94
+ await writeFile(marker, JSON.stringify({ profileId, model }), { mode: 0o600 });
95
+ respond(true, { ok: true });
96
+ } catch {
97
+ // Neither engine errors nor store contents (which contain secrets) ever leave this method.
98
+ reply({ ok: false });
99
+ }
100
+ }, { scope: 'operator.admin' });
101
+ }