@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.
Files changed (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,171 @@
1
+ import { hostname } from 'node:os';
2
+ import { basename } from 'node:path';
3
+ import { loadConfig, resolveServer, UsageError } from '../config.js';
4
+ import { accessTokenFor } from '../credentials.js';
5
+ import { ensureFreshToken } from '../mcp.js';
6
+ import { runCommand } from '../output.js';
7
+ import { clientDir, loadClientConfig } from '../workstation/config.js';
8
+ import { resolveWorkstationName, sanitizeName } from '../workstation/names.js';
9
+ /**
10
+ * Registers this workstation with the server if it has no credential yet, then leaves it alone.
11
+ * Returns whether it just registered (`false` when an existing credential meant there was nothing
12
+ * to do), so `start` can gate `install()` on that — see its action below for why that gate matters.
13
+ *
14
+ * Shared by `start` (which then hands off to the OS service) and the bare `axom workstation`
15
+ * command (which runs the daemon directly): both need the exact same credential chain, so it lives
16
+ * here once rather than twice.
17
+ *
18
+ * An existing workstation credential boots directly — the common case, and the only sane one for a
19
+ * container that gets recreated on every restart: re-registering each boot would rotate keys for no
20
+ * reason. Otherwise a usable access token registers this machine — `AXOM_ACCESS_TOKEN` (set by a
21
+ * container with no human and no browser) or a stored user token (from `axom auth`), refreshing it
22
+ * first if it's stale. With neither available, an interactive terminal falls back to the device
23
+ * flow; a headless one gets a hard stop naming the fix.
24
+ */
25
+ async function ensureRegistered(deps, serverFlag, nameFlag) {
26
+ const registered = await loadClientConfig(clientDir()).then(() => true, () => false);
27
+ if (registered)
28
+ return false;
29
+ const config = await loadConfig();
30
+ const server = resolveServer(config, serverFlag);
31
+ let token = await accessTokenFor(server);
32
+ if (!token) {
33
+ if (!deps.isInteractive()) {
34
+ throw new UsageError('No credential for this workstation. Set AXOM_ACCESS_TOKEN (create one under Settings → Security → ' +
35
+ 'Access Tokens), or run `axom auth` on a machine with a browser.');
36
+ }
37
+ await deps.authenticate(server);
38
+ token = await ensureFreshToken(server);
39
+ }
40
+ const cwd = process.cwd();
41
+ await deps.registerWorkstation(server, resolveWorkstationName(nameFlag, process.env, hostname()), token, {
42
+ name: sanitizeName(basename(cwd), 'default'),
43
+ cwd,
44
+ shell: process.env.SHELL ?? '/bin/bash',
45
+ });
46
+ return true;
47
+ }
48
+ /**
49
+ * Removes `AXOM_ACCESS_TOKEN` from *this process's* copy of the environment, right before the bare
50
+ * `axom workstation` command becomes a long-lived daemon.
51
+ *
52
+ * The daemon spawns agents and shell commands with `{ ...process.env, ... }` (exec.ts, acpClient.ts),
53
+ * so anything still in here reaches every model-authored command and every dependency postinstall
54
+ * that runs in `/workspace` — a container is precisely where this product runs untrusted code. What
55
+ * would leak is not the workstation's own key pair (scoped to one workstation, on disk) but a
56
+ * credential that authenticates *as the user*: it can register further workstations under their
57
+ * account and, with the `mcp` scope, call every MCP tool as them, until somebody notices and revokes
58
+ * it. Fixed once here rather than filtered at each of the two spawn sites, so the next spawn site
59
+ * inherits the fix instead of having to remember it.
60
+ *
61
+ * Nothing after this point needs the value. The daemon authenticates with the file credential
62
+ * written at registration (`clientKey`/`serverPub`), and the `container` update lifecycle downloads
63
+ * its release over that same authenticated management channel.
64
+ *
65
+ * Unconditional, not gated on "we just registered": a container that still has its `/data/ws` volume
66
+ * boots straight past `ensureRegistered`, and that is exactly the long-lived container this protects.
67
+ *
68
+ * The token stays in the *container's* environment, so a restarted container still registers — only
69
+ * this process's copy is dropped. Every reader of it lives before this line: `accessTokenFor`
70
+ * (credentials.ts) reads `process.env.AXOM_ACCESS_TOKEN` on its first line, and its only caller here
71
+ * is `ensureRegistered` above; `insufficientScopeError`/`envAccessTokenRejectedError` (mcp.ts) read
72
+ * it too, but only from the one-shot `axom execute`/`axom struct` CLI processes, which are separate
73
+ * processes with their own untouched copy.
74
+ *
75
+ * `workstation start` deliberately does not call this: it registers and hands off to launchd/systemd
76
+ * rather than becoming the daemon, so dropping the variable would affect only a CLI process that is
77
+ * about to exit.
78
+ */
79
+ function dropAccessTokenFromEnvironment() {
80
+ delete process.env.AXOM_ACCESS_TOKEN;
81
+ }
82
+ /**
83
+ * Registers `axom workstation start|stop|restart|status|install|uninstall`, the bare
84
+ * `axom workstation` (Docker ENTRYPOINT / debugging foreground run), and the hidden top-level
85
+ * `axom __daemon`.
86
+ *
87
+ * `__daemon` is registered on `program` itself, not on the `workstation` subcommand group: the
88
+ * generated launchd/systemd service definitions invoke it as `<cliPath> __daemon`, a bare
89
+ * top-level argument (see service/launchd.ts and service/systemd.ts).
90
+ */
91
+ export function registerWorkstationCommands(program, deps) {
92
+ const workstation = program.command('workstation').description('Manage the local Axomwork workstation daemon');
93
+ // Same idiom as `registerUpdateCommand`: the global `--server` is read at action time, not at
94
+ // registration time, so `axom --server <url> workstation start` works. `axom auth` only writes
95
+ // `defaultServer` when it is unset, so a user who authenticated against localhost first and
96
+ // production second would otherwise have no way to keep `start` off localhost.
97
+ const serverOption = () => program.opts().server;
98
+ // `workstation` (this group) and its `start` subcommand both declare `--server`/`--name` — the
99
+ // group needs its own copy so the *bare* command accepts them, and `start` keeps its copy for its
100
+ // own `--help`. But for `axom workstation start --server/--name <value>`, only one of the two
101
+ // declarations ever actually captures the flag, and it's not always the same one: `program`
102
+ // parses its own options over the *entire* remaining argv before `workstation` gets a look, so
103
+ // `--server` — also declared on `program`, globally — is always captured there, leaving both
104
+ // `options.server` and `workstationOption().server` dead; `--name` has no `program`-level
105
+ // declaration, so it falls through to `workstation`, which *does* capture it, leaving `start`'s
106
+ // own `options.name` the dead one instead. `start`'s action below reads the whole fallback chain
107
+ // so it doesn't matter which link is live for a given flag.
108
+ const workstationOption = () => workstation.opts();
109
+ workstation
110
+ .command('start')
111
+ .option('--server <url>', 'Axomwork server URL to register this workstation against')
112
+ .option('--name <name>', 'Workstation name (default: AXOM_WORKSTATION_NAME, then the hostname)')
113
+ .description('Start the workstation user service')
114
+ .action((options) => runCommand(async () => {
115
+ // `install()` runs only when this call just registered the workstation, not on every
116
+ // `start` — it's not the free idempotent no-op it looks like. On macOS it's bootout, write
117
+ // plist, bootstrap, and the `deps.start()` right after it is `kickstart -k`: on an
118
+ // already-registered machine that's two restart windows where there used to be one, plus a
119
+ // bootout window with a documented stranding mode (`launchdStuck`: the service is not
120
+ // registered with launchd right now, so nothing will start it). It would also re-pin the
121
+ // service's `node` execPath to whatever ran this `start` — which repairs a machine stranded
122
+ // by a removed nvm version only if that's a *good* node; run from an ad-hoc one and it
123
+ // manufactures the exact stranding it's meant to cure. That repair belongs to the explicit
124
+ // verb that already exists for it, `axom workstation install`, not as a side effect here.
125
+ const justRegistered = await ensureRegistered(deps, options.server ?? workstationOption().server ?? serverOption(), options.name ?? workstationOption().name);
126
+ if (justRegistered)
127
+ await deps.install();
128
+ await deps.start();
129
+ }));
130
+ workstation
131
+ .command('stop')
132
+ .description('Stop the workstation user service')
133
+ .action(() => runCommand(async () => { await deps.stop(); }));
134
+ workstation
135
+ .command('restart')
136
+ .description('Restart the workstation user service')
137
+ .action(() => runCommand(async () => { await deps.restart(); }));
138
+ workstation
139
+ .command('status')
140
+ .description('Report whether the workstation daemon is running')
141
+ .action(() => runCommand(async () => { deps.log(await deps.status()); }));
142
+ workstation
143
+ .command('install')
144
+ .description('Install and enable the operating-system service')
145
+ .action(() => runCommand(async () => { await deps.install(); }));
146
+ workstation
147
+ .command('uninstall')
148
+ .description('Disable and remove the operating-system service')
149
+ .action(() => runCommand(async () => { await deps.uninstall(); }));
150
+ workstation
151
+ .option('--server <url>', 'Axomwork server URL to register against (default: AXOM_SERVER, then the stored default)')
152
+ .option('--name <name>', 'Workstation name (default: AXOM_WORKSTATION_NAME, then the hostname)')
153
+ .description('Run the workstation daemon in the foreground (Docker ENTRYPOINT / debugging); subcommands manage the OS service')
154
+ .action((options) => runCommand(async () => {
155
+ await ensureRegistered(deps, options.server ?? serverOption(), options.name);
156
+ dropAccessTokenFromEnvironment();
157
+ await deps.runDaemon({ supervision: deps.detectSupervision() });
158
+ }));
159
+ program.command('__daemon', { hidden: true }).action(() => runCommand(async () => {
160
+ // The literal exec target launchd/systemd start: the one call site allowed to opt into
161
+ // OS-service treatment. See RunDaemonOptions in daemon.ts for why this must be an explicit
162
+ // flag rather than "no `supervision` was passed".
163
+ //
164
+ // Same reasoning as the bare command's call: this process is about to become the daemon that
165
+ // spawns agents and shell commands with its own environment. A service definition is not
166
+ // supposed to carry AXOM_ACCESS_TOKEN at all, which makes this usually a no-op — and exactly
167
+ // why it belongs here rather than being left to whoever writes the next plist.
168
+ dropAccessTokenFromEnvironment();
169
+ await deps.runDaemon({ supervisedByServiceManager: true });
170
+ }));
171
+ }
package/dist/config.js ADDED
@@ -0,0 +1,131 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ export class UsageError extends Error {
6
+ }
7
+ export class AuthError extends Error {
8
+ }
9
+ export function configPath() {
10
+ return path.join(configDir(), 'config.json');
11
+ }
12
+ function configDir() {
13
+ return process.env.AXOM_CONFIG_DIR ?? path.join(homedir(), '.axom');
14
+ }
15
+ /**
16
+ * This install's stable id, kept beside the config as `device-id`.
17
+ *
18
+ * The server keys one session row per (user, client, device), so this is what makes one machine one
19
+ * row on the Security page however often the CLI refreshes its token. It lives in the config
20
+ * directory rather than the keyring on purpose: it is not a secret, and AXOM_CONFIG_DIR already
21
+ * defines what "this install" means for everything else the CLI remembers.
22
+ *
23
+ * Never throws: an unwritable config directory yields a per-run id, which is worse for the session
24
+ * list but must not stop the CLI from authenticating.
25
+ */
26
+ export async function loadDeviceId() {
27
+ const file = path.join(configDir(), 'device-id');
28
+ try {
29
+ const existing = (await fs.readFile(file, 'utf8')).trim();
30
+ if (existing)
31
+ return existing;
32
+ }
33
+ catch (error) {
34
+ if (!(isNodeError(error) && error.code === 'ENOENT'))
35
+ return randomUUID();
36
+ }
37
+ const minted = randomUUID();
38
+ try {
39
+ await fs.mkdir(path.dirname(file), { recursive: true });
40
+ await fs.writeFile(file, `${minted}\n`, { mode: 0o600 });
41
+ }
42
+ catch {
43
+ // Unwritable config dir: the id cannot be remembered, but the caller still gets a usable one.
44
+ }
45
+ return minted;
46
+ }
47
+ export async function loadConfig() {
48
+ const file = configPath();
49
+ try {
50
+ const raw = await fs.readFile(file, 'utf8');
51
+ return parseConfig(JSON.parse(raw), file);
52
+ }
53
+ catch (error) {
54
+ if (isNodeError(error) && error.code === 'ENOENT')
55
+ return { servers: {} };
56
+ if (error instanceof SyntaxError) {
57
+ throw new Error(`Failed to parse Axomwork config at ${file}: ${error.message}`);
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+ const obsoleteCredentialKeys = new Set(['clientId', 'accessToken', 'refreshToken', 'expiresAt']);
63
+ function parseConfig(value, file) {
64
+ if (!isRecord(value))
65
+ throw invalidConfig(file, 'the root value must be an object');
66
+ const rootKeys = Object.keys(value);
67
+ const unknownRootKey = rootKeys.find((key) => key !== 'defaultServer' && key !== 'servers');
68
+ if (unknownRootKey)
69
+ throw invalidConfig(file, `unknown root key '${unknownRootKey}'`);
70
+ if (value.defaultServer !== undefined && typeof value.defaultServer !== 'string') {
71
+ throw invalidConfig(file, 'defaultServer must be a string');
72
+ }
73
+ if (!isRecord(value.servers))
74
+ throw invalidConfig(file, 'servers must be an object');
75
+ const servers = {};
76
+ for (const [server, entry] of Object.entries(value.servers)) {
77
+ if (!isRecord(entry))
78
+ throw invalidConfig(file, `server entry '${server}' must be an object`);
79
+ const obsoleteKey = Object.keys(entry).find((key) => obsoleteCredentialKeys.has(key));
80
+ if (obsoleteKey) {
81
+ throw new Error(`Unsupported credential format in Axomwork config at ${file}: server '${server}' contains obsolete key '${obsoleteKey}'. ` +
82
+ 'Remove that server entry and run axom auth again.');
83
+ }
84
+ const unknownKey = Object.keys(entry).find((key) => key !== 'authenticated');
85
+ if (unknownKey)
86
+ throw invalidConfig(file, `server entry '${server}' has unknown key '${unknownKey}'`);
87
+ if (entry.authenticated !== undefined && typeof entry.authenticated !== 'boolean') {
88
+ throw invalidConfig(file, `server entry '${server}'.authenticated must be a boolean`);
89
+ }
90
+ servers[server] = entry.authenticated === true ? { authenticated: true } : {};
91
+ }
92
+ return { defaultServer: value.defaultServer, servers };
93
+ }
94
+ function invalidConfig(file, reason) {
95
+ return new Error(`Invalid Axomwork config at ${file}: ${reason}.`);
96
+ }
97
+ export async function saveConfig(config) {
98
+ const file = configPath();
99
+ const safeConfig = {
100
+ defaultServer: config.defaultServer,
101
+ servers: Object.fromEntries(Object.entries(config.servers).map(([server, value]) => [
102
+ server,
103
+ value.authenticated === true ? { authenticated: true } : {},
104
+ ])),
105
+ };
106
+ await fs.mkdir(path.dirname(file), { recursive: true });
107
+ await fs.writeFile(file, `${JSON.stringify(safeConfig, null, 2)}\n`, { mode: 0o600 });
108
+ await fs.chmod(file, 0o600);
109
+ }
110
+ export function resolveServer(config, flag, env = process.env) {
111
+ // A blank AXOM_SERVER (unset in a container's env file, or literally "") counts as unset, not
112
+ // as a value that should win over the stored default.
113
+ const fromEnv = env.AXOM_SERVER?.trim() || undefined;
114
+ const selected = flag ?? fromEnv ?? config.defaultServer;
115
+ if (!selected) {
116
+ throw new UsageError('No Axomwork server configured. Pass --server <url>, set AXOM_SERVER, or run axom auth --server <url>.');
117
+ }
118
+ return normalizeServerUrl(selected);
119
+ }
120
+ function normalizeServerUrl(value) {
121
+ const trimmed = value.trim();
122
+ if (!trimmed)
123
+ throw new UsageError('Server URL must not be blank.');
124
+ return trimmed.replace(/\/+$/, '');
125
+ }
126
+ function isNodeError(error) {
127
+ return error instanceof Error && 'code' in error;
128
+ }
129
+ function isRecord(value) {
130
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
131
+ }
@@ -0,0 +1,340 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ const PROTOCOL_VERSION = 1;
8
+ const SERVICE_LABEL = 'com.platless.axom-credentials';
9
+ const MAX_MESSAGE_BYTES = 64 * 1024;
10
+ // This is one deadline for the initial request, service startup, retry sleeps, and every retry. A
11
+ // Keychain call may cold-start the Security framework or wait for macOS authorization, but the
12
+ // complete external-initialization boundary must still remain bounded.
13
+ const BROKER_OPERATION_TIMEOUT_MS = 30_000;
14
+ const PEER_DISCONNECT_CODES = new Set(['EPIPE', 'ECONNRESET']);
15
+ class BrokerOperationError extends Error {
16
+ }
17
+ const uid = () => process.getuid?.() ?? os.userInfo().uid;
18
+ const socketPath = () => `/tmp/${SERVICE_LABEL}.${uid()}.sock`;
19
+ const launchAgentPath = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
20
+ const stateDirectory = () => path.join(os.homedir(), 'Library', 'Caches', SERVICE_LABEL);
21
+ const cliPath = () => path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.js');
22
+ export async function requestCredentialBroker(request) {
23
+ if (process.platform !== 'darwin')
24
+ throw new Error('The Axomwork credential broker is available only on macOS.');
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(new Error('Axomwork credential broker operation timed out.')), BROKER_OPERATION_TIMEOUT_MS);
27
+ const brokerRequest = { version: PROTOCOL_VERSION, ...request };
28
+ try {
29
+ try {
30
+ return await sendRequest(brokerRequest, controller.signal);
31
+ }
32
+ catch (error) {
33
+ if (error instanceof BrokerOperationError)
34
+ throw error;
35
+ throwIfAborted(controller.signal);
36
+ await installAndStartBroker(controller.signal);
37
+ let lastError;
38
+ for (let attempt = 0; attempt < 20; attempt += 1) {
39
+ await abortableDelay(100, controller.signal);
40
+ try {
41
+ return await sendRequest(brokerRequest, controller.signal);
42
+ }
43
+ catch (retryError) {
44
+ if (retryError instanceof BrokerOperationError)
45
+ throw retryError;
46
+ throwIfAborted(controller.signal);
47
+ lastError = retryError;
48
+ }
49
+ }
50
+ throw new Error(`Axomwork could not connect to its macOS credential broker: ${message(lastError)}`);
51
+ }
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof BrokerOperationError))
55
+ throwIfAborted(controller.signal);
56
+ throw error;
57
+ }
58
+ finally {
59
+ clearTimeout(timeout);
60
+ }
61
+ }
62
+ export async function runCredentialBroker() {
63
+ if (process.platform !== 'darwin')
64
+ throw new Error('The Axomwork credential broker is available only on macOS.');
65
+ await fs.mkdir(stateDirectory(), { recursive: true, mode: 0o700 });
66
+ await removeStaleSocket();
67
+ const credentials = await import('./credentials.js');
68
+ const server = net.createServer((connection) => {
69
+ let raw = '';
70
+ let handled = false;
71
+ connection.on('error', (error) => {
72
+ // The credential operation may finish after its caller has gone away. That is not a broker
73
+ // failure; every other socket error still terminates the service through its normal error path.
74
+ if (!PEER_DISCONNECT_CODES.has(error.code ?? ''))
75
+ server.emit('error', error);
76
+ });
77
+ connection.setEncoding('utf8');
78
+ connection.on('data', (chunk) => {
79
+ raw += chunk;
80
+ if (Buffer.byteLength(raw) > MAX_MESSAGE_BYTES) {
81
+ connection.destroy(new Error('Credential broker request is too large.'));
82
+ return;
83
+ }
84
+ if (!handled && raw.includes('\n')) {
85
+ handled = true;
86
+ void handleRequest(raw, connection, credentials);
87
+ }
88
+ });
89
+ });
90
+ await new Promise((resolve, reject) => {
91
+ const onError = (error) => {
92
+ server.off('error', onError);
93
+ reject(error);
94
+ };
95
+ server.once('error', onError);
96
+ server.listen(socketPath(), () => {
97
+ server.off('error', onError);
98
+ resolve();
99
+ });
100
+ });
101
+ await fs.chmod(socketPath(), 0o600);
102
+ const shutdown = () => {
103
+ server.close(() => { void fs.rm(socketPath(), { force: true }).finally(() => process.exit(0)); });
104
+ };
105
+ process.once('SIGINT', shutdown);
106
+ process.once('SIGTERM', shutdown);
107
+ await new Promise((resolve, reject) => {
108
+ const onClose = () => {
109
+ server.off('error', onError);
110
+ resolve();
111
+ };
112
+ const onError = (error) => {
113
+ server.off('close', onClose);
114
+ reject(error);
115
+ };
116
+ server.once('close', onClose);
117
+ server.once('error', onError);
118
+ });
119
+ }
120
+ async function handleRequest(raw, connection, credentials) {
121
+ let response;
122
+ try {
123
+ const request = parseRequest(raw.slice(0, raw.indexOf('\n')));
124
+ if (request.action === 'save') {
125
+ await credentials.saveNativeTokens(request.server, request.tokens);
126
+ response = { ok: true, version: PROTOCOL_VERSION };
127
+ }
128
+ else if (request.action === 'access') {
129
+ const accessToken = await credentials.accessTokenFromNativeStore(request.server, request.forceRefresh);
130
+ response = { ok: true, accessToken, version: PROTOCOL_VERSION };
131
+ }
132
+ else if (request.action === 'revoke') {
133
+ const persisted = await credentials.loadNativeTokens(request.server);
134
+ if (persisted?.refreshToken)
135
+ await credentials.revokeRefreshToken(request.server, persisted.refreshToken);
136
+ await credentials.clearNativeTokens(request.server);
137
+ response = { ok: true, version: PROTOCOL_VERSION };
138
+ }
139
+ else {
140
+ await credentials.clearNativeTokens(request.server);
141
+ response = { ok: true, version: PROTOCOL_VERSION };
142
+ }
143
+ }
144
+ catch (error) {
145
+ response = { ok: false, error: message(error), version: PROTOCOL_VERSION };
146
+ }
147
+ connection.end(`${JSON.stringify(response)}\n`);
148
+ }
149
+ async function sendRequest(request, signal) {
150
+ throwIfAborted(signal);
151
+ await assertSafeSocket();
152
+ throwIfAborted(signal);
153
+ return new Promise((resolve, reject) => {
154
+ const connection = net.createConnection(socketPath());
155
+ let raw = '';
156
+ let settled = false;
157
+ const finish = (action) => {
158
+ if (settled)
159
+ return;
160
+ settled = true;
161
+ signal.removeEventListener('abort', onAbort);
162
+ action();
163
+ };
164
+ const onAbort = () => {
165
+ connection.destroy();
166
+ finish(() => reject(abortReason(signal)));
167
+ };
168
+ connection.setEncoding('utf8');
169
+ connection.once('connect', () => connection.write(`${JSON.stringify(request)}\n`));
170
+ connection.on('data', (chunk) => {
171
+ raw += chunk;
172
+ if (Buffer.byteLength(raw) > MAX_MESSAGE_BYTES)
173
+ connection.destroy(new Error('Credential broker response is too large.'));
174
+ });
175
+ connection.once('error', (error) => finish(() => reject(error)));
176
+ connection.once('end', () => {
177
+ finish(() => {
178
+ try {
179
+ const response = JSON.parse(raw);
180
+ if (response.version !== PROTOCOL_VERSION)
181
+ throw new Error('Credential broker protocol version mismatch.');
182
+ if (!response.ok)
183
+ throw new BrokerOperationError(response.error || 'Credential broker request failed.');
184
+ resolve(response);
185
+ }
186
+ catch (error) {
187
+ reject(error);
188
+ }
189
+ });
190
+ });
191
+ signal.addEventListener('abort', onAbort, { once: true });
192
+ if (signal.aborted)
193
+ onAbort();
194
+ });
195
+ }
196
+ async function assertSafeSocket() {
197
+ const stat = await fs.lstat(socketPath());
198
+ if (!stat.isSocket() || stat.uid !== uid() || (stat.mode & 0o077) !== 0) {
199
+ throw new Error('Axomwork credential broker socket has unsafe ownership or permissions.');
200
+ }
201
+ }
202
+ async function removeStaleSocket() {
203
+ try {
204
+ const stat = await fs.lstat(socketPath());
205
+ if (stat.uid !== uid())
206
+ throw new Error('Refusing to replace a credential broker socket owned by another user.');
207
+ await fs.rm(socketPath());
208
+ }
209
+ catch (error) {
210
+ if (error.code !== 'ENOENT')
211
+ throw error;
212
+ }
213
+ }
214
+ async function installAndStartBroker(signal) {
215
+ throwIfAborted(signal);
216
+ const agent = launchAgentPath();
217
+ await fs.mkdir(path.dirname(agent), { recursive: true });
218
+ throwIfAborted(signal);
219
+ await fs.mkdir(stateDirectory(), { recursive: true, mode: 0o700 });
220
+ throwIfAborted(signal);
221
+ const temporary = `${agent}.${process.pid}.tmp`;
222
+ await fs.writeFile(temporary, renderLaunchAgent(), { mode: 0o644 });
223
+ throwIfAborted(signal);
224
+ await fs.rename(temporary, agent);
225
+ throwIfAborted(signal);
226
+ const domain = `gui/${uid()}`;
227
+ const service = `${domain}/${SERVICE_LABEL}`;
228
+ try {
229
+ await runLaunchctl(['bootout', service], signal);
230
+ }
231
+ catch {
232
+ throwIfAborted(signal);
233
+ }
234
+ await abortableDelay(100, signal);
235
+ try {
236
+ await runLaunchctl(['bootstrap', domain, agent], signal);
237
+ }
238
+ catch (error) {
239
+ if (/Domain does not support specified action/i.test(message(error))) {
240
+ throw new Error('No macOS GUI login session is available. Log in at the console before using Keychain credentials from SSH.');
241
+ }
242
+ throw error;
243
+ }
244
+ }
245
+ async function runLaunchctl(args, signal) {
246
+ throwIfAborted(signal);
247
+ await new Promise((resolve, reject) => {
248
+ const child = spawn('/bin/launchctl', args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'] });
249
+ let stderr = '';
250
+ let settled = false;
251
+ const finish = (action) => {
252
+ if (settled)
253
+ return;
254
+ settled = true;
255
+ signal.removeEventListener('abort', onAbort);
256
+ action();
257
+ };
258
+ const onAbort = () => {
259
+ try {
260
+ child.kill('SIGKILL');
261
+ }
262
+ catch {
263
+ // The deadline result is authoritative even if the already-exiting child rejects the kill.
264
+ }
265
+ finish(() => reject(abortReason(signal)));
266
+ };
267
+ child.stderr.on('data', (chunk) => { if (stderr.length < 4096)
268
+ stderr += chunk.toString(); });
269
+ child.once('error', (error) => finish(() => reject(error)));
270
+ child.once('close', (code) => finish(() => code === 0
271
+ ? resolve()
272
+ : reject(new Error(`launchctl ${args[0]} failed (${code}): ${stderr.trim()}`))));
273
+ signal.addEventListener('abort', onAbort, { once: true });
274
+ if (signal.aborted)
275
+ onAbort();
276
+ });
277
+ }
278
+ async function abortableDelay(milliseconds, signal) {
279
+ throwIfAborted(signal);
280
+ await new Promise((resolve, reject) => {
281
+ let settled = false;
282
+ const timeout = setTimeout(() => finish(resolve), milliseconds);
283
+ const onAbort = () => finish(() => reject(abortReason(signal)));
284
+ const finish = (action) => {
285
+ if (settled)
286
+ return;
287
+ settled = true;
288
+ clearTimeout(timeout);
289
+ signal.removeEventListener('abort', onAbort);
290
+ action();
291
+ };
292
+ signal.addEventListener('abort', onAbort, { once: true });
293
+ if (signal.aborted)
294
+ onAbort();
295
+ });
296
+ }
297
+ function throwIfAborted(signal) {
298
+ if (signal.aborted)
299
+ throw abortReason(signal);
300
+ }
301
+ function abortReason(signal) {
302
+ return signal.reason instanceof Error ? signal.reason : new Error('Axomwork credential broker operation timed out.');
303
+ }
304
+ function renderLaunchAgent() {
305
+ const log = path.join(stateDirectory(), 'broker.log');
306
+ return `<?xml version="1.0" encoding="UTF-8"?>
307
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
308
+ <plist version="1.0"><dict>
309
+ <key>Label</key><string>${SERVICE_LABEL}</string>
310
+ <key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(cliPath())}</string><string>__credential-broker</string></array>
311
+ <key>EnvironmentVariables</key><dict><key>HOME</key><string>${xml(os.homedir())}</string></dict>
312
+ <key>StandardOutPath</key><string>${xml(log)}</string>
313
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
314
+ <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
315
+ </dict></plist>
316
+ `;
317
+ }
318
+ function parseRequest(raw) {
319
+ const request = JSON.parse(raw);
320
+ if (request.version !== PROTOCOL_VERSION)
321
+ throw new Error('Unsupported credential broker protocol version.');
322
+ if (request.action !== 'save' && request.action !== 'access' && request.action !== 'clear' && request.action !== 'revoke') {
323
+ throw new Error('Unsupported credential broker operation.');
324
+ }
325
+ if (typeof request.server !== 'string' || !request.server)
326
+ throw new Error('Credential broker request has no server.');
327
+ if (request.action === 'save') {
328
+ const tokens = request.tokens;
329
+ if (!tokens || typeof tokens.accessToken !== 'string' || typeof tokens.refreshToken !== 'string' || typeof tokens.expiresAt !== 'string') {
330
+ throw new Error('Credential broker save request has invalid tokens.');
331
+ }
332
+ }
333
+ return request;
334
+ }
335
+ function xml(value) {
336
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
337
+ }
338
+ function message(error) {
339
+ return error instanceof Error ? error.message : String(error);
340
+ }