@platless/axom-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Default cap for one session's journal. Past this, the oldest half is dropped (see {@link SessionJournal.droppedOldest}). */
|
|
4
|
+
export const DEFAULT_JOURNAL_MAX_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
function serializeEntry(entry) {
|
|
6
|
+
return `${JSON.stringify({ seq: entry.seq, frame: entry.frame })}\n`;
|
|
7
|
+
}
|
|
8
|
+
function parseEntry(line) {
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(line);
|
|
11
|
+
if (typeof parsed?.seq !== 'number' || !Number.isFinite(parsed.seq))
|
|
12
|
+
return null;
|
|
13
|
+
if (typeof parsed.frame !== 'object' || parsed.frame === null)
|
|
14
|
+
return null;
|
|
15
|
+
return { seq: parsed.seq, frame: parsed.frame };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isEnoent(err) {
|
|
22
|
+
return err?.code === 'ENOENT';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Append-only record of everything one external agent session has streamed to the server, plus the
|
|
26
|
+
* session's seq allocator.
|
|
27
|
+
*
|
|
28
|
+
* It exists because the tunnel is not reliable: the websocket can drop mid-turn and the daemon can
|
|
29
|
+
* restart while the native agent keeps running. Every outbound `agent_event` / `agent_turn_done` is
|
|
30
|
+
* written here *before* it is sent, so on reconnect the server can say "I have applied through N"
|
|
31
|
+
* and get everything after N replayed verbatim. The server acks with `agent_event_ack`, which is
|
|
32
|
+
* what keeps the file from growing without bound in the steady state.
|
|
33
|
+
*
|
|
34
|
+
* **Scope of the guarantee: process crash, not power loss.** Writes go through `appendFile` /
|
|
35
|
+
* `writeFile` with no `fsync`, so an entry that has returned from {@link append} survives the daemon
|
|
36
|
+
* being killed, but bytes still sitting in the OS page cache can be lost to a kernel panic or a
|
|
37
|
+
* power cut. That is the right trade for streamed agent output — an fsync per event would dominate
|
|
38
|
+
* the cost of the event — and the failure mode degrades to the same seq gap a dropped socket causes.
|
|
39
|
+
*
|
|
40
|
+
* The seq allocator lives here rather than in the manager for the daemon-restart case: seq must not
|
|
41
|
+
* restart at 1 when the process comes back, or the server's `seq > bridgeSeq` de-duplication would
|
|
42
|
+
* silently swallow the whole next turn. {@link init} recovers the high-water mark from disk *and*
|
|
43
|
+
* takes the reopening server's watermark as a floor (the file can be empty precisely because
|
|
44
|
+
* everything in it was acked), and {@link nextSeq} continues from there.
|
|
45
|
+
*
|
|
46
|
+
* File layout: `<dir>/<sessionRef>.jsonl`, one `{seq, frame}` JSON object per line. `dir` is the
|
|
47
|
+
* directory that *directly* holds the files — `AgentSessionManager` passes its `sessionsDir`
|
|
48
|
+
* (`<journalDir>/sessions`). The file is created on the first {@link append}, not by {@link init},
|
|
49
|
+
* so a session that opens and is abandoned before producing anything leaves nothing behind.
|
|
50
|
+
*
|
|
51
|
+
* Every public method is serialized on an internal queue: a rewrite (ack truncation, overflow drop)
|
|
52
|
+
* must never interleave with an append, and callers hand work in from unordered bridge callbacks.
|
|
53
|
+
*/
|
|
54
|
+
export class SessionJournal {
|
|
55
|
+
/** Absolute path of the `.jsonl`. Exposed for logging and tests; nothing else should read it directly. */
|
|
56
|
+
filePath;
|
|
57
|
+
dir;
|
|
58
|
+
tmpPath;
|
|
59
|
+
maxBytes;
|
|
60
|
+
maxSeq = 0;
|
|
61
|
+
/** Byte size of the file as of the last write; kept in memory so append does not stat every time. */
|
|
62
|
+
bytes = 0;
|
|
63
|
+
dropped = false;
|
|
64
|
+
ready = false;
|
|
65
|
+
queue = Promise.resolve();
|
|
66
|
+
constructor(dir, sessionRef, maxBytes = DEFAULT_JOURNAL_MAX_BYTES) {
|
|
67
|
+
this.dir = dir;
|
|
68
|
+
// sessionRefs are server-supplied strings, not filenames: percent-encoding keeps '/' and '..'
|
|
69
|
+
// from steering the write out of the journal directory, and keeps the mapping reversible.
|
|
70
|
+
this.filePath = path.join(dir, `${encodeURIComponent(sessionRef)}.jsonl`);
|
|
71
|
+
this.tmpPath = `${this.filePath}.tmp`;
|
|
72
|
+
this.maxBytes = maxBytes;
|
|
73
|
+
}
|
|
74
|
+
/** True once an overflow has discarded entries. The manager turns this into a visible gap notice on resume. */
|
|
75
|
+
get droppedOldest() {
|
|
76
|
+
return this.dropped;
|
|
77
|
+
}
|
|
78
|
+
/** Highest seq ever handed out or recovered. `0` means nothing has been journaled for this session. */
|
|
79
|
+
get lastSeq() {
|
|
80
|
+
return this.maxSeq;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Creates the journal directory and recovers `maxSeq` (and the byte size) from any existing file.
|
|
84
|
+
* Must be awaited before {@link nextSeq} / {@link append}; both throw otherwise, because a missed
|
|
85
|
+
* init would restart seq at 1 and desynchronize the server's watermark.
|
|
86
|
+
*
|
|
87
|
+
* `resumeFromSeq` is the server's watermark from the `agent_open` that is (re)creating this
|
|
88
|
+
* session, and it is a **floor** under the recovered value — never a ceiling. The file alone is
|
|
89
|
+
* not a sufficient source for the allocator: acked entries are truncated away, so between turns
|
|
90
|
+
* the journal is legitimately empty. A daemon restart in that window recovers `maxSeq = 0` while
|
|
91
|
+
* the server is still at N, `nextSeq()` starts back at 1, and every event the session produces
|
|
92
|
+
* from then on is `<= lastSeenSeq` on the server — silently swallowed, no error, no recovery
|
|
93
|
+
* path, the run hangs forever. Seeding from the watermark closes that hole without asking the
|
|
94
|
+
* server to lower anything (its watermark is advance-only at the storage layer, deliberately).
|
|
95
|
+
*
|
|
96
|
+
* A trailing line torn by a crash mid-append is dropped and the file rewritten, so a later append
|
|
97
|
+
* cannot glue new bytes onto half a record.
|
|
98
|
+
*/
|
|
99
|
+
async init(resumeFromSeq = 0) {
|
|
100
|
+
await this.serialize(async () => {
|
|
101
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
102
|
+
const { entries, bytes, clean } = await this.readEntries();
|
|
103
|
+
const recovered = entries.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
104
|
+
this.maxSeq = Math.max(recovered, resumeFromSeq);
|
|
105
|
+
this.bytes = clean ? bytes : await this.rewrite(entries);
|
|
106
|
+
this.ready = true;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Next seq for this session. Synchronous by design: bridge callbacks must number events in arrival order. */
|
|
110
|
+
nextSeq() {
|
|
111
|
+
this.requireReady();
|
|
112
|
+
this.maxSeq += 1;
|
|
113
|
+
return this.maxSeq;
|
|
114
|
+
}
|
|
115
|
+
/** Writes the entry to disk. Resolves only once the bytes are handed to the OS — callers send after this. */
|
|
116
|
+
async append(entry) {
|
|
117
|
+
await this.serialize(async () => {
|
|
118
|
+
this.requireReady();
|
|
119
|
+
const line = serializeEntry(entry);
|
|
120
|
+
const size = Buffer.byteLength(line);
|
|
121
|
+
if (this.bytes + size > this.maxBytes)
|
|
122
|
+
await this.dropOldestHalf(size);
|
|
123
|
+
await fs.appendFile(this.filePath, line);
|
|
124
|
+
this.bytes += size;
|
|
125
|
+
if (entry.seq > this.maxSeq)
|
|
126
|
+
this.maxSeq = entry.seq;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Everything the server has not applied yet, in append order. `seq` is the server's watermark.
|
|
131
|
+
*
|
|
132
|
+
* This is the one method that parses the whole file, so it is O(file) in both time and peak
|
|
133
|
+
* memory. That is affordable only because it runs on the reconnect path — once per `agent_open` —
|
|
134
|
+
* and never on the append path. Keep it that way.
|
|
135
|
+
*/
|
|
136
|
+
async entriesAfter(seq) {
|
|
137
|
+
return this.serialize(async () => {
|
|
138
|
+
const { entries } = await this.readEntries();
|
|
139
|
+
return entries.filter((entry) => entry.seq > seq);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Forgets everything the server has acknowledged (`seq <= throughSeq`) by rewriting the file.
|
|
144
|
+
* `maxSeq` is deliberately untouched: acking is not a reason to reissue seqs.
|
|
145
|
+
*/
|
|
146
|
+
async truncateThrough(seq) {
|
|
147
|
+
await this.serialize(async () => {
|
|
148
|
+
if (seq <= 0)
|
|
149
|
+
return;
|
|
150
|
+
const { entries } = await this.readEntries();
|
|
151
|
+
const kept = entries.filter((entry) => entry.seq > seq);
|
|
152
|
+
if (kept.length === entries.length)
|
|
153
|
+
return; // nothing acked that we still hold; don't churn the file
|
|
154
|
+
this.bytes = await this.rewrite(kept);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Removes the file for good (agent_close). The journal is unusable afterwards. */
|
|
158
|
+
async destroy() {
|
|
159
|
+
await this.serialize(async () => {
|
|
160
|
+
await fs.rm(this.filePath, { force: true });
|
|
161
|
+
await fs.rm(this.tmpPath, { force: true });
|
|
162
|
+
this.bytes = 0;
|
|
163
|
+
this.ready = false;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// --- internals (never call from inside another serialized op except via the private helpers) ---
|
|
167
|
+
requireReady() {
|
|
168
|
+
if (!this.ready) {
|
|
169
|
+
throw new Error(`SessionJournal ${this.filePath} used before init() (or after destroy())`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Chains `op` after whatever is already queued — including a failed predecessor, so one bad write
|
|
174
|
+
* does not wedge the session forever.
|
|
175
|
+
*/
|
|
176
|
+
serialize(op) {
|
|
177
|
+
const run = this.queue.then(op, op);
|
|
178
|
+
this.queue = run.then(() => undefined, () => undefined);
|
|
179
|
+
return run;
|
|
180
|
+
}
|
|
181
|
+
/** `clean: false` means at least one line failed to parse and the file should be rewritten. */
|
|
182
|
+
async readEntries() {
|
|
183
|
+
let text;
|
|
184
|
+
try {
|
|
185
|
+
text = await fs.readFile(this.filePath, 'utf8');
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (isEnoent(err))
|
|
189
|
+
return { entries: [], bytes: 0, clean: true };
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
const entries = [];
|
|
193
|
+
let clean = true;
|
|
194
|
+
for (const line of text.split('\n')) {
|
|
195
|
+
if (line.length === 0)
|
|
196
|
+
continue;
|
|
197
|
+
const entry = parseEntry(line);
|
|
198
|
+
if (entry)
|
|
199
|
+
entries.push(entry);
|
|
200
|
+
else
|
|
201
|
+
clean = false;
|
|
202
|
+
}
|
|
203
|
+
return { entries, bytes: Buffer.byteLength(text), clean };
|
|
204
|
+
}
|
|
205
|
+
/** Replaces the file with exactly `entries`, atomically. Returns the new byte size. */
|
|
206
|
+
async rewrite(entries) {
|
|
207
|
+
return this.rewriteRaw(Buffer.from(entries.map(serializeEntry).join('')));
|
|
208
|
+
}
|
|
209
|
+
/** Replaces the file with `data`, atomically. Returns the new byte size. */
|
|
210
|
+
async rewriteRaw(data) {
|
|
211
|
+
await fs.writeFile(this.tmpPath, data);
|
|
212
|
+
await fs.rename(this.tmpPath, this.filePath);
|
|
213
|
+
return data.length;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Makes room for `incoming` bytes by discarding the oldest half, repeating until it fits. Dropping
|
|
217
|
+
* the *oldest* is the only safe direction: the newest output is what the user is watching, and the
|
|
218
|
+
* server can be told about the hole (a synthetic gap event) but cannot invent the missing bytes.
|
|
219
|
+
*
|
|
220
|
+
* This runs on the **append path**, so it deliberately never parses or re-serializes a record. It
|
|
221
|
+
* reads the file once as raw bytes, walks to a newline boundary past the half-way mark, and writes
|
|
222
|
+
* back a slice of that same buffer. At the 64MB cap that is one 64MB read plus one ≤32MB write —
|
|
223
|
+
* versus the several-hundred-MB transient and multi-second single-threaded stall an entry-level
|
|
224
|
+
* round-trip would cost, on a daemon that is also serving `exec_request` on the same loop.
|
|
225
|
+
*
|
|
226
|
+
* Halving by *bytes* rather than by record count is the same policy in a cheaper coordinate: the
|
|
227
|
+
* point is to reclaim about half the file in one pass so the trim is amortized over many appends.
|
|
228
|
+
*/
|
|
229
|
+
async dropOldestHalf(incoming) {
|
|
230
|
+
let buf;
|
|
231
|
+
try {
|
|
232
|
+
buf = await fs.readFile(this.filePath);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (isEnoent(err)) {
|
|
236
|
+
this.bytes = 0; // tracked size was stale; nothing on disk to drop
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
let start = 0;
|
|
242
|
+
while (start < buf.length && buf.length - start + incoming > this.maxBytes) {
|
|
243
|
+
// Cut at the first record boundary at or after the half-way point of what is left. Every line
|
|
244
|
+
// ends in '\n', so the newline search can only land on a real boundary; if there is none left
|
|
245
|
+
// the remainder is a single oversized record and we drop all of it.
|
|
246
|
+
const half = start + Math.ceil((buf.length - start) / 2);
|
|
247
|
+
const newline = buf.indexOf(0x0a, half);
|
|
248
|
+
start = newline === -1 ? buf.length : newline + 1;
|
|
249
|
+
}
|
|
250
|
+
// Nothing to drop means a single record larger than the whole cap; write it anyway rather than
|
|
251
|
+
// lose it, and let the next append trim back down.
|
|
252
|
+
if (start === 0)
|
|
253
|
+
return;
|
|
254
|
+
this.dropped = true;
|
|
255
|
+
this.bytes = await this.rewriteRaw(buf.subarray(start));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Sanitize a raw string to the workstation/workspace name charset `[A-Za-z0-9._-]`.
|
|
2
|
+
* Non-matching chars → '-'. Empty / '.' / '..' → fallback. */
|
|
3
|
+
export function sanitizeName(raw, fallback) {
|
|
4
|
+
const cleaned = (raw ?? '').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
5
|
+
if (cleaned === '' || cleaned === '.' || cleaned === '..')
|
|
6
|
+
return fallback;
|
|
7
|
+
return cleaned;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Picks the workstation's registered name: an explicit `--name` flag, then `AXOM_WORKSTATION_NAME`
|
|
11
|
+
* (a blank value in either counts as unset), then the host name — sanitized by `sanitizeName`,
|
|
12
|
+
* which alone owns the character-set and empty/dot handling.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveWorkstationName(flag, env, host) {
|
|
15
|
+
const fromEnv = env.AXOM_WORKSTATION_NAME?.trim() || undefined;
|
|
16
|
+
return sanitizeName(flag?.trim() || fromEnv || host, 'client');
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Opens the authorization page when this host has a graphical session. The caller always prints
|
|
4
|
+
* the URL first, so a missing opener (or a failed opener) is never an authorization blocker.
|
|
5
|
+
*/
|
|
6
|
+
export function tryOpenAuthorizationUrl(url, deps = {}) {
|
|
7
|
+
const platform = deps.platform ?? process.platform;
|
|
8
|
+
const env = deps.env ?? process.env;
|
|
9
|
+
const command = platform === 'darwin'
|
|
10
|
+
? 'open'
|
|
11
|
+
: platform === 'linux' && (env.DISPLAY || env.WAYLAND_DISPLAY)
|
|
12
|
+
? 'xdg-open'
|
|
13
|
+
: undefined;
|
|
14
|
+
if (!command)
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
const child = (deps.spawn ?? nodeSpawn)(command, [url], { stdio: 'ignore', detached: true });
|
|
18
|
+
child.once?.('error', () => undefined);
|
|
19
|
+
child.unref?.();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// The URL was already printed. Browser launch is a convenience only.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { RetryPolicy } from '../connection/retryPolicy.js';
|
|
3
|
+
import { tryOpenAuthorizationUrl } from './browser.js';
|
|
4
|
+
import { loadDeviceId } from '../../config.js';
|
|
5
|
+
export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
|
+
export class DeviceAuthorizationError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
9
|
+
function normalizedServer(server) {
|
|
10
|
+
const normalized = server.trim().replace(/\/+$/, '');
|
|
11
|
+
if (!normalized)
|
|
12
|
+
throw new DeviceAuthorizationError('Axomwork server URL is required');
|
|
13
|
+
try {
|
|
14
|
+
new URL(normalized);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new DeviceAuthorizationError('Axomwork server URL is invalid');
|
|
18
|
+
}
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
async function requestJson(fetch_, url, init, acceptedStatus, description) {
|
|
22
|
+
let response;
|
|
23
|
+
try {
|
|
24
|
+
response = await fetch_(url, init);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new DeviceAuthorizationError(`Failed to reach ${url.origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
28
|
+
}
|
|
29
|
+
if (response.status !== acceptedStatus)
|
|
30
|
+
throw new DeviceAuthorizationError(`${description} failed: ${response.status}`);
|
|
31
|
+
try {
|
|
32
|
+
return await response.json();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new DeviceAuthorizationError(`${description} returned invalid JSON`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function requireString(value, field, description) {
|
|
39
|
+
if (typeof value !== 'string' || !value)
|
|
40
|
+
throw new DeviceAuthorizationError(`${description} missing ${field}`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function websocketUrl(server) {
|
|
44
|
+
const url = new URL('/oauth/device/ws', server);
|
|
45
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
46
|
+
return url.toString();
|
|
47
|
+
}
|
|
48
|
+
function readStatus(data) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(typeof data === 'string' ? data : String(data));
|
|
51
|
+
return parsed.status === 'pending' || parsed.status === 'approved' || parsed.status === 'denied' || parsed.status === 'expired'
|
|
52
|
+
? parsed.status
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function waitForNotification(socket, sessionId, notificationTicket, deadline, now, onOpen) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let settled = false;
|
|
62
|
+
const timer = setTimeout(() => finish('expired'), Math.max(0, deadline - now()));
|
|
63
|
+
const finish = (status) => {
|
|
64
|
+
if (settled)
|
|
65
|
+
return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
if (status !== 'reconnect')
|
|
69
|
+
socket.close();
|
|
70
|
+
resolve(status);
|
|
71
|
+
};
|
|
72
|
+
socket.on('open', () => {
|
|
73
|
+
onOpen();
|
|
74
|
+
try {
|
|
75
|
+
socket.send(JSON.stringify({ session_id: sessionId, notification_ticket: notificationTicket }));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
finish('reconnect');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
socket.on('message', (data) => {
|
|
82
|
+
const status = readStatus(data);
|
|
83
|
+
if (status && status !== 'pending')
|
|
84
|
+
finish(status);
|
|
85
|
+
});
|
|
86
|
+
socket.on('close', () => finish('reconnect'));
|
|
87
|
+
socket.on('error', () => finish('reconnect'));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function exchangeDeviceCode(server, clientId, deviceCode, fetch_) {
|
|
91
|
+
const response = await requestJson(fetch_, new URL('/oauth/token', server), {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
94
|
+
body: new URLSearchParams({
|
|
95
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
96
|
+
client_id: clientId,
|
|
97
|
+
device_code: deviceCode,
|
|
98
|
+
device_id: await loadDeviceId(),
|
|
99
|
+
}),
|
|
100
|
+
}, 200, 'Device token exchange');
|
|
101
|
+
const accessToken = requireString(response.access_token, 'access_token', 'Device token response');
|
|
102
|
+
const refreshToken = requireString(response.refresh_token, 'refresh_token', 'Device token response');
|
|
103
|
+
if (!Number.isFinite(response.expires_in) || response.expires_in <= 0) {
|
|
104
|
+
throw new DeviceAuthorizationError('Device token response has invalid expires_in');
|
|
105
|
+
}
|
|
106
|
+
return { accessToken, refreshToken, expiresInSeconds: response.expires_in };
|
|
107
|
+
}
|
|
108
|
+
async function pollDeviceCode(server, clientId, deviceCode, fetch_) {
|
|
109
|
+
const response = await fetch_(new URL('/oauth/token', server), {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
112
|
+
body: new URLSearchParams({
|
|
113
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
114
|
+
client_id: clientId,
|
|
115
|
+
device_code: deviceCode,
|
|
116
|
+
device_id: await loadDeviceId(),
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
if (response.status === 400) {
|
|
120
|
+
const body = await response.json().catch(() => ({}));
|
|
121
|
+
if (body.error === 'authorization_pending' || body.error === 'slow_down')
|
|
122
|
+
return undefined;
|
|
123
|
+
if (body.error === 'access_denied')
|
|
124
|
+
throw new DeviceAuthorizationError('Device authorization was denied');
|
|
125
|
+
if (body.error === 'expired_token')
|
|
126
|
+
throw new DeviceAuthorizationError('Device authorization expired');
|
|
127
|
+
}
|
|
128
|
+
if (response.status !== 200)
|
|
129
|
+
throw new DeviceAuthorizationError(`Device token polling failed: ${response.status}`);
|
|
130
|
+
const token = await response.json();
|
|
131
|
+
return {
|
|
132
|
+
accessToken: requireString(token.access_token, 'access_token', 'Device token response'),
|
|
133
|
+
refreshToken: requireString(token.refresh_token, 'refresh_token', 'Device token response'),
|
|
134
|
+
expiresInSeconds: token.expires_in,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Uses OAuth Device Authorization for both desktop and headless clients. The notification socket
|
|
139
|
+
* only carries state; the tokens exist only in this call's return value.
|
|
140
|
+
*/
|
|
141
|
+
export async function authorizeDevice(server, clientName, hostname, deps = {}) {
|
|
142
|
+
const baseUrl = normalizedServer(server);
|
|
143
|
+
const fetch_ = deps.fetch ?? globalThis.fetch;
|
|
144
|
+
const print = deps.print ?? console.log;
|
|
145
|
+
const openBrowser = deps.openBrowser ?? tryOpenAuthorizationUrl;
|
|
146
|
+
const createWebSocket = deps.createWebSocket ?? ((url) => new WebSocket(url));
|
|
147
|
+
const now = deps.now ?? Date.now;
|
|
148
|
+
const wait = deps.sleep ?? sleep;
|
|
149
|
+
void clientName;
|
|
150
|
+
const clientId = 'axom-cli';
|
|
151
|
+
const authorization = await requestJson(fetch_, new URL('/oauth/device_authorization', baseUrl), {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
154
|
+
body: new URLSearchParams({ client_id: clientId, hostname }),
|
|
155
|
+
}, 200, 'Device authorization request');
|
|
156
|
+
const deviceCode = requireString(authorization.device_code, 'device_code', 'Device authorization response');
|
|
157
|
+
const verificationUrl = requireString(authorization.verification_uri_complete, 'verification_uri_complete', 'Device authorization response');
|
|
158
|
+
const sessionId = requireString(authorization.session_id, 'session_id', 'Device authorization response');
|
|
159
|
+
const notificationTicket = requireString(authorization.notification_ticket, 'notification_ticket', 'Device authorization response');
|
|
160
|
+
if (!Number.isFinite(authorization.expires_in) || authorization.expires_in <= 0) {
|
|
161
|
+
throw new DeviceAuthorizationError('Device authorization response has invalid expires_in');
|
|
162
|
+
}
|
|
163
|
+
print(`Open this URL to authorize: ${verificationUrl}`);
|
|
164
|
+
openBrowser(verificationUrl);
|
|
165
|
+
const deadline = now() + authorization.expires_in * 1_000;
|
|
166
|
+
const retry = new RetryPolicy();
|
|
167
|
+
while (now() < deadline) {
|
|
168
|
+
let socket;
|
|
169
|
+
try {
|
|
170
|
+
socket = createWebSocket(websocketUrl(baseUrl));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
const delay = Math.min(retry.recordFailure(), Math.max(0, deadline - now()));
|
|
174
|
+
if (delay === 0)
|
|
175
|
+
break;
|
|
176
|
+
await wait(delay);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const status = await waitForNotification(socket, sessionId, notificationTicket, deadline, now, () => retry.recordSuccess());
|
|
180
|
+
if (status === 'approved') {
|
|
181
|
+
const tokens = await exchangeDeviceCode(baseUrl, clientId, deviceCode, fetch_);
|
|
182
|
+
return { clientId, ...tokens };
|
|
183
|
+
}
|
|
184
|
+
if (status === 'denied')
|
|
185
|
+
throw new DeviceAuthorizationError('Device authorization was denied');
|
|
186
|
+
if (status === 'expired')
|
|
187
|
+
break;
|
|
188
|
+
const polled = await pollDeviceCode(baseUrl, clientId, deviceCode, fetch_);
|
|
189
|
+
if (polled)
|
|
190
|
+
return { clientId, ...polled };
|
|
191
|
+
const pollingFloor = Math.max(1, authorization.interval ?? 5) * 1_000;
|
|
192
|
+
const delay = Math.min(Math.max(retry.recordFailure(), pollingFloor), Math.max(0, deadline - now()));
|
|
193
|
+
if (delay === 0)
|
|
194
|
+
break;
|
|
195
|
+
await wait(delay);
|
|
196
|
+
}
|
|
197
|
+
throw new DeviceAuthorizationError('Device authorization expired');
|
|
198
|
+
}
|