@shardflux/cli 0.15.2 → 0.17.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 (77) hide show
  1. package/CHANGELOG.md +144 -14
  2. package/README.md +92 -7
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +15 -1
  5. package/dist/agent-commands.js +84 -23
  6. package/dist/agent-models.d.ts +61 -0
  7. package/dist/agent-models.js +79 -0
  8. package/dist/agent-state.d.ts +10 -0
  9. package/dist/agent-state.js +13 -0
  10. package/dist/agent-target-commands.d.ts +13 -3
  11. package/dist/agent-target-commands.js +129 -23
  12. package/dist/agent-targets.d.ts +5 -0
  13. package/dist/agent-targets.js +14 -5
  14. package/dist/agent-terminal.d.ts +8 -0
  15. package/dist/agent-terminal.js +35 -2
  16. package/dist/args.d.ts +5 -1
  17. package/dist/args.js +74 -11
  18. package/dist/cli-proxy-forward.d.ts +45 -0
  19. package/dist/cli-proxy-forward.js +432 -0
  20. package/dist/cli-proxy-hub.d.ts +273 -0
  21. package/dist/cli-proxy-hub.js +749 -0
  22. package/dist/cli-proxy.d.ts +325 -0
  23. package/dist/cli-proxy.js +1016 -0
  24. package/dist/codex-cloud.d.ts +3 -0
  25. package/dist/codex-cloud.js +17 -5
  26. package/dist/command-center-actions.js +25 -1
  27. package/dist/commands.d.ts +22 -0
  28. package/dist/commands.js +54 -14
  29. package/dist/conversation-commands.js +6 -0
  30. package/dist/conversation-local.js +2 -2
  31. package/dist/conversation-stash.js +4 -3
  32. package/dist/conversation-terminal.d.ts +5 -0
  33. package/dist/conversation-terminal.js +54 -6
  34. package/dist/conversations.d.ts +31 -3
  35. package/dist/conversations.js +53 -10
  36. package/dist/device-approvals.d.ts +12 -0
  37. package/dist/device-approvals.js +73 -0
  38. package/dist/device-helper.d.ts +3 -1
  39. package/dist/device-helper.js +14 -4
  40. package/dist/errors.d.ts +7 -0
  41. package/dist/errors.js +12 -2
  42. package/dist/files-mirror.d.ts +164 -0
  43. package/dist/files-mirror.js +761 -0
  44. package/dist/handlers.js +5 -1
  45. package/dist/index.d.ts +4 -0
  46. package/dist/index.js +3 -0
  47. package/dist/linux-helper.d.ts +12 -0
  48. package/dist/linux-helper.js +88 -0
  49. package/dist/linux-terminal.d.ts +11 -0
  50. package/dist/linux-terminal.js +42 -0
  51. package/dist/main.js +38 -8
  52. package/dist/onboarding.d.ts +2 -0
  53. package/dist/onboarding.js +53 -5
  54. package/dist/registry.d.ts +2 -42
  55. package/dist/registry.js +6 -90
  56. package/dist/t3-launch.d.ts +37 -0
  57. package/dist/t3-launch.js +133 -0
  58. package/dist/t3-mode.d.ts +121 -0
  59. package/dist/t3-mode.js +654 -0
  60. package/dist/t3-phone.d.ts +17 -0
  61. package/dist/t3-phone.js +15 -0
  62. package/dist/t3-thread-script.d.ts +18 -0
  63. package/dist/t3-thread-script.js +245 -0
  64. package/dist/terminal-onboarding.d.ts +11 -0
  65. package/dist/terminal-onboarding.js +75 -0
  66. package/dist/thread-on-computer.d.ts +35 -0
  67. package/dist/thread-on-computer.js +206 -0
  68. package/dist/update.d.ts +1 -1
  69. package/dist/update.js +12 -1
  70. package/dist/wait-status.d.ts +22 -0
  71. package/dist/wait-status.js +59 -0
  72. package/dist/worktree-twin-engine.d.ts +125 -0
  73. package/dist/worktree-twin-engine.js +615 -0
  74. package/dist/worktree-twin-land.py +331 -0
  75. package/dist/worktree-twin.d.ts +313 -0
  76. package/dist/worktree-twin.js +843 -0
  77. package/package.json +3 -3
@@ -0,0 +1,749 @@
1
+ /**
2
+ * The account's hosted CLIProxyAPI ("hub"): one copy of router-for-me/CLIProxyAPI per account, in a small workspace
3
+ * (`shardflux/cli-proxy`) that sleeps between requests and wakes on a request to its port. Every device reaches it
4
+ * through a forwarder on 127.0.0.1:8317 (cli-proxy-forward.ts here, `sf-agent cli-proxy-forward` in VMs) that sends
5
+ * each request to the port's callback URL.
6
+ *
7
+ * Exactly one copy may run per account: CLIProxyAPI rotates the subscription refresh tokens in its auth files and never
8
+ * re-reads them, so two copies of one login sign each other out. Ensuring the hub is therefore idempotent and
9
+ * serialized in the hub itself (flock), and auth files already on the hub are never overwritten.
10
+ *
11
+ * Hub info (`cli-proxy.json`, 0600, in the config directory, in the hub and in the machine template so every VM has
12
+ * it): { version, api_url, workspace_id, callback_url, callback_created_at, management_key }. Never printed or logged.
13
+ */
14
+ import { randomBytes } from 'node:crypto';
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { Document, isMap, parseDocument } from 'yaml';
18
+ import { ShardfluxApiError } from '@shardflux/sdk';
19
+ import { writePrivateFile } from "./credentials.js";
20
+ import { CodedError, UsageError } from "./errors.js";
21
+ /** The pinned CLIProxyAPI release and the SHA-256 of its Linux assets (verified in the hub before install). */
22
+ export const CLI_PROXY_VERSION = '8.0.23';
23
+ export const CLI_PROXY_ASSETS = {
24
+ x86_64: { asset: 'linux_amd64', sha256: '744000a1a4f9ce19f5964774f853d029797b87ef53796d054389258c66cb1224' },
25
+ aarch64: { asset: 'linux_aarch64', sha256: '7c54c9306167b3e252090924c39a9b9dd5c09dffd376551271d8902f391f0246' },
26
+ };
27
+ export const CLI_PROXY_PORT = 8317;
28
+ /**
29
+ * The hub's VM port: `sf-agent cli-proxy-filter` in front of 8317 without the management API. VMs receive only this
30
+ * port's callback URL; 8317's (the management port) stays with the computers that imported the hub.
31
+ */
32
+ export const CLI_PROXY_VM_PORT = 8318;
33
+ /** The hub's workspace key in the API key's project, and its labels. */
34
+ export const HUB_KEY = 'shardflux/cli-proxy';
35
+ export const HUB_LABELS = { shard_role: 'cli_proxy' };
36
+ /**
37
+ * `codex-connector`: the smallest fleet template (256 MiB fixed, 1 vCPU, 2 GiB disk) with curl, coreutils, util-linux
38
+ * and CA roots, which is all a single Go binary needs; `coding` would hold 2 GiB and start Docker, SSH and the agent
39
+ * services for nothing.
40
+ */
41
+ export const HUB_TEMPLATE = 'codex-connector';
42
+ /**
43
+ * The codex-connector version a hub runs at least: the first with the hub's boot service and an sf-agent that serves
44
+ * the VM port (cli-proxy-filter). New hubs are created on it; an older hub is upgraded in place (its home, and so its
45
+ * logins and settings, stay) before VMs get the hub's address.
46
+ */
47
+ export const HUB_TEMPLATE_VERSION = 4;
48
+ export const HUB_CAPS = { cpu_millis: 1000, memory_mib: 256, disk_gib: 2, allocation_mode: 'fixed' };
49
+ export const HUB_INFO_FILE = 'cli-proxy.json';
50
+ /** Paths in the hub workspace. */
51
+ export const HUB_DIR = '/home/user/.cli-proxy-hub';
52
+ export const HUB_AUTH_DIR = '/home/user/.cli-proxy-api';
53
+ const CALLBACK_PATH = /^\/__shardflux\/callback\/sfcb_[A-Za-z0-9_-]+\/$/;
54
+ function callbackUrlOk(cb) {
55
+ try {
56
+ const u = new URL(cb);
57
+ const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname);
58
+ return (u.protocol === 'https:' || (u.protocol === 'http:' && loopback)) && CALLBACK_PATH.test(u.pathname) && !u.search && !u.hash;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** The hub info in `text`, or null when it is not a valid one (an unreadable file is treated as absent). */
65
+ export function parseHubInfo(text) {
66
+ let v;
67
+ try {
68
+ v = JSON.parse(text);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ if (!v || typeof v !== 'object')
74
+ return null;
75
+ const o = v;
76
+ const str = (k) => { const v = o[k]; return typeof v === 'string' && v.length > 0 ? v : null; };
77
+ const [api, id, cb, at, mk] = [str('api_url'), str('workspace_id'), str('callback_url'), str('callback_created_at'), str('management_key')];
78
+ if (o.version !== 1 || !api || !id || !cb || !at || !mk || !callbackUrlOk(cb))
79
+ return null;
80
+ const [vcb, vat] = [str('vm_callback_url'), str('vm_callback_created_at')];
81
+ const vm = vcb && vat && callbackUrlOk(vcb) ? { vm_callback_url: vcb, vm_callback_created_at: vat } : {};
82
+ return { version: 1, api_url: api, workspace_id: id, callback_url: cb, callback_created_at: at, management_key: mk, ...vm };
83
+ }
84
+ export function hubInfoPath(dir) {
85
+ return join(dir, HUB_INFO_FILE);
86
+ }
87
+ /** The saved hub info for `apiUrl` (any API URL when omitted), or null. */
88
+ export function readHubInfo(dir, apiUrl) {
89
+ let text;
90
+ try {
91
+ text = readFileSync(hubInfoPath(dir), 'utf8');
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ const info = parseHubInfo(text);
97
+ return info && (apiUrl === undefined || info.api_url === apiUrl) ? info : null;
98
+ }
99
+ export function hubInfoText(info) {
100
+ return `${JSON.stringify(info, null, 2)}\n`;
101
+ }
102
+ /**
103
+ * What every VM receives (in the machine template): the VM port's (8318) callback URL, which serves CLIProxyAPI
104
+ * without its management API, and nothing else secret. The management key and the management port's URL stay on the
105
+ * computers that imported the hub, so no agent in a VM reaches the hub's auth files, whatever key it finds. Null
106
+ * while the hub does not serve the VM port.
107
+ */
108
+ export function vmHubInfoText(info) {
109
+ // The VM port's URL only: the management port's (8317) never leaves the computers that imported the hub.
110
+ if (!info.vm_callback_url || !info.vm_callback_created_at)
111
+ return null;
112
+ const { version, api_url, workspace_id } = info;
113
+ return `${JSON.stringify({ version, api_url, workspace_id, callback_url: info.vm_callback_url, callback_created_at: info.vm_callback_created_at }, null, 2)}\n`;
114
+ }
115
+ export function writeHubInfo(dir, info) {
116
+ writePrivateFile(dir, hubInfoPath(dir), hubInfoText(info));
117
+ }
118
+ /** Top-level sections only the v8 (nested) layout has (`routing`, `models` and `plugins` are in both). */
119
+ const V8_SECTIONS = ['server', 'management', 'access', 'oauth', 'requests', 'observability', 'upstream', 'multimedia', 'credentials', 'client'];
120
+ /** Each setting the hub sets (`value`) or leaves out: its path in the legacy (flat) layout and in the v8 layout. */
121
+ const HUB_SETTINGS = [
122
+ { legacy: ['host'], v8: ['server', 'host'], value: '0.0.0.0' },
123
+ { legacy: ['port'], v8: ['server', 'port'], value: CLI_PROXY_PORT },
124
+ { legacy: ['tls'], v8: ['server', 'tls'] },
125
+ { legacy: ['trusted-proxies'], v8: ['server', 'trusted-proxies'] },
126
+ { legacy: ['discovery'], v8: ['server', 'discovery'] },
127
+ { legacy: ['pprof'], v8: ['observability', 'pprof'] },
128
+ { legacy: ['plugins'], v8: ['plugins'] },
129
+ { legacy: ['auth-dir'], v8: ['oauth', 'auth-dir'], value: HUB_AUTH_DIR },
130
+ { legacy: ['logging-to-file'], v8: ['observability', 'logs', 'logging-to-file'], value: true },
131
+ { legacy: ['logs-max-total-size-mb'], v8: ['observability', 'logs', 'logs-max-total-size-mb'], value: 100 },
132
+ { legacy: ['request-log'], v8: ['observability', 'logs', 'request-log'], value: false },
133
+ { legacy: ['debug'], v8: ['observability', 'logs', 'debug'], value: false },
134
+ ];
135
+ function isLocalHost(host) {
136
+ const h = host.replace(/^\[|\]$/g, '').toLowerCase();
137
+ if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost') || h === '::1' || h === '0.0.0.0' || h.startsWith('fe80:') || /^f[cd][0-9a-f]{0,2}:/.test(h))
138
+ return true;
139
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(h);
140
+ if (!m)
141
+ return false;
142
+ const [a, b] = [Number(m[1]), Number(m[2])];
143
+ return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
144
+ }
145
+ /** A proxy URL that points at the user's own computer or network (unreachable from the hub). */
146
+ export function isLocalProxyUrl(value) {
147
+ if (typeof value !== 'string' || !value.trim())
148
+ return false;
149
+ try {
150
+ return isLocalHost(new URL(value.trim()).hostname);
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ }
156
+ /**
157
+ * The hub's config: the user's own CLIProxyAPI config (routing, retries, client API keys, model aliases, upstream keys,
158
+ * payload rules…), so the hosted copy answers exactly like the local one, with what only works on their computer
159
+ * replaced: listen address, TLS, auth directory, management (remote, the hub key in MANAGEMENT_PASSWORD, the user's own
160
+ * key kept, control panel off), plugins, pprof, a proxy-url on their network, and logging (rotating files in the hub,
161
+ * 100 MB at most, no request bodies). The file keeps its layout: the legacy flat one or `config-version: 8`.
162
+ */
163
+ export function hubConfig(userText) {
164
+ const dropped = [];
165
+ let doc = new Document({});
166
+ if (userText && userText.trim()) {
167
+ doc = parseDocument(userText);
168
+ if (doc.errors.length > 0)
169
+ throw new UsageError(`Your CLIProxyAPI config is not valid YAML: ${doc.errors[0].message.split('\n')[0]}`);
170
+ if (!isMap(doc.contents))
171
+ doc = new Document({});
172
+ }
173
+ const at = (path) => doc.getIn(path);
174
+ const del = (path) => { if (doc.hasIn(path))
175
+ doc.deleteIn(path); };
176
+ const v8 = doc.hasIn(['config-version']) || V8_SECTIONS.some((k) => isMap(at([k])));
177
+ // The user's own management key (a bcrypt hash once CLIProxyAPI has started) keeps working against the hub.
178
+ const userKey = [at(['management', 'secret-key']), at(['remote-management', 'secret-key'])].find((k) => typeof k === 'string' && k.trim() !== '');
179
+ if (at(['tls', 'enable']) === true || at(['server', 'tls', 'enable']) === true)
180
+ dropped.push('tls (the Shardflux edge serves HTTPS)');
181
+ if (at(['plugins', 'enabled']) === true)
182
+ dropped.push('plugins (they live on this computer)');
183
+ for (const path of [['proxy-url'], ['requests', 'proxy-url']]) {
184
+ if (isLocalProxyUrl(at(path))) {
185
+ del(path);
186
+ dropped.push('proxy-url (a proxy on this computer’s network)');
187
+ }
188
+ }
189
+ // Every replaced setting goes in both layouts, then is set in the layout the file uses.
190
+ for (const s of HUB_SETTINGS) {
191
+ del(s.legacy);
192
+ del(s.v8);
193
+ }
194
+ del(['remote-management']);
195
+ del(['management']);
196
+ if (v8 && !doc.hasIn(['config-version']))
197
+ doc.setIn(['config-version'], 8);
198
+ doc.setIn(v8 ? ['management'] : ['remote-management'], doc.createNode({ 'allow-remote': true, 'secret-key': userKey ?? '', 'disable-control-panel': true }));
199
+ for (const s of HUB_SETTINGS)
200
+ if (s.value !== undefined)
201
+ doc.setIn(v8 ? s.v8 : s.legacy, s.value);
202
+ return { text: doc.toString(), dropped: [...new Set(dropped)], keepsUserManagementKey: userKey !== undefined };
203
+ }
204
+ const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
205
+ /** The hub through the SDK and the project API key. */
206
+ /**
207
+ * Runs `script` in the hub with standard input open: the first stdout line goes to `answer`, whose reply is sent as
208
+ * the command's input (then closed), and the command's exit and output are returned. A failing `answer` still sends
209
+ * `ABORT` so the hub releases what it holds, then rethrows.
210
+ */
211
+ async function converse(cell, script, args, signal, answer) {
212
+ const session = await cell.exec.start({ argv: ['bash', '-c', script, 'cli-proxy-hub', ...args], stdin_open: true, timeout_ms: 900_000 }, signal);
213
+ const events = await cell.exec.output(session.session_id, { follow: true, ...(signal ? { signal } : {}) });
214
+ const text = { stdout: '', stderr: '' };
215
+ const offsets = { stdout: 0, stderr: 0 };
216
+ let answered = false, failure = null, exit = null;
217
+ for await (const event of events) {
218
+ if (event.type === 'error')
219
+ throw new HubError(`The hub command failed: ${event.error?.error.message ?? 'stream error'}`);
220
+ if (event.type === 'exit') {
221
+ exit = event.session?.exit_code ?? 1;
222
+ break;
223
+ }
224
+ if (event.type !== 'output' || !event.stream || event.offset === undefined || event.data === undefined)
225
+ continue;
226
+ const bytes = Buffer.from(event.data, 'base64');
227
+ const fresh = bytes.subarray(Math.max(0, offsets[event.stream] - event.offset));
228
+ offsets[event.stream] = Math.max(offsets[event.stream], event.offset + bytes.length);
229
+ text[event.stream] += fresh.toString('utf8');
230
+ const nl = text.stdout.indexOf('\n');
231
+ if (answered || nl < 0)
232
+ continue;
233
+ answered = true;
234
+ let reply = 'ABORT\n';
235
+ try {
236
+ reply = await answer(text.stdout.slice(0, nl));
237
+ }
238
+ catch (err) {
239
+ failure = err instanceof Error ? err : new Error(String(err));
240
+ }
241
+ await cell.exec.input(session.session_id, reply, { offset: 0, close: true, ...(signal ? { signal } : {}) });
242
+ }
243
+ if (failure)
244
+ throw failure;
245
+ return { exit, stdout: text.stdout, stderr: text.stderr };
246
+ }
247
+ /**
248
+ * Where a new hub may be created: only in the organization's first project (its oldest), so every project's key finds
249
+ * the same one hub. `home` is null without a session to list the projects with.
250
+ */
251
+ export function hubPlacement(keyProjectId, home) {
252
+ if (!home)
253
+ throw new HubError('Shard needs your account session to find your account’s CLIProxyAPI hub.', 'sign in: shard auth login, then run: shard cli-proxy import');
254
+ if (home.id !== keyProjectId) {
255
+ throw new HubError(`Your account’s CLIProxyAPI hub lives in project ${home.slug} (the organization’s first project), and this API key belongs to another project.`, `use a key of ${home.slug}: shard setup --project ${home.slug} --new-key, then run: shard cli-proxy import`);
256
+ }
257
+ }
258
+ export function sdkHubCloud(cloud, opts = {}) {
259
+ const signal = opts.signal;
260
+ return {
261
+ async open(create) {
262
+ const existing = await cloud.workspaces.findByKey(HUB_KEY, { includeDeleted: false, ...(signal ? { signal } : {}) });
263
+ if (!existing && !create)
264
+ return null;
265
+ if (!existing) {
266
+ // One hub per account: a key of another project must not create a second one.
267
+ const me = await cloud.me();
268
+ if (me.type !== 'api_key' || !me.api_key)
269
+ throw new HubError('The hub needs a project API key.', 'save one: shard setup');
270
+ hubPlacement(me.api_key.project_id, (await opts.homeProject?.(me.api_key.organization_id)) ?? null);
271
+ }
272
+ const ws = await cloud.workspaces.open({
273
+ key: HUB_KEY,
274
+ ...(existing ? {} : { template: `${HUB_TEMPLATE}@${HUB_TEMPLATE_VERSION}`, caps: HUB_CAPS, lifetime: 'persistent' }),
275
+ labels: { ...existing?.labels, ...HUB_LABELS },
276
+ wait: { ...(opts.wakeTimeoutMs ? { timeoutMs: Math.max(opts.wakeTimeoutMs, 180_000) } : {}), ...(signal ? { signal } : {}) },
277
+ });
278
+ const cell = ws.cell({ ...(opts.agentLabel ? { agentLabel: opts.agentLabel } : {}), ...(opts.wakeTimeoutMs ? { transitionTimeoutMs: opts.wakeTimeoutMs } : {}) });
279
+ const hub = {
280
+ id: ws.id,
281
+ async sh(script, args = [], o = {}) {
282
+ const r = await cell.exec.run(['bash', '-c', script, 'cli-proxy-hub', ...args], {
283
+ timeoutMs: o.timeoutMs ?? 120_000, maxOutputBytes: 64 * 1024, ...(o.stdin !== undefined ? { stdin: o.stdin } : {}), ...(signal ? { signal } : {}),
284
+ });
285
+ return { code: r.exitCode, stdout: r.stdout, stderr: r.stderr };
286
+ },
287
+ async read(path) {
288
+ try {
289
+ return await cell.files.readText(path);
290
+ }
291
+ catch (e) {
292
+ if (missing(e))
293
+ return null;
294
+ throw e;
295
+ }
296
+ },
297
+ async write(path, data, mode) {
298
+ await cell.files.write(path, data, { mode, createParents: true });
299
+ },
300
+ async list(dir) {
301
+ try {
302
+ return (await cell.files.list(dir)).entries.map((e) => e.name);
303
+ }
304
+ catch (e) {
305
+ if (missing(e))
306
+ return null;
307
+ throw e;
308
+ }
309
+ },
310
+ async authFiles() {
311
+ const r = await hub.sh(`[ -d ${HUB_AUTH_DIR} ] || exit 0; cd ${HUB_AUTH_DIR} && find . -type f -name '*.json' -printf '%P\\n'`);
312
+ if (r.code !== 0)
313
+ throw new HubError(`Could not list the hub’s logins: ${r.stderr.trim() || `exit ${r.code}`}`);
314
+ return r.stdout.split('\n').filter(Boolean);
315
+ },
316
+ async exposePort(port) {
317
+ const p = await ws.ports.expose(port);
318
+ return { callbackCreatedAt: p.callback?.createdAt ?? null };
319
+ },
320
+ async createCallbackUrl(port) {
321
+ return ws.ports.createCallbackUrl(port);
322
+ },
323
+ async withCallbackLock(decide, file = 'callback.json') {
324
+ let result = null, wrote = false;
325
+ const r = await converse(cell, CALLBACK_SCRIPT, [file], signal, async (line) => {
326
+ const m = /^STORED (\S+)$/.exec(line);
327
+ if (!m)
328
+ return 'ABORT\n';
329
+ const stored = m[1] === '-' ? null : parseStoredCallback(Buffer.from(m[1], 'base64').toString('utf8'));
330
+ result = await decide(stored);
331
+ wrote = result !== stored;
332
+ return wrote ? `NEW ${Buffer.from(JSON.stringify(result)).toString('base64')}\n` : 'KEEP\n';
333
+ });
334
+ if (r.exit !== 0 || !result || (wrote && !r.stdout.includes('SAVED'))) {
335
+ throw new HubError(`The hub did not store its callback URL: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.exit}`}`);
336
+ }
337
+ return result;
338
+ },
339
+ async withLock(name, fn) {
340
+ let value;
341
+ let ran = false;
342
+ const r = await converse(cell, LOCK_SCRIPT, [name], signal, async (line) => {
343
+ if (line !== 'LOCKED')
344
+ return '\n';
345
+ value = await fn();
346
+ ran = true;
347
+ return 'DONE\n';
348
+ });
349
+ if (r.exit !== 0 || !ran)
350
+ throw new HubError(`The hub’s ${name} lock failed: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.exit}`}`);
351
+ return value;
352
+ },
353
+ };
354
+ return { ws: hub, created: !existing, template: { version: ws.template?.version ?? null, upgradeTo: ws.upgradeAvailable?.template_version ?? null } };
355
+ },
356
+ async upgrade(id) {
357
+ await cloud.workspaces.upgrade(id, { at: 'now', wait: { ...(opts.wakeTimeoutMs ? { timeoutMs: Math.max(opts.wakeTimeoutMs, 300_000) } : { timeoutMs: 300_000 }), ...(signal ? { signal } : {}) } });
358
+ },
359
+ async state(id) {
360
+ try {
361
+ const w = await cloud.workspaces.get(id);
362
+ return w.data.deleted_at ? null : w.data.observed_state;
363
+ }
364
+ catch (e) {
365
+ if (missing(e))
366
+ return null;
367
+ throw e;
368
+ }
369
+ },
370
+ async suspend(id) {
371
+ await (await cloud.workspaces.get(id)).suspend({ wait: { ...(signal ? { signal } : {}) } });
372
+ },
373
+ probe: (url) => probeCallback(url),
374
+ };
375
+ }
376
+ /** Installs the pinned release once: download, verify its SHA-256, then move the binary into place. */
377
+ export const INSTALL_SCRIPT = `set -euo pipefail
378
+ v=${CLI_PROXY_VERSION}; dir=${HUB_DIR}; bin="$dir/bin/cli-proxy-api-$v"
379
+ if [ -x "$bin" ]; then echo present; exit 0; fi
380
+ case "$(uname -m)" in
381
+ x86_64|amd64) asset=${CLI_PROXY_ASSETS.x86_64.asset}; sum=${CLI_PROXY_ASSETS.x86_64.sha256} ;;
382
+ aarch64|arm64) asset=${CLI_PROXY_ASSETS.aarch64.asset}; sum=${CLI_PROXY_ASSETS.aarch64.sha256} ;;
383
+ *) echo "unsupported architecture $(uname -m)" >&2; exit 3 ;;
384
+ esac
385
+ mkdir -p "$dir/bin"
386
+ tmp=$(mktemp -d "$dir/.install.XXXXXX"); trap 'rm -rf "$tmp"' EXIT
387
+ curl -fsSL --retry 3 --connect-timeout 10 -o "$tmp/release.tar.gz" "https://github.com/router-for-me/CLIProxyAPI/releases/download/v$v/CLIProxyAPI_\${v}_\${asset}.tar.gz"
388
+ echo "$sum $tmp/release.tar.gz" | sha256sum -c --quiet - >&2 || { echo "the CLIProxyAPI $v download does not match its pinned SHA-256" >&2; exit 4; }
389
+ tar -xzf "$tmp/release.tar.gz" -C "$tmp" cli-proxy-api
390
+ chmod 0755 "$tmp/cli-proxy-api"
391
+ mv "$tmp/cli-proxy-api" "$bin"
392
+ echo installed
393
+ `;
394
+ /**
395
+ * Ends what an earlier supervisor of this hub left running (shared by run.sh and the stop script): its CLIProxyAPI
396
+ * (8317), its VM port (sf-agent cli-proxy-filter, 8318), and the supervisor and its loop. Matched by the argv of
397
+ * processes of this user, never by text in a command line (an exec'd script contains these words), never this shell.
398
+ * TERM, up to 5 s, then KILL.
399
+ */
400
+ const CLEAR_STALE = `clear_stale() {
401
+ local p i a found=() left=()
402
+ for p in /proc/[0-9]*; do
403
+ p=\${p#/proc/}
404
+ [ "$p" != "$$" ] && [ -O "/proc/$p" ] || continue
405
+ a=(); mapfile -d '' -t a 2>/dev/null < "/proc/$p/cmdline" || continue
406
+ [ "\${#a[@]}" -gt 0 ] || continue
407
+ if [[ "\${a[0]}" == "$dir/bin/cli-proxy-api"* ]] || { [[ "\${a[0]}" == */bash || "\${a[0]}" == bash ]] && [ "\${a[1]-}" = "$dir/run.sh" ]; }; then
408
+ found+=("$p"); continue
409
+ fi
410
+ if [[ "\${a[0]}" == *sf-agent ]]; then
411
+ for ((i = 1; i + 1 < \${#a[@]}; i++)); do
412
+ if [ "\${a[i]}" = cli-proxy-filter ] && [ "\${a[i+1]}" = --listen ]; then found+=("$p"); break; fi
413
+ done
414
+ fi
415
+ done
416
+ [ "\${#found[@]}" -gt 0 ] || return 0
417
+ kill -TERM "\${found[@]}" 2>/dev/null || true
418
+ for ((i = 0; i < 50; i++)); do
419
+ left=()
420
+ for p in "\${found[@]}"; do
421
+ a=(); mapfile -d '' -t a 2>/dev/null < "/proc/$p/cmdline" || continue
422
+ [ "\${#a[@]}" -gt 0 ] && left+=("$p")
423
+ done
424
+ [ "\${#left[@]}" -gt 0 ] || return 0
425
+ sleep 0.1
426
+ done
427
+ kill -KILL "\${left[@]}" 2>/dev/null || true
428
+ return 0
429
+ }`;
430
+ /**
431
+ * The supervisor (setsid, its own process group): runs CLIProxyAPI in the foreground and starts it again 2 s after it
432
+ * exits. The management key is read from its 0600 file into MANAGEMENT_PASSWORD (never in the config or argv); logs go
433
+ * to rotating files under the hub directory (WRITABLE_PATH). First it ends whatever an earlier supervisor that died
434
+ * alone left running, which would hold 8317 and 8318.
435
+ */
436
+ export const RUN_SCRIPT = `#!/bin/bash
437
+ set -u
438
+ dir=\${CLI_PROXY_HUB_DIR:-${HUB_DIR}}
439
+ ${CLEAR_STALE}
440
+ clear_stale
441
+ echo $$ > "$dir/run.pid"
442
+ cd "$dir"
443
+ # Its own session: stopping it ends the whole group (CLIProxyAPI and the VM port).
444
+ trap 'trap - TERM INT; kill -TERM 0 2>/dev/null; exit 0' TERM INT
445
+ # The VM port (0.0.0.0:${CLI_PROXY_VM_PORT}): CLIProxyAPI without its management API, served by sf-agent (the
446
+ # template's, else one placed in the hub's bin).
447
+ (
448
+ while :; do
449
+ filter=
450
+ for b in /usr/local/bin/sf-agent "$dir/bin/sf-agent"; do
451
+ if [ -x "$b" ] && "$b" cli-proxy-filter --probe >/dev/null 2>&1; then filter=$b; break; fi
452
+ done
453
+ if [ -z "$filter" ]; then sleep 300; continue; fi
454
+ "$filter" cli-proxy-filter --listen 0.0.0.0:${CLI_PROXY_VM_PORT} --upstream 127.0.0.1:${CLI_PROXY_PORT}
455
+ echo "$(date -u +%FT%TZ) the VM port exited with status $?; starting it again in 2 s"
456
+ sleep 2
457
+ done
458
+ ) &
459
+ while :; do
460
+ MANAGEMENT_PASSWORD="$(cat "$dir/management.key")" WRITABLE_PATH="$dir" "$dir/bin/cli-proxy-api-${CLI_PROXY_VERSION}" -config "$dir/config.yaml" &
461
+ child=$!
462
+ wait "$child"; code=$?
463
+ echo "$(date -u +%FT%TZ) cli-proxy-api exited with status $code; starting it again in 2 s"
464
+ sleep 2
465
+ done
466
+ `;
467
+ /**
468
+ * Starts the supervisor unless it runs and the hub answers /healthz (serialized by a lock in the hub, so two devices
469
+ * ensuring at once never start two copies). $1=1 restarts it (a new binary, run script or management key).
470
+ * Prints `running`, `started` or `restarted`.
471
+ *
472
+ * The codex-connector template's `cli-proxy-hub` service (images/templates/codex-connector/start-cli-proxy-hub) starts
473
+ * the same run.sh at boot under the same lock and run.pid rules; a template without it (version 3 and earlier) relies
474
+ * on this script alone. run.pid counts only while that pid's command line is run.sh: after a cold boot the old pid
475
+ * may belong to any process, which must be neither waited for nor signalled.
476
+ */
477
+ export const START_SCRIPT = `set -euo pipefail
478
+ dir=${HUB_DIR}; restart=\${1:-0}
479
+ exec 9>"$dir/start.lock"; flock -w 90 9 || { echo "another start of CLIProxyAPI is still running in the hub" >&2; exit 6; }
480
+ healthy() { curl -fsS -m 2 -o /dev/null http://127.0.0.1:${CLI_PROXY_PORT}/healthz 2>/dev/null; }
481
+ # The second line says whether the VM port answers (it starts a moment after CLIProxyAPI).
482
+ vmport() { for _ in $(seq 25); do if curl -fsS -m 1 -o /dev/null http://127.0.0.1:${CLI_PROXY_VM_PORT}/healthz 2>/dev/null; then echo "vm-port yes"; return; fi; sleep 0.2; done; echo "vm-port no"; }
483
+ alive() {
484
+ [ -s "$dir/run.pid" ] || return 1
485
+ pid=$(cat "$dir/run.pid")
486
+ [[ "$pid" =~ ^[0-9]+$ ]] || return 1
487
+ cmd=$(tr '\\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") || return 1
488
+ [[ "$cmd" == *"$dir/run.sh"* ]]
489
+ }
490
+ stop() {
491
+ if alive; then
492
+ pid=$(cat "$dir/run.pid"); kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
493
+ for _ in $(seq 50); do alive || break; sleep 0.1; done
494
+ if alive; then kill -KILL -- "-$pid" 2>/dev/null || true; fi
495
+ fi
496
+ rm -f "$dir/run.pid"
497
+ }
498
+ state=started
499
+ if [ "$restart" = 1 ] && alive; then stop; state=restarted; fi
500
+ if alive; then
501
+ for _ in $(seq 100); do if healthy; then echo running; vmport; exit 0; fi; sleep 0.2; done
502
+ stop; state=restarted
503
+ fi
504
+ if [ -f "$dir/supervisor.log" ] && [ "$(stat -c %s "$dir/supervisor.log")" -gt 1048576 ]; then : > "$dir/supervisor.log"; fi
505
+ # The supervisor must not inherit the lock (fd 9), or the next start would wait for it forever.
506
+ setsid "$dir/run.sh" 9>&- >>"$dir/supervisor.log" 2>&1 </dev/null &
507
+ for _ in $(seq 150); do if healthy; then echo "$state"; vmport; exit 0; fi; sleep 0.2; done
508
+ echo "CLIProxyAPI did not answer on port ${CLI_PROXY_PORT} within 30 s" >&2
509
+ tail -n 5 "$dir/supervisor.log" >&2 2>/dev/null || true
510
+ exit 5
511
+ `;
512
+ /**
513
+ * Stops CLIProxyAPI in the hub for `shard cli-proxy export` (under the start lock): the supervisor and anything it
514
+ * started end (CLIProxyAPI and the VM port, also when an earlier supervisor left them running), run.sh is set aside
515
+ * (the boot service stays idle) and `exported` marks the hub, so no forwarder starts it again until the next import.
516
+ * Prints `stopped`.
517
+ */
518
+ export const STOP_SCRIPT = `set -euo pipefail
519
+ dir=\${CLI_PROXY_HUB_DIR:-${HUB_DIR}}
520
+ ${CLEAR_STALE}
521
+ exec 9>"$dir/start.lock"; flock -w 90 9 || { echo "a start of CLIProxyAPI is still running in the hub" >&2; exit 6; }
522
+ alive() {
523
+ [ -s "$dir/run.pid" ] || return 1
524
+ pid=$(cat "$dir/run.pid")
525
+ [[ "$pid" =~ ^[0-9]+$ ]] || return 1
526
+ cmd=$(tr '\\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") || return 1
527
+ [[ "$cmd" == *"$dir/run.sh"* ]]
528
+ }
529
+ date -u +%FT%TZ > "$dir/exported"
530
+ if [ -f "$dir/run.sh" ]; then mv "$dir/run.sh" "$dir/run.sh.exported"; fi
531
+ if alive; then
532
+ kill -TERM -- "-$pid" 2>/dev/null || true
533
+ for _ in $(seq 50); do alive || break; sleep 0.1; done
534
+ if alive; then kill -KILL -- "-$pid" 2>/dev/null || true; fi
535
+ fi
536
+ rm -f "$dir/run.pid"
537
+ clear_stale
538
+ for _ in $(seq 50); do
539
+ if ! curl -fsS -m 1 -o /dev/null http://127.0.0.1:${CLI_PROXY_PORT}/healthz 2>/dev/null && ! curl -fsS -m 1 -o /dev/null http://127.0.0.1:${CLI_PROXY_VM_PORT}/healthz 2>/dev/null; then echo stopped; exit 0; fi
540
+ sleep 0.1
541
+ done
542
+ echo "CLIProxyAPI still answers in the hub" >&2
543
+ exit 5
544
+ `;
545
+ /**
546
+ * The hub's management key, created once: under key.lock, a missing or malformed management.key becomes the key on
547
+ * standard input (0600, atomic). Prints `created` when it wrote one, then the key in use.
548
+ */
549
+ export const KEY_SCRIPT = `set -euo pipefail
550
+ dir=${HUB_DIR}; mkdir -p "$dir"
551
+ exec 7>"$dir/key.lock"; flock -w 60 7 || { echo "another device is setting up the hub's key" >&2; exit 6; }
552
+ IFS= read -r proposed || true
553
+ if ! grep -qxE '[0-9a-f]{64}' "$dir/management.key" 2>/dev/null; then
554
+ [[ "$proposed" =~ ^[0-9a-f]{64}$ ]] || { echo "no valid key was proposed" >&2; exit 7; }
555
+ umask 077; printf '%s\\n' "$proposed" > "$dir/management.key.tmp"; mv "$dir/management.key.tmp" "$dir/management.key"
556
+ echo created
557
+ fi
558
+ cat "$dir/management.key"
559
+ `;
560
+ /**
561
+ * Adds one auth file to the hub only when that path is free (a hard link cannot replace a file), so two devices
562
+ * importing at once never overwrite each other's login or the hub's newer tokens. $1 is the path relative to the auth
563
+ * directory, the content comes on standard input. Prints `added` or `exists`.
564
+ */
565
+ export const ADD_AUTH_SCRIPT = `set -euo pipefail
566
+ a=${HUB_AUTH_DIR}; rel=$1
567
+ case "$rel" in ''|/*|*..*) echo "not a path inside the auth folder" >&2; exit 2 ;; esac
568
+ mkdir -p -m 0700 "$(dirname "$a/$rel")"
569
+ tmp=$(mktemp "$a/.adding.XXXXXX"); chmod 0600 "$tmp"; cat > "$tmp"
570
+ if ln "$tmp" "$a/$rel" 2>/dev/null; then echo added; else echo exists; fi
571
+ rm -f "$tmp"
572
+ `;
573
+ /** Sets the hub's logins aside after an export copied them out (the hub holds none until the next import). */
574
+ export const SET_ASIDE_SCRIPT = `set -euo pipefail
575
+ a=${HUB_AUTH_DIR}; [ -d "$a" ] || exit 0
576
+ to="$a.exported-$(date -u +%Y%m%dT%H%M%SZ)"
577
+ mv "$a" "$to"; mkdir -m 0700 "$a"; chmod 0700 "$to"
578
+ echo "$to"
579
+ `;
580
+ /** The hub could not be made ready (exit 1, code `cli_proxy_hub`). */
581
+ export class HubError extends CodedError {
582
+ name = 'HubError';
583
+ constructor(message, hint) {
584
+ super('cli_proxy_hub', message, hint);
585
+ }
586
+ }
587
+ /**
588
+ * Makes sure the account's hub exists, runs the pinned CLIProxyAPI on port 8317 and has a callback URL, and saves the
589
+ * hub info locally (0600) and in the hub. Idempotent: a running, healthy hub with a valid callback URL is left as it is.
590
+ */
591
+ export async function ensureCliProxyHub(cloud, opts) {
592
+ const hubCloud = 'workspaces' in cloud ? sdkHubCloud(cloud) : cloud;
593
+ const progress = opts.progress ?? (() => { });
594
+ const local = readHubInfo(opts.dir, opts.apiUrl);
595
+ progress('Opening the CLIProxyAPI hub…');
596
+ let opened = await hubCloud.open(!opts.repairOnly);
597
+ if (!opened)
598
+ throw new HubError('This account has no hosted CLIProxyAPI yet.', 'move yours: shard cli-proxy import');
599
+ // A hub on an older template is upgraded before VMs get its address (a plain repair only starts it again).
600
+ const template = opened.template;
601
+ if (!opened.created && (!opts.repairOnly || opts.renewInvalid) && hubCloud.upgrade && template?.version != null && template.version < HUB_TEMPLATE_VERSION && (template.upgradeTo ?? 0) >= HUB_TEMPLATE_VERSION) {
602
+ progress('Updating the CLIProxyAPI hub…');
603
+ await hubCloud.upgrade(opened.ws.id);
604
+ opened = await hubCloud.open(false);
605
+ if (!opened)
606
+ throw new HubError('The hub was deleted during its update.', 'run: shard cli-proxy import');
607
+ }
608
+ const { ws, created } = opened;
609
+ // After `shard cli-proxy export` the logins live on a computer again: only an import brings the hub back.
610
+ if ((await ws.read(`${HUB_DIR}/exported`)) !== null) {
611
+ if (opts.repairOnly)
612
+ throw new HubError('Your logins were moved back to a computer (shard cli-proxy export), so the hosted CLIProxyAPI stays stopped.', 'move them to the hub again: shard cli-proxy import');
613
+ await ws.sh(`rm -f ${HUB_DIR}/exported ${HUB_DIR}/run.sh.exported`);
614
+ }
615
+ const installed = await ws.sh(INSTALL_SCRIPT, [], { timeoutMs: 300_000 });
616
+ if (installed.code !== 0)
617
+ throw new HubError(`Could not install CLIProxyAPI ${CLI_PROXY_VERSION} in the hub: ${installed.stderr.trim().split('\n').at(-1) || `exit ${installed.code}`}`);
618
+ let restart = installed.stdout.trim() === 'installed' && !created;
619
+ // The management key: the hub's own file is the source of truth, created once under a hub-side lock (two devices
620
+ // ensuring a new hub at once must not each write their own).
621
+ const proposed = local?.workspace_id === ws.id && /^[0-9a-f]{64}$/.test(local.management_key) ? local.management_key : (opts.randomKey ?? (() => randomBytes(32).toString('hex')))();
622
+ const keyed = await ws.sh(KEY_SCRIPT, [], { stdin: `${proposed}\n` });
623
+ const keyLines = keyed.stdout.trim().split('\n');
624
+ const managementKey = keyLines.at(-1)?.trim() ?? '';
625
+ if (keyed.code !== 0 || !/^[0-9a-f]{64}$/.test(managementKey))
626
+ throw new HubError(`The hub did not keep its management key: ${keyed.stderr.trim().split('\n').at(-1) || `exit ${keyed.code}`}`);
627
+ if (keyLines[0] === 'created')
628
+ restart = true;
629
+ if ((await ws.read(`${HUB_DIR}/run.sh`)) !== RUN_SCRIPT) {
630
+ await ws.write(`${HUB_DIR}/run.sh`, RUN_SCRIPT, '0700');
631
+ restart = true;
632
+ }
633
+ // The hub's config is made once, from the first user config it gets: a second computer's import never replaces it
634
+ // (its client keys, routing and upstream keys stay the hub's). config.source says where it came from.
635
+ let config = null;
636
+ const configPath = `${HUB_DIR}/config.yaml`, sourcePath = `${HUB_DIR}/config.source`;
637
+ const hasConfig = (await ws.read(configPath)) !== null;
638
+ const source = hasConfig ? ((await ws.read(sourcePath))?.trim() ?? 'user') : null;
639
+ const fromUser = opts.userConfig !== undefined && opts.userConfig !== null;
640
+ if (!hasConfig || (source === 'default' && fromUser)) {
641
+ config = hubConfig(opts.userConfig ?? null);
642
+ // A running hub reloads its config by itself.
643
+ await ws.write(configPath, config.text, '0600');
644
+ await ws.write(sourcePath, fromUser ? 'user\n' : 'default\n', '0600');
645
+ }
646
+ if ((await ws.list(HUB_AUTH_DIR)) === null)
647
+ await ws.sh(`mkdir -p -m 0700 ${HUB_AUTH_DIR}`);
648
+ const started = await ws.sh(START_SCRIPT, [restart ? '1' : '0'], { timeoutMs: 180_000 });
649
+ const startLines = started.stdout.trim().split('\n');
650
+ const state = startLines.find((l) => ['running', 'started', 'restarted'].includes(l));
651
+ if (started.code !== 0 || !state) {
652
+ throw new HubError(`CLIProxyAPI did not start in the hub: ${started.stderr.trim().split('\n').at(0) || `exit ${started.code}`}`);
653
+ }
654
+ if (state !== 'running')
655
+ progress(state === 'started' ? 'CLIProxyAPI is running in the hub' : 'CLIProxyAPI started again in the hub');
656
+ // The management port's callback URL (this computer only) and, when the hub serves it, the VM port's (for VMs).
657
+ const probe = !opts.repairOnly || opts.renewInvalid ? (opts.probe ?? hubCloud.probe) : undefined;
658
+ const repairOnly = !!opts.repairOnly && !opts.renewInvalid;
659
+ const { callback, replaced: callbackReplaced } = await hubCallback(ws, repairOnly, CLI_PROXY_PORT, probe);
660
+ const vmPort = startLines.includes('vm-port yes') ? (await hubCallback(ws, repairOnly, CLI_PROXY_VM_PORT, probe)).callback : null;
661
+ const info = {
662
+ version: 1, api_url: opts.apiUrl, workspace_id: ws.id, callback_url: callback.callback_url, callback_created_at: callback.callback_created_at, management_key: managementKey,
663
+ ...(vmPort ? { vm_callback_url: vmPort.callback_url, vm_callback_created_at: vmPort.callback_created_at } : {}),
664
+ };
665
+ if (!local || hubInfoText(local) !== hubInfoText(info))
666
+ writeHubInfo(opts.dir, info);
667
+ return { info, created, process: state, callbackReplaced, config };
668
+ }
669
+ /**
670
+ * The hub's one callback URL. Under the hub's callback lock: the stored one is kept while the port's callback still
671
+ * has its creation time; otherwise one is created and stored before the lock is released. Every device and every
672
+ * import reads the stored one, so concurrent imports never replace each other's URL (and the VMs' copy).
673
+ */
674
+ export async function hubCallback(ws, repairOnly = false, portNumber = CLI_PROXY_PORT, probe) {
675
+ let replaced = false;
676
+ const callback = await ws.withCallbackLock(async (stored) => {
677
+ const port = await ws.exposePort(portNumber);
678
+ const current = stored !== null && port.callbackCreatedAt !== null && stored.callback_created_at === port.callbackCreatedAt;
679
+ // The port still lists it, but the edge may refuse it (its API key was revoked): a probe tells.
680
+ if (current && stored && (!probe || (await probe(stored.callback_url)) !== 'invalid'))
681
+ return stored;
682
+ if (repairOnly)
683
+ throw new HubError('The hub’s callback URL changed.', 'connect this computer again: shard cli-proxy import');
684
+ const fresh = await ws.createCallbackUrl(portNumber);
685
+ replaced = port.callbackCreatedAt !== null;
686
+ return { callback_url: fresh.url, callback_created_at: fresh.createdAt };
687
+ }, callbackFile(portNumber));
688
+ return { callback, replaced };
689
+ }
690
+ /**
691
+ * Whether the edge still accepts a callback URL: `invalid` only for its own 401 `callback_invalid` refusal; any other
692
+ * answer (CLIProxyAPI's, a waking or starting hub) means the URL itself works. `unknown` when nothing answered.
693
+ */
694
+ export async function probeCallback(url, timeoutMs = 45_000) {
695
+ try {
696
+ const res = await fetch(new URL('healthz', url), { signal: AbortSignal.timeout(timeoutMs) });
697
+ await res.arrayBuffer().catch(() => undefined);
698
+ return res.status === 401 && res.headers.get('x-shardflux-reason') === 'callback_invalid' ? 'invalid' : 'ok';
699
+ }
700
+ catch {
701
+ return 'unknown';
702
+ }
703
+ }
704
+ /** Where the hub stores a port's callback URL: callback.json (8317, management), callback-vm.json (8318, VMs). */
705
+ export function callbackFile(port) {
706
+ return port === CLI_PROXY_VM_PORT ? 'callback-vm.json' : 'callback.json';
707
+ }
708
+ /**
709
+ * Holds the hub's callback lock (flock on callback.lock, in the hub) while the client decides: prints the stored
710
+ * callback (base64 JSON, or -), then reads one line: KEEP, ABORT, or NEW <base64 JSON>, which it stores atomically.
711
+ */
712
+ export const CALLBACK_SCRIPT = `set -euo pipefail
713
+ dir=${HUB_DIR}; mkdir -p "$dir"; f=\${1:-callback.json}
714
+ case "$f" in callback.json|callback-vm.json) ;; *) echo "unknown callback file" >&2; exit 2 ;; esac
715
+ exec 8>"$dir/callback.lock"
716
+ flock -w 120 8 || { echo "another device is setting up the hub's callback URL" >&2; exit 6; }
717
+ if [ -s "$dir/$f" ]; then printf 'STORED %s\\n' "$(base64 -w0 < "$dir/$f")"; else echo 'STORED -'; fi
718
+ IFS= read -r -t 150 reply || { echo "no answer from the device" >&2; exit 7; }
719
+ case "$reply" in
720
+ KEEP|ABORT) exit 0 ;;
721
+ NEW\\ *) umask 077; printf '%s' "\${reply#NEW }" | base64 -d > "$dir/$f.tmp"; mv "$dir/$f.tmp" "$dir/$f"; echo SAVED ;;
722
+ *) echo "unexpected answer" >&2; exit 8 ;;
723
+ esac
724
+ `;
725
+ /**
726
+ * Holds a hub-side lock (flock on <name>.lock) while the device works: prints LOCKED, then waits for a line (or the
727
+ * end of standard input) and releases it.
728
+ */
729
+ export const LOCK_SCRIPT = `set -euo pipefail
730
+ dir=${HUB_DIR}; mkdir -p "$dir"; name=\${1:?}
731
+ [[ "$name" =~ ^[a-z-]+$ ]] || { echo "bad lock name" >&2; exit 2; }
732
+ exec 8>"$dir/$name.lock"
733
+ flock -w 120 8 || { echo "another device holds the hub's $name lock" >&2; exit 6; }
734
+ echo LOCKED
735
+ IFS= read -r -t 600 _ || true
736
+ echo RELEASED
737
+ `;
738
+ export function parseStoredCallback(text) {
739
+ let v;
740
+ try {
741
+ v = JSON.parse(text);
742
+ }
743
+ catch {
744
+ return null;
745
+ }
746
+ const o = (v && typeof v === 'object' ? v : {});
747
+ return typeof o.callback_url === 'string' && typeof o.callback_created_at === 'string' && o.callback_url && o.callback_created_at
748
+ ? { callback_url: o.callback_url, callback_created_at: o.callback_created_at } : null;
749
+ }