@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
package/dist/link.js ADDED
@@ -0,0 +1,273 @@
1
+ // Host-side link adapter (7.1): typed, member-checked ops over @byokit/link, sealed approval push
2
+ // via the relay (7.3). Node only.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createServer } from 'node:http';
5
+ import { WebSocketServer } from 'ws';
6
+ import { PublicLinkError, } from '@byokit/link';
7
+ import { reach } from '@byokit/reach';
8
+ import { b64urlDecode, sealNotice } from "./notices.js";
9
+ import { routeFor } from "./routes.js";
10
+ import { stateWords, words } from "./words.js";
11
+ const VIEW_OPS = new Set(['oc.state', 'oc.routes', 'oc.signin.view', 'oc.sessions', 'oc.approvals', 'oc.events']);
12
+ const refused = () => new PublicLinkError(words('link.notAllowed'));
13
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ const memberKey = (member, sessionKey) => sessionKey.startsWith(`agent:${member}:`);
15
+ function memberSessions(result, member) {
16
+ const rows = Array.isArray(result) ? result : isRecord(result) && Array.isArray(result.sessions) ? result.sessions : [];
17
+ return rows.filter((row) => isRecord(row) && typeof row.sessionKey === 'string' && memberKey(member, row.sessionKey));
18
+ }
19
+ export function openclawLink(kit, o) {
20
+ const passThrough = o.passThrough ?? (() => false);
21
+ // Grants that have called, so sealed pushes and push actions can check the member (7.3).
22
+ const seen = new Map();
23
+ // Devices that registered a box key, by grant id.
24
+ const boxes = new Map();
25
+ // One sign-in drive per member+provider, plus the latest view even after it ends.
26
+ const drives = new Map();
27
+ const latest = new Map();
28
+ const memberOf = (grant) => {
29
+ seen.set(grant.id, grant);
30
+ const member = o.memberOf(grant);
31
+ if (member === undefined)
32
+ throw refused();
33
+ return member;
34
+ };
35
+ const pushApproval = async (approval) => {
36
+ if (!o.relay)
37
+ return;
38
+ const jobs = [];
39
+ for (const [id, box] of boxes) {
40
+ const grant = seen.get(id);
41
+ if (!grant || o.memberOf(grant) !== approval.member)
42
+ continue;
43
+ jobs.push(o.relay.notify({ id: approval.id, title: words('approval.notice'), data: sealNotice(approval, box),
44
+ to: [id], actions: ['allow', 'deny'], urgency: 'high' }, { includeContent: true }));
45
+ }
46
+ for (const [id, grant] of seen) {
47
+ if (boxes.has(id) || o.memberOf(grant) !== approval.member)
48
+ continue;
49
+ jobs.push(o.relay.notify({ id: approval.id, title: words('approval.notice'), to: [id], actions: ['allow', 'deny'], urgency: 'high' }, { includeContent: false }));
50
+ }
51
+ await Promise.all(jobs);
52
+ };
53
+ const offPush = o.relay ? kit.onApproval((a, change) => {
54
+ if (change !== 'added')
55
+ return;
56
+ void pushApproval(a).catch(() => { });
57
+ }) : undefined;
58
+ void offPush;
59
+ const onAction = async (a) => {
60
+ const grant = seen.get(a.device);
61
+ const member = grant ? o.memberOf(grant) : undefined;
62
+ if (member === undefined)
63
+ throw refused();
64
+ if (a.action !== 'allow' && a.action !== 'deny')
65
+ throw refused();
66
+ if (!kit.approvals(member).some((approval) => approval.id === a.event))
67
+ throw refused();
68
+ await kit.decide(a.event, { allow: a.action === 'allow' });
69
+ return null;
70
+ };
71
+ const endReason = (error) => error instanceof PublicLinkError ? error.message.slice(0, 200) : 'failed';
72
+ const handle = async (req, grant) => {
73
+ const member = memberOf(grant);
74
+ if (grant.role === 'view' && !VIEW_OPS.has(req.op))
75
+ throw refused();
76
+ const args = isRecord(req.args) ? req.args : {};
77
+ switch (req.op) {
78
+ case 'oc.state':
79
+ return { state: kit.state, words: stateWords(kit.state) };
80
+ case 'oc.routes':
81
+ return kit.routes().filter((route) => route.offer);
82
+ case 'oc.signin.start': {
83
+ if (typeof args.provider !== 'string' || (args.via !== 'browser' && args.via !== 'code'))
84
+ throw new Error('oc.signin.start needs { provider, via: browser|code }');
85
+ const route = routeFor(args.provider, args.via);
86
+ if (!route)
87
+ throw refused();
88
+ const key = `${member}:${args.provider}`;
89
+ drives.get(key)?.cancel();
90
+ const view = { state: 'waiting', via: args.via };
91
+ latest.set(key, view);
92
+ const drive = kit.signIn(member, { authChoice: route.choice, via: args.via }, (next) => {
93
+ latest.set(key, next);
94
+ });
95
+ drives.set(key, drive);
96
+ void drive.done.then((done) => { latest.set(key, done); if (drives.get(key) === drive)
97
+ drives.delete(key); }, () => { if (drives.get(key) === drive)
98
+ drives.delete(key); });
99
+ return latest.get(key);
100
+ }
101
+ case 'oc.signin.view': {
102
+ if (typeof args.provider !== 'string')
103
+ throw new Error('oc.signin.view needs { provider }');
104
+ // `ready` is the member's account (signed in to this provider), not the engine: `phaseOf` reads it as done.
105
+ const key = `${member}:${args.provider}`;
106
+ const ready = kit.state.phase === 'ready' && await kit.signedIn(member, args.provider);
107
+ // A finished sign-in whose account is gone since (signed out anywhere, or dropped by the engine) is no
108
+ // sign-in to show: without this the view says done while `ready` says no, which phaseOf reads as opening.
109
+ if (!ready && !drives.has(key) && latest.get(key)?.state === 'done')
110
+ latest.delete(key);
111
+ return { ready, view: latest.get(key) ?? null };
112
+ }
113
+ case 'oc.signin.paste': {
114
+ if (typeof args.provider !== 'string' || typeof args.text !== 'string')
115
+ throw new Error('oc.signin.paste needs { provider, text }');
116
+ drives.get(`${member}:${args.provider}`)?.paste(args.text);
117
+ return null;
118
+ }
119
+ case 'oc.signin.cancel': {
120
+ if (typeof args.provider !== 'string')
121
+ throw new Error('oc.signin.cancel needs { provider }');
122
+ drives.get(`${member}:${args.provider}`)?.cancel();
123
+ return null;
124
+ }
125
+ case 'oc.signout': {
126
+ if (typeof args.provider !== 'string')
127
+ throw new Error('oc.signout needs { provider }');
128
+ await kit.signOut(member, args.provider);
129
+ return null;
130
+ }
131
+ case 'oc.sessions': {
132
+ const result = await kit.call('sessions.list', {});
133
+ return memberSessions(result, member);
134
+ }
135
+ case 'oc.steer': {
136
+ if (typeof args.sessionKey !== 'string' || typeof args.text !== 'string')
137
+ throw new Error('oc.steer needs { sessionKey, text }');
138
+ if (!memberKey(member, args.sessionKey))
139
+ throw refused();
140
+ await kit.steer(args.sessionKey, args.text);
141
+ return null;
142
+ }
143
+ case 'oc.abort': {
144
+ if (typeof args.sessionKey !== 'string')
145
+ throw new Error('oc.abort needs { sessionKey }');
146
+ if (!memberKey(member, args.sessionKey))
147
+ throw refused();
148
+ await kit.abort(args.sessionKey);
149
+ return null;
150
+ }
151
+ case 'oc.approvals':
152
+ return kit.approvals(member);
153
+ case 'oc.decide': {
154
+ if (typeof args.id !== 'string' || typeof args.allow !== 'boolean')
155
+ throw new Error('oc.decide needs { id, allow }');
156
+ if (!kit.approvals(member).some((approval) => approval.id === args.id))
157
+ throw refused();
158
+ await kit.decide(args.id, {
159
+ allow: args.allow,
160
+ ...(typeof args.reason === 'string' ? { reason: args.reason } : {}),
161
+ ...('answer' in args ? { answer: args.answer } : {}),
162
+ });
163
+ return null;
164
+ }
165
+ case 'oc.notices.register': {
166
+ // Per-device box key for sealed pushes. Kept in this adapter (not grant meta: the HostOptions
167
+ // shape gives handle no host for setMeta, and the key is session state devices re-register).
168
+ if (typeof args.boxPublicKey !== 'string')
169
+ throw new Error('oc.notices.register needs { boxPublicKey }');
170
+ const box = b64urlDecode(args.boxPublicKey);
171
+ if (!box || box.length !== 32)
172
+ throw new Error('oc.notices.register needs a 32-byte b64url boxPublicKey');
173
+ boxes.set(grant.id, box);
174
+ return null;
175
+ }
176
+ case 'oc.call': {
177
+ if (typeof args.method !== 'string')
178
+ throw new Error('oc.call needs { method, params }');
179
+ if (!passThrough(args.method, grant))
180
+ throw refused();
181
+ return await kit.call(args.method, args.params);
182
+ }
183
+ default:
184
+ throw refused();
185
+ }
186
+ };
187
+ const stream = async (s, req, grant) => {
188
+ const member = memberOf(grant);
189
+ if (req.op === 'oc.run') {
190
+ if (grant.role === 'view')
191
+ throw refused();
192
+ const args = isRecord(req.args) ? req.args : {};
193
+ if (typeof args.message !== 'string' || (args.model !== undefined && typeof args.model !== 'string'))
194
+ throw new Error('oc.run needs { message, sessionKey?, model? }');
195
+ const sessionKey = args.sessionKey === undefined ? `agent:${member}:link:${randomUUID()}` : args.sessionKey;
196
+ if (typeof sessionKey !== 'string' || !memberKey(member, sessionKey))
197
+ throw refused();
198
+ // Ordered frames over the paced stream: each write waits for the last.
199
+ let tail = Promise.resolve();
200
+ const send = (frame) => {
201
+ tail = tail.then(() => s.write(`${JSON.stringify(frame)}\n`)).catch(() => { });
202
+ };
203
+ try {
204
+ const end = await kit.run({ member, sessionKey, message: args.message,
205
+ ...(args.model === undefined ? {} : { model: args.model }) }, (e) => send(e));
206
+ send({ type: 'end', end });
207
+ await tail;
208
+ s.end();
209
+ }
210
+ catch (error) {
211
+ await tail;
212
+ s.end(endReason(error));
213
+ }
214
+ return;
215
+ }
216
+ if (req.op === 'oc.events') {
217
+ const offEvent = kit.onEvent('*', (payload, event) => {
218
+ const record = payload;
219
+ const agent = record?.agentId;
220
+ const key = record?.sessionKey;
221
+ if (agent !== member && !(typeof key === 'string' && memberKey(member, key)))
222
+ return;
223
+ void s.write(`${JSON.stringify({ event, payload })}\n`).catch(() => { });
224
+ });
225
+ const offApproval = kit.onApproval((approval, change) => {
226
+ if (approval.member !== member)
227
+ return;
228
+ void s.write(`${JSON.stringify({ event: 'approval', change, approval })}\n`).catch(() => { });
229
+ });
230
+ s.onEnd = () => { offEvent(); offApproval(); };
231
+ return;
232
+ }
233
+ throw refused();
234
+ };
235
+ return { handle, stream, allow: () => true, onAction };
236
+ }
237
+ // ponytail: this ~40-line serve() wiring is deliberately duplicated per runtime kit (D3);
238
+ // extract it into a shared package when a third runtime kit appears.
239
+ export async function serve(o) {
240
+ const r = await reach({ port: o.port, via: o.via, previous: o.previous });
241
+ const server = createServer(o.http ??
242
+ ((_req, res) => {
243
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
244
+ res.end('Not found');
245
+ }));
246
+ await new Promise((resolve, reject) => {
247
+ server.once('error', reject);
248
+ server.listen(o.port, r.bind, () => {
249
+ server.off('error', reject);
250
+ resolve();
251
+ });
252
+ });
253
+ const wss = new WebSocketServer({ server });
254
+ const sockets = new Set();
255
+ wss.on('connection', (ws, req) => {
256
+ sockets.add(ws);
257
+ ws.on('close', () => {
258
+ sockets.delete(ws);
259
+ });
260
+ o.host.accept(ws, { peer: req.socket.remoteAddress });
261
+ });
262
+ return {
263
+ urls: r.urls,
264
+ ...(r.ingress ? { ingress: r.ingress } : {}),
265
+ close: async () => {
266
+ // Ends device sockets first: the http close below waits for connections.
267
+ for (const ws of sockets)
268
+ ws.terminate();
269
+ await new Promise((resolve) => wss.close(() => resolve()));
270
+ await new Promise((resolve) => server.close(() => resolve()));
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,11 @@
1
+ import type { GatewayTransport, Member } from './types.ts';
2
+ export declare const MEMBER_ID: RegExp;
3
+ export declare function createMembers(ctx: {
4
+ request: GatewayTransport['request'];
5
+ root: string;
6
+ }): {
7
+ ensure(member: Member): Promise<{
8
+ agentId: string;
9
+ workspace: string;
10
+ }>;
11
+ };
@@ -0,0 +1,23 @@
1
+ import { join } from 'node:path';
2
+ export const MEMBER_ID = /^[a-z][a-z0-9-]{0,31}$/;
3
+ export function createMembers(ctx) {
4
+ const cache = new Map();
5
+ return { ensure(member) {
6
+ if (!MEMBER_ID.test(member))
7
+ return Promise.reject(new Error(`invalid member id: ${member}`));
8
+ const existing = cache.get(member);
9
+ if (existing)
10
+ return existing;
11
+ const workspace = join(ctx.root, 'workspaces', member);
12
+ const result = (async () => {
13
+ const list = await ctx.request('agents.list');
14
+ if (!list.agents.some((agent) => agent.id === member))
15
+ await ctx.request('agents.create', { name: member, workspace });
16
+ return { agentId: member, workspace };
17
+ })();
18
+ cache.set(member, result);
19
+ void result.catch(() => { if (cache.get(member) === result)
20
+ cache.delete(member); });
21
+ return result;
22
+ } };
23
+ }
@@ -0,0 +1,22 @@
1
+ import type { RetainedLogin } from './kit.ts';
2
+ import type { SignInCtx } from './signin.ts';
3
+ import type { Member } from './types.ts';
4
+ export type DoctorRunner = () => {
5
+ status: number | null;
6
+ };
7
+ /**
8
+ * Stage before `start()` (an import while a Gateway owns the state is exactly how a preserved sign-in is lost):
9
+ * `prepare()` first, then the member's `auth-profiles.json` (0600), then one offline doctor run. The original file
10
+ * is never written to: a failed run only clears the staging, so the next boot stages it again byte for byte.
11
+ */
12
+ export declare function migrateRetainedLogin(ctx: {
13
+ root: string;
14
+ prepare(): Promise<void>;
15
+ doctor: DoctorRunner;
16
+ }, member: Member, source: RetainedLogin): Promise<'staged' | 'nothing' | 'failed'>;
17
+ /**
18
+ * After `ready`: the migration counts only when the Gateway itself reports every provider signed in. Only then does
19
+ * a path source move aside (a rename, so the original bytes survive whole) — anything else leaves the sign-in where
20
+ * it was and the next boot retries it without asking the person to sign in again.
21
+ */
22
+ export declare function confirmRetainedLogin(ctx: SignInCtx, member: Member, source: RetainedLogin): Promise<boolean>;
@@ -0,0 +1,118 @@
1
+ // Retained-login migration (D15, 5.7): stage a Pi auth.json-shaped record into the engine's own store, then confirm
2
+ // only when the Gateway itself reports every provider signed in.
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { MEMBER_ID } from "./members.js";
6
+ import { providers } from "./signin.js";
7
+ const MOVED = '.moved-to-engine';
8
+ const MARKER = '.canonicalized';
9
+ const TRY_MS = 1_500;
10
+ const TRIES = 3;
11
+ /** The file to import from (D15): the path itself, else the retired copy while it is still unconfirmed. */
12
+ function sourceFile(source) {
13
+ if (!('path' in source))
14
+ return undefined;
15
+ if (existsSync(source.path))
16
+ return source.path;
17
+ const moved = source.path + MOVED;
18
+ return existsSync(moved) && !existsSync(moved + MARKER) ? moved : undefined;
19
+ }
20
+ /** The old sign-in's plain `{ '<provider>': credential }` map, or undefined when it is absent or unreadable. */
21
+ function credentials(source, path) {
22
+ try {
23
+ const raw = 'record' in source ? source.record : JSON.parse(readFileSync(path, 'utf8'));
24
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ /** The engine's own provider ids for what the old sign-in holds; `openai-codex` is `openai` (D15). */
31
+ function wantedProviders(legacy) {
32
+ const names = Object.entries(legacy).map(([key, credential]) => {
33
+ if (key === 'openai-codex')
34
+ return 'openai';
35
+ const own = credential && typeof credential === 'object' ? credential.provider : undefined;
36
+ return typeof own === 'string' && own !== '' ? own : key.toLowerCase();
37
+ });
38
+ return [...new Set(names)];
39
+ }
40
+ /**
41
+ * Stage before `start()` (an import while a Gateway owns the state is exactly how a preserved sign-in is lost):
42
+ * `prepare()` first, then the member's `auth-profiles.json` (0600), then one offline doctor run. The original file
43
+ * is never written to: a failed run only clears the staging, so the next boot stages it again byte for byte.
44
+ */
45
+ export async function migrateRetainedLogin(ctx, member, source) {
46
+ // The member id is the only thing between a caller and a credential write, and this runs before any transport
47
+ // could validate it (D9).
48
+ if (!MEMBER_ID.test(member))
49
+ throw new Error(`invalid member id: ${member}`);
50
+ const path = sourceFile(source);
51
+ if ('path' in source && !path)
52
+ return 'nothing';
53
+ const legacy = credentials(source, path);
54
+ if (!legacy || !Object.keys(legacy).length)
55
+ return 'nothing'; // never migrate an empty source
56
+ // A retired copy without the one provider the engine's canonicalization exists for is not worth importing.
57
+ if (path?.endsWith(MOVED) && !('openai-codex' in legacy))
58
+ return 'nothing';
59
+ await ctx.prepare();
60
+ const agentDir = join(ctx.root, 'state', 'agents', member, 'agent');
61
+ const staged = join(agentDir, 'auth-profiles.json');
62
+ // Only a staging this call wrote may ever be removed: the file is the member's live sign-in once the engine has
63
+ // imported it, and a failed run must not take that with it.
64
+ const wrote = !existsSync(staged);
65
+ if (wrote) {
66
+ // Doctor canonicalizes legacy provider ids before importing; staging `auth.json` instead keeps the old id,
67
+ // which looks signed in but cannot authenticate `openai/*` turns.
68
+ mkdirSync(agentDir, { recursive: true, mode: 0o700 });
69
+ const body = JSON.stringify({ version: 1, profiles: Object.fromEntries(Object.entries(legacy).map(([provider, credential]) => [`${provider}:default`, credential])) });
70
+ // Written beside its home and renamed into place, so a reader never sees half a profile store.
71
+ const staging = `${staged}.staging-${process.pid}`;
72
+ writeFileSync(staging, body, { mode: 0o600 });
73
+ renameSync(staging, staged);
74
+ }
75
+ // The doctor's exit is a weak yes (it exits 0 even when it imports nothing), so a failed run only clears the
76
+ // staging; retiring the original stays confirm's job.
77
+ if (ctx.doctor().status !== 0) {
78
+ if (wrote)
79
+ rmSync(staged, { force: true });
80
+ return 'failed';
81
+ }
82
+ return 'staged';
83
+ }
84
+ /**
85
+ * After `ready`: the migration counts only when the Gateway itself reports every provider signed in. Only then does
86
+ * a path source move aside (a rename, so the original bytes survive whole) — anything else leaves the sign-in where
87
+ * it was and the next boot retries it without asking the person to sign in again.
88
+ */
89
+ export async function confirmRetainedLogin(ctx, member, source) {
90
+ if (!MEMBER_ID.test(member))
91
+ throw new Error(`invalid member id: ${member}`);
92
+ const original = 'path' in source ? source.path : undefined;
93
+ const path = sourceFile(source);
94
+ if (original && !path)
95
+ return false;
96
+ const legacy = credentials(source, path);
97
+ if (!legacy)
98
+ return false;
99
+ const wanted = wantedProviders(legacy);
100
+ if (!wanted.length)
101
+ return false; // nothing recognizable to verify: never retire on a guess
102
+ try {
103
+ for (let tries = 0; tries < TRIES; tries++) {
104
+ const have = await providers(ctx, member, true);
105
+ if (wanted.every((provider) => have.includes(provider))) {
106
+ if (path && path === original)
107
+ renameSync(path, path + MOVED);
108
+ if (original)
109
+ writeFileSync(original + MOVED + MARKER, '', { mode: 0o600 });
110
+ return true; // a record source moves nothing: the app deletes its own copy
111
+ }
112
+ if (tries < TRIES - 1)
113
+ await new Promise((resume) => setTimeout(resume, TRY_MS));
114
+ }
115
+ }
116
+ catch { /* the engine is down or the file unreadable: the original stays */ }
117
+ return false;
118
+ }
@@ -0,0 +1,9 @@
1
+ import type { Approval } from './types.ts';
2
+ /** Portable base64url. btoa/atob are globals in Node, browsers and React Native. */
3
+ export declare function b64urlEncode(bytes: Uint8Array): string;
4
+ export declare function b64urlDecode(text: string): Uint8Array | null;
5
+ export declare function sealNotice(a: Approval, boxPublicKey: Uint8Array): {
6
+ v: 1;
7
+ sealed: string;
8
+ };
9
+ export declare function openNotice(data: Record<string, unknown>, seed: Uint8Array): Approval | null;
@@ -0,0 +1,75 @@
1
+ // Sealed approval notices for the relay push (7.3). Portable: only @byokit/seal and types —
2
+ // no node:* or Node-only imports may reach here (test/portable.test.ts guards the device entry
3
+ // that re-exports openNotice). The relay reads only the generic title; the approval itself
4
+ // travels sealed to the device's box key.
5
+ import { openBox, sealBox } from '@byokit/seal';
6
+ const SOURCES = new Set(['gate', 'exec', 'plugin', 'question']);
7
+ /** Portable base64url. btoa/atob are globals in Node, browsers and React Native. */
8
+ export function b64urlEncode(bytes) {
9
+ let binary = '';
10
+ for (const byte of bytes)
11
+ binary += String.fromCharCode(byte);
12
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
13
+ }
14
+ export function b64urlDecode(text) {
15
+ try {
16
+ const padded = text.replaceAll('-', '+').replaceAll('_', '/');
17
+ const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
18
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ const isBoxKey = (value) => ArrayBuffer.isView(value) && value instanceof Uint8Array && value.length === 32;
26
+ /** Shape-check a decrypted notice: anything else is a wrong key or a foreign payload, never an approval. */
27
+ function isApproval(value) {
28
+ if (!isRecord(value))
29
+ return false;
30
+ if (typeof value.id !== 'string' || value.id === '')
31
+ return false;
32
+ if (typeof value.source !== 'string' || !SOURCES.has(value.source))
33
+ return false;
34
+ if (typeof value.member !== 'string')
35
+ return false;
36
+ if (typeof value.summary !== 'string')
37
+ return false;
38
+ if (typeof value.at !== 'number' || typeof value.expires !== 'number')
39
+ return false;
40
+ if (value.sessionKey !== undefined && typeof value.sessionKey !== 'string')
41
+ return false;
42
+ if (value.tool !== undefined && typeof value.tool !== 'string')
43
+ return false;
44
+ return true;
45
+ }
46
+ export function sealNotice(a, boxPublicKey) {
47
+ if (!isBoxKey(boxPublicKey))
48
+ throw new RangeError('sealNotice needs a 32-byte box public key');
49
+ const bytes = new TextEncoder().encode(JSON.stringify(a));
50
+ try {
51
+ return { v: 1, sealed: b64urlEncode(sealBox(bytes, boxPublicKey)) };
52
+ }
53
+ finally {
54
+ bytes.fill(0);
55
+ }
56
+ }
57
+ export function openNotice(data, seed) {
58
+ if (!isRecord(data) || data.v !== 1 || typeof data.sealed !== 'string')
59
+ return null;
60
+ if (!isBoxKey(seed))
61
+ return null;
62
+ const bundle = b64urlDecode(data.sealed);
63
+ if (!bundle)
64
+ return null;
65
+ const bytes = openBox(bundle, seed);
66
+ if (!bytes)
67
+ return null;
68
+ try {
69
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
70
+ return isApproval(value) ? value : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
@@ -0,0 +1,9 @@
1
+ import type { Route } from './types.ts';
2
+ /** The pin's auth-choice table. `provider` is the id a person's account is known by (the doc's matrix), which for
3
+ * MiniMax's portal routes is the account-visible `minimax`, not the wizard's internal `minimax-portal`. `plugin` is
4
+ * the bundled plugin the app must allow for that route to start (5.6). Only the pin's bundled provider contracts are
5
+ * listed: install-catalog plugins (external providers needing their own install) are not contracted by the pin and
6
+ * are deliberately absent. */
7
+ export declare function routes(): Route[];
8
+ /** The route an app should use: only an offered route counts, so an Anthropic fallback can never be picked. */
9
+ export declare function routeFor(provider: string, via: 'browser' | 'code'): Route | undefined;
package/dist/routes.js ADDED
@@ -0,0 +1,15 @@
1
+ // Route labels: data, not code (D12). One entry per auth choice in the pinned tarball's provider contracts (O6).
2
+ import routesJson from './routes.json' with { type: 'json' };
3
+ const table = routesJson;
4
+ /** The pin's auth-choice table. `provider` is the id a person's account is known by (the doc's matrix), which for
5
+ * MiniMax's portal routes is the account-visible `minimax`, not the wizard's internal `minimax-portal`. `plugin` is
6
+ * the bundled plugin the app must allow for that route to start (5.6). Only the pin's bundled provider contracts are
7
+ * listed: install-catalog plugins (external providers needing their own install) are not contracted by the pin and
8
+ * are deliberately absent. */
9
+ export function routes() {
10
+ return table;
11
+ }
12
+ /** The route an app should use: only an offered route counts, so an Anthropic fallback can never be picked. */
13
+ export function routeFor(provider, via) {
14
+ return table.find((route) => route.offer && route.provider === provider && route.via === via);
15
+ }