@shardflux/cli 0.16.0 → 0.18.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -2
- package/README.md +47 -8
- package/dist/agent-commands.d.ts +9 -1
- package/dist/agent-commands.js +65 -16
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +6 -1
- package/dist/agent-target-commands.js +113 -16
- package/dist/agent-targets.d.ts +17 -5
- package/dist/agent-targets.js +27 -11
- package/dist/args.d.ts +5 -1
- package/dist/args.js +49 -9
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +2 -1
- package/dist/codex-cloud.js +16 -6
- package/dist/command-center-actions.js +16 -1
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +45 -6
- package/dist/conversations.d.ts +18 -2
- package/dist/conversations.js +12 -5
- package/dist/desktop-linux.d.ts +40 -0
- package/dist/desktop-linux.js +186 -0
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +75 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +5 -2
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +1 -1
- package/dist/git-remote-bin.js +0 -0
- package/dist/handlers.js +3 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +30 -6
- package/dist/menubar.js +44 -4
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +59 -9
- package/dist/t3-asar.d.ts +10 -0
- package/dist/t3-asar.js +91 -0
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-linux.d.ts +68 -0
- package/dist/t3-linux.js +343 -0
- package/dist/t3-mode.d.ts +129 -0
- package/dist/t3-mode.js +676 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-provider.d.ts +5 -3
- package/dist/t3-provider.js +12 -49
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +2 -2
- package/dist/thread-on-computer.js +13 -7
- package/dist/update.d.ts +1 -1
- package/dist/update.js +28 -1
- package/dist/worktree-twin-engine.d.ts +31 -1
- package/dist/worktree-twin-engine.js +253 -40
- package/dist/worktree-twin.d.ts +17 -0
- package/dist/worktree-twin.js +147 -22
- package/package.json +7 -19
|
@@ -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
|
+
}
|