@byokit/openclaw 0.11.0 → 0.12.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 (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +45 -7
  3. package/SECURITY.md +3 -2
  4. package/dist/accounts.d.ts +45 -1
  5. package/dist/accounts.js +641 -46
  6. package/dist/activation.d.ts +17 -0
  7. package/dist/activation.js +9 -0
  8. package/dist/approvals.d.ts +2 -0
  9. package/dist/approvals.js +9 -9
  10. package/dist/attribution.d.ts +3 -0
  11. package/dist/attribution.js +25 -0
  12. package/dist/bridge.d.ts +3 -1
  13. package/dist/bridge.js +4 -6
  14. package/dist/config.js +10 -0
  15. package/dist/constants.d.ts +1 -0
  16. package/dist/constants.js +2 -0
  17. package/dist/device.d.ts +5 -2
  18. package/dist/device.js +11 -1
  19. package/dist/engine.js +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/keys.d.ts +19 -5
  22. package/dist/keys.js +135 -23
  23. package/dist/kit.d.ts +38 -1
  24. package/dist/kit.js +153 -28
  25. package/dist/link.js +21 -11
  26. package/dist/members.d.ts +3 -0
  27. package/dist/members.js +13 -9
  28. package/dist/pick.d.ts +7 -3
  29. package/dist/pick.js +34 -5
  30. package/dist/routes.d.ts +1 -0
  31. package/dist/routes.js +5 -3
  32. package/dist/routes.json +31 -31
  33. package/dist/runs.d.ts +7 -0
  34. package/dist/runs.js +111 -11
  35. package/dist/signin.d.ts +3 -0
  36. package/dist/signin.js +51 -17
  37. package/dist/testing/fake-gateway.d.ts +18 -0
  38. package/dist/testing/fake-gateway.js +210 -16
  39. package/dist/testing/model-stub.d.ts +1 -0
  40. package/dist/testing/model-stub.js +2 -1
  41. package/dist/types.d.ts +19 -0
  42. package/dist/words.json +8 -1
  43. package/package.json +2 -2
  44. package/plugin/accounts.js +12 -3
  45. package/plugin/catalog.js +48 -0
  46. package/plugin/index.js +2 -1
  47. package/plugin/keys.js +22 -4
package/dist/routes.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "prerequisite": null,
9
9
  "offer": false,
10
10
  "offerPolicy": "explicit",
11
- "reason": "Alibaba Model Studio: API key (billed per use).",
11
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
12
12
  "source": "extensions/alibaba/openclaw.plugin.json (2026.8.1)",
13
13
  "revision": "2026.8.1",
14
14
  "checked": "2026-10-02",
@@ -33,7 +33,7 @@
33
33
  "id": "alibaba",
34
34
  "method": "api-key",
35
35
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
36
- "flow": "present"
36
+ "flow": "absent"
37
37
  }
38
38
  },
39
39
  {
@@ -176,7 +176,7 @@
176
176
  "prerequisite": null,
177
177
  "offer": false,
178
178
  "offerPolicy": "explicit",
179
- "reason": "The pinned Gateway has no app-guided sign-in for this choice.",
179
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
180
180
  "source": "extensions/anthropic/openclaw.plugin.json (2026.8.1)",
181
181
  "revision": "2026.8.1",
182
182
  "checked": "2026-10-02",
@@ -610,7 +610,7 @@
610
610
  "prerequisite": null,
611
611
  "offer": false,
612
612
  "offerPolicy": "explicit",
613
- "reason": "ClawRouter: API key (billed per use).",
613
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
614
614
  "source": "extensions/clawrouter/openclaw.plugin.json (2026.8.1)",
615
615
  "revision": "2026.8.1",
616
616
  "checked": "2026-10-02",
@@ -635,7 +635,7 @@
635
635
  "id": "clawrouter",
636
636
  "method": "api-key",
637
637
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
638
- "flow": "present"
638
+ "flow": "absent"
639
639
  }
640
640
  },
641
641
  {
@@ -647,7 +647,7 @@
647
647
  "prerequisite": null,
648
648
  "offer": false,
649
649
  "offerPolicy": "explicit",
650
- "reason": "Cloudflare AI Gateway: API key (billed per use).",
650
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
651
651
  "source": "extensions/cloudflare-ai-gateway/openclaw.plugin.json (2026.8.1)",
652
652
  "revision": "2026.8.1",
653
653
  "checked": "2026-10-02",
@@ -681,7 +681,7 @@
681
681
  "id": "cloudflare-ai-gateway",
682
682
  "method": "api-key",
683
683
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
684
- "flow": "present"
684
+ "flow": "absent"
685
685
  }
686
686
  },
687
687
  {
@@ -739,7 +739,7 @@
739
739
  "prerequisite": null,
740
740
  "offer": false,
741
741
  "offerPolicy": "explicit",
742
- "reason": "ComfyUI: API key (billed per use).",
742
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
743
743
  "source": "extensions/comfy/openclaw.plugin.json (2026.8.1)",
744
744
  "revision": "2026.8.1",
745
745
  "checked": "2026-10-02",
@@ -773,7 +773,7 @@
773
773
  "id": "comfy",
774
774
  "method": "cloud-api-key",
775
775
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
776
- "flow": "present"
776
+ "flow": "absent"
777
777
  }
778
778
  },
779
779
  {
@@ -785,7 +785,7 @@
785
785
  "prerequisite": null,
786
786
  "offer": false,
787
787
  "offerPolicy": "explicit",
788
- "reason": "Billing must be supplied by the host; a server address does not determine billing.",
788
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
789
789
  "source": "extensions/copilot-proxy/openclaw.plugin.json (2026.8.1)",
790
790
  "revision": "2026.8.1",
791
791
  "checked": "2026-10-02",
@@ -807,7 +807,7 @@
807
807
  "id": "copilot-proxy",
808
808
  "method": "local",
809
809
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
810
- "flow": "present"
810
+ "flow": "absent"
811
811
  }
812
812
  },
813
813
  {
@@ -911,7 +911,7 @@
911
911
  "prerequisite": null,
912
912
  "offer": false,
913
913
  "offerPolicy": "explicit",
914
- "reason": "fal: API key (billed per use).",
914
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
915
915
  "source": "extensions/fal/openclaw.plugin.json (2026.8.1)",
916
916
  "revision": "2026.8.1",
917
917
  "checked": "2026-10-02",
@@ -936,7 +936,7 @@
936
936
  "id": "fal",
937
937
  "method": "api-key",
938
938
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
939
- "flow": "present"
939
+ "flow": "absent"
940
940
  }
941
941
  },
942
942
  {
@@ -1585,7 +1585,7 @@
1585
1585
  "prerequisite": null,
1586
1586
  "offer": false,
1587
1587
  "offerPolicy": "explicit",
1588
- "reason": "LongCat: API key (billed per use).",
1588
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
1589
1589
  "source": "extensions/longcat/openclaw.plugin.json (2026.8.1)",
1590
1590
  "revision": "2026.8.1",
1591
1591
  "checked": "2026-10-02",
@@ -1619,7 +1619,7 @@
1619
1619
  "id": "longcat",
1620
1620
  "method": "api-key",
1621
1621
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
1622
- "flow": "present"
1622
+ "flow": "absent"
1623
1623
  }
1624
1624
  },
1625
1625
  {
@@ -1677,7 +1677,7 @@
1677
1677
  "prerequisite": null,
1678
1678
  "offer": false,
1679
1679
  "offerPolicy": "explicit",
1680
- "reason": "Microsoft Foundry: Cloud credentials (billed per use).",
1680
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
1681
1681
  "source": "extensions/microsoft-foundry/openclaw.plugin.json (2026.8.1)",
1682
1682
  "revision": "2026.8.1",
1683
1683
  "checked": "2026-10-02",
@@ -1699,7 +1699,7 @@
1699
1699
  "id": "microsoft-foundry",
1700
1700
  "method": "entra-id",
1701
1701
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
1702
- "flow": "present"
1702
+ "flow": "absent"
1703
1703
  }
1704
1704
  },
1705
1705
  {
@@ -1711,7 +1711,7 @@
1711
1711
  "prerequisite": null,
1712
1712
  "offer": false,
1713
1713
  "offerPolicy": "explicit",
1714
- "reason": "Microsoft Foundry: API key (billed per use).",
1714
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
1715
1715
  "source": "extensions/microsoft-foundry/openclaw.plugin.json (2026.8.1)",
1716
1716
  "revision": "2026.8.1",
1717
1717
  "checked": "2026-10-02",
@@ -1736,7 +1736,7 @@
1736
1736
  "id": "microsoft-foundry",
1737
1737
  "method": "api-key",
1738
1738
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
1739
- "flow": "present"
1739
+ "flow": "absent"
1740
1740
  }
1741
1741
  },
1742
1742
  {
@@ -2458,7 +2458,7 @@
2458
2458
  "prerequisite": null,
2459
2459
  "offer": false,
2460
2460
  "offerPolicy": "explicit",
2461
- "reason": "PixVerse: API key (billed per use).",
2461
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
2462
2462
  "source": "extensions/pixverse/openclaw.plugin.json (2026.8.1)",
2463
2463
  "revision": "2026.8.1",
2464
2464
  "checked": "2026-10-02",
@@ -2492,7 +2492,7 @@
2492
2492
  "id": "pixverse",
2493
2493
  "method": "api-key",
2494
2494
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
2495
- "flow": "present"
2495
+ "flow": "absent"
2496
2496
  }
2497
2497
  },
2498
2498
  {
@@ -2838,7 +2838,7 @@
2838
2838
  "prerequisite": null,
2839
2839
  "offer": false,
2840
2840
  "offerPolicy": "explicit",
2841
- "reason": "Runway: API key (billed per use).",
2841
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
2842
2842
  "source": "extensions/runway/openclaw.plugin.json (2026.8.1)",
2843
2843
  "revision": "2026.8.1",
2844
2844
  "checked": "2026-10-02",
@@ -2863,7 +2863,7 @@
2863
2863
  "id": "runway",
2864
2864
  "method": "api-key",
2865
2865
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
2866
- "flow": "present"
2866
+ "flow": "absent"
2867
2867
  }
2868
2868
  },
2869
2869
  {
@@ -2875,7 +2875,7 @@
2875
2875
  "prerequisite": null,
2876
2876
  "offer": false,
2877
2877
  "offerPolicy": "explicit",
2878
- "reason": "Local runtime; explicitly select this route.",
2878
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
2879
2879
  "source": "extensions/sglang/openclaw.plugin.json (2026.8.1)",
2880
2880
  "revision": "2026.8.1",
2881
2881
  "checked": "2026-10-02",
@@ -2897,7 +2897,7 @@
2897
2897
  "id": "sglang",
2898
2898
  "method": "custom",
2899
2899
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
2900
- "flow": "present"
2900
+ "flow": "absent"
2901
2901
  }
2902
2902
  },
2903
2903
  {
@@ -3360,7 +3360,7 @@
3360
3360
  "prerequisite": null,
3361
3361
  "offer": false,
3362
3362
  "offerPolicy": "explicit",
3363
- "reason": "Local runtime; explicitly select this route.",
3363
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
3364
3364
  "source": "extensions/vllm/openclaw.plugin.json (2026.8.1)",
3365
3365
  "revision": "2026.8.1",
3366
3366
  "checked": "2026-10-02",
@@ -3382,7 +3382,7 @@
3382
3382
  "id": "vllm",
3383
3383
  "method": "custom",
3384
3384
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
3385
- "flow": "present"
3385
+ "flow": "absent"
3386
3386
  }
3387
3387
  },
3388
3388
  {
@@ -3440,7 +3440,7 @@
3440
3440
  "prerequisite": null,
3441
3441
  "offer": false,
3442
3442
  "offerPolicy": "explicit",
3443
- "reason": "Vydra: API key (billed per use).",
3443
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
3444
3444
  "source": "extensions/vydra/openclaw.plugin.json (2026.8.1)",
3445
3445
  "revision": "2026.8.1",
3446
3446
  "checked": "2026-10-02",
@@ -3474,7 +3474,7 @@
3474
3474
  "id": "vydra",
3475
3475
  "method": "api-key",
3476
3476
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
3477
- "flow": "present"
3477
+ "flow": "absent"
3478
3478
  }
3479
3479
  },
3480
3480
  {
@@ -4005,7 +4005,7 @@
4005
4005
  "prerequisite": null,
4006
4006
  "offer": false,
4007
4007
  "offerPolicy": "explicit",
4008
- "reason": "Billing must be supplied by the host; a server address does not determine billing.",
4008
+ "reason": "The pinned Gateway has no app-guided setup for this choice.",
4009
4009
  "source": "src/commands/auth-choice-options.static.ts (2026.8.1)",
4010
4010
  "revision": "2026.8.1",
4011
4011
  "checked": "2026-10-02",
@@ -4030,7 +4030,7 @@
4030
4030
  "id": "custom",
4031
4031
  "method": "custom",
4032
4032
  "revision": "ea806575e6450e4d1efdfc72c19f04be982a1b9b",
4033
- "flow": "present"
4033
+ "flow": "absent"
4034
4034
  }
4035
4035
  }
4036
4036
  ]
package/dist/runs.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Bridge } from './bridge.ts';
2
+ import type { createAccounts } from './accounts.ts';
3
+ import type { createLocks } from './locks.ts';
2
4
  import { authStatus } from './auth-status.ts';
3
5
  import { outputSchema, type OutputSchema, type SchemaOutput } from './output.ts';
4
6
  import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
@@ -18,6 +20,11 @@ export declare function createRuns(ctx: {
18
20
  }>;
19
21
  bridge: Pick<Bridge, 'register'>;
20
22
  tools: ReadonlySet<string>;
23
+ accounts?: Pick<ReturnType<typeof createAccounts>, 'bind' | 'keyRefusal' | 'pick' | 'rest'>;
24
+ locks?: ReturnType<typeof createLocks>;
25
+ routes: Map<string, {
26
+ session: string;
27
+ }>;
21
28
  }): {
22
29
  run<const S extends OutputSchema | undefined = undefined>(spec: RunSpec<S>, on?: (e: RunEvent) => void, keyAgent?: string, preparedOutput?: ReturnType<typeof outputSchema>): Promise<RunEnd<SchemaOutput<S>>>;
23
30
  steer(k: string, t: string): Promise<void>;
package/dist/runs.js CHANGED
@@ -5,12 +5,15 @@ import { authStatus } from "./auth-status.js";
5
5
  import { outputSchema } from "./output.js";
6
6
  import { words } from "./words.js";
7
7
  import { classify } from "./classify.js";
8
+ import { providerOf } from "./pick.js";
8
9
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
9
10
  const WAIT_MS = 3_600_000;
10
11
  const WAIT_CLIENT_MS = 3_610_000;
11
12
  // The `agent` request's final frame lands with the run's end; a run already over waits no longer than this for it.
12
13
  const FINAL_GRACE_MS = 5_000;
13
14
  const STATUS_MS = 5_000;
15
+ // A resting end whose message names no time rests the account this long (5.15 Runs step 7).
16
+ const REST_MS = 300_000;
14
17
  export const USABLE = new Set(['ok', 'expiring', 'static']);
15
18
  const usable = (row) => typeof row === 'string'
16
19
  || (Array.isArray(row.profiles) && row.profiles.length > 0 ? row.profiles.some((p) => USABLE.has(String(p?.status)))
@@ -62,10 +65,10 @@ function planOf(provider, row) {
62
65
  * a session preference it may rotate away from (docs/runtime-kits.md 5.8), so the kit never sends one.
63
66
  */
64
67
  function account(model) {
65
- const at = model.indexOf('/');
66
- if (at <= 0 || at === model.length - 1 || /[\s@]/.test(model))
68
+ const provider = providerOf(model);
69
+ if (provider === undefined)
67
70
  throw new Error(`refused: "${model}" is not a provider/model reference`);
68
- return { provider: model.slice(0, at).toLowerCase(), model: model.slice(at + 1) }; // the engine lowercases ids
71
+ return { provider, model: model.slice(model.indexOf('/') + 1) }; // the engine lowercases ids
69
72
  }
70
73
  export function createRuns(ctx) {
71
74
  // The plan window the engine last read for the run's provider: `models.authStatus` answers from its cache without
@@ -87,8 +90,9 @@ export function createRuns(ctx) {
87
90
  return { ok: false, ...classified, message };
88
91
  };
89
92
  const run = async (spec, on, keyAgent, preparedOutput) => {
90
- // Member boundary first: a member never speaks in another member's session, refused before any request.
91
- if (!spec.sessionKey.startsWith(`agent:${spec.member}:`))
93
+ // Member boundary first: a member never speaks in another member's session, refused before any request. A key
94
+ // of one of the member's account agents is the member's too; binding below checks the index names it.
95
+ if (!spec.sessionKey.startsWith(`agent:${spec.member}:`) && !(ctx.accounts && !keyAgent && spec.sessionKey.startsWith(`agent:${spec.member}--`)))
92
96
  throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
93
97
  // Same validation as the pin's NonEmptyString; preserve the caller's bytes (including whitespace).
94
98
  if (spec.idempotencyKey !== undefined && (typeof spec.idempotencyKey !== 'string' || !spec.idempotencyKey.length))
@@ -97,11 +101,104 @@ export function createRuns(ctx) {
97
101
  const output = preparedOutput ?? (spec.schema === undefined ? undefined : outputSchema(spec.schema));
98
102
  const system = [spec.system, output?.prompt].filter(Boolean).join('\n\n');
99
103
  const picked = spec.model === undefined ? undefined : account(spec.model);
104
+ for (const need of spec.needs ?? [])
105
+ account(need);
100
106
  for (const tool of spec.tools ?? [])
101
107
  if (!ctx.tools.has(tool))
102
108
  throw new Error(`refused: "${tool}" is not one of this kit's tools`);
103
- const agentId = keyAgent ?? (await ctx.ensure(spec.member)).agentId;
104
- const sessionKey = keyAgent ? `agent:${keyAgent}:${spec.sessionKey.slice(`agent:${spec.member}:`.length)}` : spec.sessionKey;
109
+ const tail = spec.sessionKey.slice(spec.sessionKey.indexOf(':', 'agent:'.length) + 1);
110
+ // Runs step 1 (5.15): every run first finds the account its session is bound to, the API-key lane included.
111
+ const binding = ctx.accounts ? await ctx.accounts.bind(spec.member, spec.sessionKey) : undefined;
112
+ // Step 2 on the API-key lane: only an API-billed conversation continues on its key; any other binding is refused.
113
+ if (keyAgent && ctx.accounts && binding) {
114
+ const message = await ctx.accounts.keyRefusal(spec.member, binding);
115
+ if (message !== undefined)
116
+ return { ok: false, kind: 'other', message };
117
+ }
118
+ // A run that waited for its agent's lock across a move (5.15) binds again under it: a changed binding means its
119
+ // session went to another account, so it ends before any event and the caller's retry lands there.
120
+ const moved = async () => !!binding && (await ctx.accounts.bind(spec.member, spec.sessionKey))?.agentId !== binding.agentId;
121
+ const movedEnd = { ok: false, kind: 'other', message: words('run.moved') };
122
+ // Step 3: with `account` and `needs` both absent, on a key no account agent holds (or an API-key run, whose
123
+ // binding is API-billed), exactly the run on the agent it always was (a bound key under its shared lock).
124
+ if ((keyAgent || binding?.id === undefined) && spec.account === undefined && !spec.needs?.length) {
125
+ if (keyAgent)
126
+ return start(spec, on, { agentId: keyAgent, sessionKey: `agent:${keyAgent}:${tail}`, idempotencyKey, system, picked, output });
127
+ const { agentId } = await ctx.ensure(spec.member);
128
+ const member = () => start(spec, on, { agentId, sessionKey: spec.sessionKey, idempotencyKey, system, picked, output });
129
+ if (!binding || !ctx.locks)
130
+ return member();
131
+ return ctx.locks.shared(agentId, async () => await moved() ? movedEnd : member());
132
+ }
133
+ if (keyAgent)
134
+ throw new Error('refused: an API-key run uses its own account');
135
+ if (!ctx.accounts || !ctx.locks)
136
+ throw new Error('gateway not ready');
137
+ const accounts = ctx.accounts;
138
+ const locks = ctx.locks;
139
+ const restAccount = async (id, until) => {
140
+ const at = until ?? Date.now() + REST_MS;
141
+ await accounts.rest(spec.member, id, at);
142
+ return at;
143
+ };
144
+ const signedIn = async (agentId, provider) => !!(await ctx.request('byokit.accounts', { action: 'ready', agentId, provider })).ok;
145
+ const skipped = [];
146
+ for (;;) {
147
+ // Steps 2 and 4: the selection is resolved once per attempt, before any request, narrowed by the binding; `needs`
148
+ // alone resolves as 'default'. A refusal (`bound`, `paid`) ends the run with its words before any room, model or agent request.
149
+ const { pick, agent } = await accounts.pick(spec.member, { account: spec.account ?? 'default',
150
+ ...(spec.model !== undefined ? { model: spec.model } : {}), ...(spec.needs ? { needs: spec.needs } : {}) }, skipped, binding);
151
+ if (!pick.ok) {
152
+ if (pick.code === 'unknown_account')
153
+ throw new Error(`unknown account: ${spec.account}`); // a caller bug
154
+ if (pick.code === 'resting')
155
+ return { ok: false, kind: 'resting', until: await restAccount(pick.account, pick.until), message: pick.reason };
156
+ return { ok: false, kind: pick.code === 'none' ? 'signed-out' : pick.code === 'not_included' ? 'plan' : 'other', message: pick.reason };
157
+ }
158
+ const sessionKey = `agent:${agent}:${tail}`;
159
+ const automatic = pick.how === 'auto' || pick.why === 'first_ready';
160
+ // The run goes to the picked account's agent, under the key's tail. The run holds the agent's shared
161
+ // lock from its ready check to its end. An automatic pick not signed in is skipped before any event; an explicit
162
+ // one announces itself, then ends signed-out.
163
+ const end = await locks.shared(agent, async () => {
164
+ if (await moved())
165
+ return movedEnd;
166
+ let ready;
167
+ try {
168
+ ready = await signedIn(agent, pick.account.provider);
169
+ }
170
+ catch (error) {
171
+ return failure(error instanceof Error ? error.message : String(error));
172
+ }
173
+ if (!ready && automatic)
174
+ return undefined;
175
+ const route = { session: sessionKey };
176
+ ctx.routes.set(spec.sessionKey, route);
177
+ try {
178
+ on?.({ type: 'account', account: pick.account.id, model: pick.model, sessionKey, how: pick.how, why: pick.why, considered: pick.considered });
179
+ if (!ready)
180
+ return { ok: false, kind: 'signed-out', message: `${pick.account.name} is not signed in` };
181
+ return await start(spec, on, { agentId: agent, sessionKey, idempotencyKey, system,
182
+ picked: pick.model ? account(pick.model) : undefined, output, chosen: pick }); // '' = no override
183
+ }
184
+ finally {
185
+ if (ctx.routes.get(spec.sessionKey) === route)
186
+ ctx.routes.delete(spec.sessionKey);
187
+ }
188
+ });
189
+ if (end === undefined) {
190
+ skipped.push(pick.account.id);
191
+ continue;
192
+ }
193
+ // Step 7: a resting end rests the account, so the next Auto skips it until then.
194
+ if (end.ok || !('kind' in end) || end.kind !== 'resting')
195
+ return end;
196
+ const until = await restAccount(pick.account.id, end.until);
197
+ return { ...end, until };
198
+ }
199
+ };
200
+ const start = async (spec, on, t) => {
201
+ const { agentId, sessionKey, idempotencyKey, system, picked, output, chosen } = t;
105
202
  const release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools, idempotencyKey) : undefined;
106
203
  let last;
107
204
  let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
@@ -140,7 +237,7 @@ export function createRuns(ctx) {
140
237
  }
141
238
  });
142
239
  try {
143
- if (picked) {
240
+ if (picked && !chosen) {
144
241
  // The picked account must be the one called: check it is signed in for this member before the run, since
145
242
  // the engine would otherwise fail the run only after admitting it. An explicit provider/model is strict on
146
243
  // the pinned engine (never another provider or model), so this check plus the override is the guarantee.
@@ -207,7 +304,10 @@ export function createRuns(ctx) {
207
304
  const usage = usageOf(agentMeta);
208
305
  const planWindow = typeof agentMeta.provider === 'string'
209
306
  ? await planWindowOf(agentId, agentMeta.provider.toLowerCase()) : undefined;
210
- return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
307
+ const reported = typeof agentMeta.provider === 'string' && typeof agentMeta.model === 'string'
308
+ ? `${agentMeta.provider}/${agentMeta.model}`.toLowerCase() : '';
309
+ return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}),
310
+ ...(chosen ? { account: chosen.account.id, model: chosen.model || reported } : {}) };
211
311
  }
212
312
  if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
213
313
  return { ok: false, aborted: true };
@@ -227,7 +327,7 @@ export function createRuns(ctx) {
227
327
  };
228
328
  return {
229
329
  run,
230
- steer: (k, t) => ctx.request('sessions.steer', { sessionKey: k, message: t }),
231
- abort: async (k) => { await ctx.request('chat.abort', { sessionKey: k }); },
330
+ steer: (k, t) => ctx.request('sessions.steer', { sessionKey: ctx.routes.get(k)?.session ?? k, message: t }),
331
+ abort: async (k) => { await ctx.request('chat.abort', { sessionKey: ctx.routes.get(k)?.session ?? k }); },
232
332
  };
233
333
  }
package/dist/signin.d.ts CHANGED
@@ -8,6 +8,9 @@ export type SignInCtx = {
8
8
  }>;
9
9
  callbackPort: number;
10
10
  onDisconnect?(fn: () => void): () => void;
11
+ authConfig(): string;
12
+ restarted(authBefore: string | undefined, signal?: AbortSignal): Promise<boolean>;
13
+ fresh?: boolean;
11
14
  };
12
15
  /**
13
16
  * Drive the engine's provider-owned login. `on` sees every view the person watches; `done` resolves once, and
package/dist/signin.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // sign-in; OAuth credentials stay in the engine/CLI. Explicit API keys go only to engine activation (D11).
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createServer } from 'node:http';
5
+ import { activate, LATE_RESTART } from "./activation.js";
5
6
  import { authStatus } from "./auth-status.js";
7
+ import { routes } from "./routes.js";
6
8
  import { signedInProviders } from "./runs.js";
7
9
  import { words } from "./words.js";
8
10
  // The pin's wizard contract (2026.8.1): steps arrive only from `wizard.next` (wizard.status answers {status, error}
@@ -15,6 +17,8 @@ const CANCEL_MS = 10_000;
15
17
  const RECONFIRM_MS = 60_000;
16
18
  const TURNS = 200;
17
19
  const PASTE_MS = 15 * 60_000;
20
+ const KEY_UNAVAILABLE = 'This key type is not available on this engine.';
21
+ const KEY_FAILED = 'API key activation failed';
18
22
  // Two of the pin's code routes print their code only into a note's text ("Code: X" with
19
23
  // "Code expires in N minutes", or "enter the code X." with "Expires at: <ISO>"), never as `deviceCode`.
20
24
  // Read it back so every code route reaches the caller in the one structured shape.
@@ -41,6 +45,16 @@ export function signIn(ctx, member, o, on) {
41
45
  const { signal } = owner;
42
46
  const driveOwner = new AbortController();
43
47
  const wantsCallback = via === 'browser' && o.authChoice === 'openai';
48
+ // The pin builds ChatGPT's authorize URL unsigned, so a fresh sign-in can ask for the login screen itself.
49
+ const shown = (url) => {
50
+ if (!ctx.fresh || !wantsCallback)
51
+ return url;
52
+ const parsed = URL.canParse(url) ? new URL(url) : undefined;
53
+ if (parsed?.origin !== 'https://auth.openai.com' || parsed.pathname !== '/oauth/authorize')
54
+ return url;
55
+ parsed.searchParams.set('prompt', 'login');
56
+ return parsed.toString();
57
+ };
44
58
  let server;
45
59
  let sessionId = '';
46
60
  let starting;
@@ -48,6 +62,7 @@ export function signIn(ctx, member, o, on) {
48
62
  let settleStart;
49
63
  const startingSettled = new Promise((resolve) => { settleStart = resolve; });
50
64
  let over = false;
65
+ let activating = false;
51
66
  let recovering = false;
52
67
  let offDisconnect;
53
68
  let agentId;
@@ -82,13 +97,21 @@ export function signIn(ctx, member, o, on) {
82
97
  let pasteIn;
83
98
  let returned;
84
99
  // Once a secret step is seen, no later gateway prose, link or code is safe to display.
85
- let sensitive = o.authChoice === 'setup-token' || o.authChoice === 'apiKey';
86
- const secretFailure = () => o.authChoice === 'apiKey' ? 'API key activation failed' : 'Sign-in failed. Try again.';
100
+ let sensitive = o.authChoice === 'setup-token';
101
+ const secretFailure = 'Sign-in failed. Try again.';
102
+ // A pasted key is scrubbed from every failure on top of the engine's own redaction.
103
+ const keyRoute = routes().find((r) => r.choice === o.authChoice && r.keyEntry);
104
+ let secret;
105
+ const clean = (value) => {
106
+ const text = value instanceof Error ? value.message : String(value);
107
+ return cut(secret ? text.split(secret).join('…') : text);
108
+ };
109
+ const keyFailed = (status) => status === 'unavailable' ? KEY_UNAVAILABLE : KEY_FAILED;
87
110
  const done = new Promise((resolve) => { settle = resolve; });
88
111
  const say = (v) => {
89
112
  if (!over && !recovering && !signal.aborted)
90
113
  on({ state: 'waiting', via, ...(sensitive
91
- ? { ...(v.prompt ? { prompt: v.prompt } : {}), ...(v.error ? { error: secretFailure() } : {}) }
114
+ ? { ...(v.prompt ? { prompt: v.prompt } : {}), ...(v.error ? { error: secretFailure } : {}) }
92
115
  : v) });
93
116
  };
94
117
  const closeCallback = async () => {
@@ -109,7 +132,7 @@ export function signIn(ctx, member, o, on) {
109
132
  pasteIn = undefined;
110
133
  await closeCallback();
111
134
  if (sensitive && view.state === 'failed' && view.why === 'failed')
112
- view = { ...view, error: secretFailure() };
135
+ view = { ...view, error: secretFailure };
113
136
  on(view);
114
137
  settle(view);
115
138
  };
@@ -134,11 +157,13 @@ export function signIn(ctx, member, o, on) {
134
157
  };
135
158
  const cancel = () => {
136
159
  // Stop the drive now; the start itself is never aborted, and release waits for it to settle before cancelling,
137
- // so the gateway's one setup admission is freed by a cancel that the engine can actually find.
160
+ // so the gateway's one setup admission is freed by a cancel that the engine can actually find. An activation in
161
+ // flight is not finished here: the drive returns once the engine answers, so cleanup follows the engine's answer.
138
162
  if (signal.aborted)
139
163
  return;
140
164
  owner.abort();
141
- void (async () => { await release(); await finish(cancelled()); })();
165
+ void (async () => { await release(); if (!activating)
166
+ await finish(cancelled()); })();
142
167
  };
143
168
  o.signal?.addEventListener('abort', cancel, { once: true });
144
169
  if (o.signal?.aborted)
@@ -188,7 +213,9 @@ export function signIn(ctx, member, o, on) {
188
213
  }
189
214
  // Native login is completed by the person in unmodified Claude Code. The pin does not expose this
190
215
  // choice via auth.start; activation verifies the CLI route and persists it only after a live test succeeds.
191
- if (o.authChoice === 'anthropic-cli' || o.authChoice === 'apiKey') {
216
+ // A key-entry choice (API key or plan key) is not an auth.start login either: the pin's Gateway sets it up only
217
+ // from the pasted key through typed activation, and refuses it there ("provider setup is not available").
218
+ if (o.authChoice === 'anthropic-cli' || keyRoute) {
192
219
  settleStart();
193
220
  let apiKey;
194
221
  if (o.authChoice === 'anthropic-cli') {
@@ -197,7 +224,7 @@ export function signIn(ctx, member, o, on) {
197
224
  return { state: 'failed', via, why: 'failed', error: 'Sign in with Claude Code on this machine in the kit’s isolated HOME, then try again. The login stays in Claude Code.' };
198
225
  }
199
226
  else {
200
- say({ prompt: 'API key (billed per use)' });
227
+ say({ prompt: keyRoute?.billing === 'api' ? 'API key (billed per use)' : 'Plan key' });
201
228
  apiKey = returned ?? await new Promise((resolve) => {
202
229
  const stop = (value) => { clearTimeout(timer); signal.removeEventListener('abort', abort); pasteIn = undefined; resolve(value); };
203
230
  const abort = () => stop();
@@ -210,16 +237,23 @@ export function signIn(ctx, member, o, on) {
210
237
  returned = undefined;
211
238
  if (!apiKey)
212
239
  return { state: 'failed', via, why: signal.aborted ? 'declined' : 'expired' };
240
+ secret = apiKey.trim() || undefined;
213
241
  }
214
242
  if (signal.aborted)
215
243
  return { state: 'failed', via, why: 'declined' };
216
- const result = await ctx.request('openclaw.setup.activate', {
244
+ // Done waits for the gateway restart an `auth` change causes (activation.ts); a run started before it runs on the
245
+ // old config. A restart that runs late or is cancelled ends failed, and the account is cleaned up by the caller.
246
+ activating = true;
247
+ const result = await activate(ctx, {
217
248
  agentId, kind: apiKey === undefined ? 'claude-cli' : 'api-key',
218
- ...(apiKey === undefined ? {} : { authChoice: 'apiKey', apiKey }),
219
- }, { timeoutMs: PULL_MS, signal });
220
- // API secrets must never be echoed in a view, even if the gateway returns an error containing one.
221
- return result.ok ? { state: 'done', via } : { state: 'failed', via, why: 'failed',
222
- error: apiKey === undefined ? cut(result.error ?? 'Claude Code activation failed') : 'API key activation failed' };
249
+ ...(apiKey === undefined ? {} : { authChoice: o.authChoice, apiKey }),
250
+ }, { timeoutMs: PULL_MS, signal }).finally(() => { activating = false; });
251
+ if (result.late)
252
+ return signal.aborted ? cancelled() : { state: 'failed', via, why: 'failed', error: LATE_RESTART };
253
+ if (!result.ok)
254
+ return { state: 'failed', via, why: 'failed',
255
+ error: apiKey === undefined ? clean(result.error ?? 'Claude Code activation failed') : keyFailed(result.status) };
256
+ return signal.aborted ? cancelled() : { state: 'done', via };
223
257
  }
224
258
  // The client chooses the wizard id; cancellation waits for the engine to register it.
225
259
  sessionId = `byokit-${randomUUID()}`;
@@ -295,7 +329,7 @@ export function signIn(ctx, member, o, on) {
295
329
  codeTimer = setTimeout(() => { codeExpired = true; owner.abort(); }, duration);
296
330
  const message = deviceCode.message ?? current.message;
297
331
  // The code is on the card; acknowledging it lets the engine poll the provider itself.
298
- say({ ...(deviceCode.code ? { code: deviceCode.code } : {}), ...(current.externalUrl ? { url: current.externalUrl } : {}),
332
+ say({ ...(deviceCode.code ? { code: deviceCode.code } : {}), ...(current.externalUrl ? { url: shown(current.externalUrl) } : {}),
299
333
  ...(timed ? { expiresAt: Date.now() + duration } : {}), ...(message ? { message } : {}) });
300
334
  const next = await answer(current);
301
335
  if (next.done) {
@@ -326,7 +360,7 @@ export function signIn(ctx, member, o, on) {
326
360
  }
327
361
  if (current.type === 'note' || current.type === 'confirm' || current.type === 'select' || current.type === 'action') {
328
362
  if (current.externalUrl)
329
- say({ url: current.externalUrl });
363
+ say({ url: shown(current.externalUrl) });
330
364
  const next = await answer(current);
331
365
  if (next.done) {
332
366
  terminal = next;
@@ -418,7 +452,7 @@ export function signIn(ctx, member, o, on) {
418
452
  else if (BUSY.test(cut(error)))
419
453
  view = { state: 'failed', via, why: 'busy' };
420
454
  else
421
- view = { state: 'failed', via, why: 'failed', error: o.authChoice === 'apiKey' ? 'API key activation failed' : cut(error) };
455
+ view = { state: 'failed', via, why: 'failed', error: keyRoute ? KEY_FAILED : clean(error) };
422
456
  }
423
457
  settleStart();
424
458
  if (view.state !== 'done')