@platless/axom-cli 0.5.9 → 0.6.36
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 +18 -5
- package/dist/axom-patch/cli/README.md +100 -12
- package/dist/axom-patch/cli/account-limits/generate.py +15 -12
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +20 -3
- package/dist/axom-patch/cli/claude-account-limits.json +9 -9
- package/dist/axom-patch/cli/codex-account-limits.json +11 -11
- package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +9 -9
- package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
- package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
- package/dist/axom-patch/cli/nightly-task.md +55 -13
- package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +33 -0
- package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1591 -0
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +360 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +13 -6
- package/dist/axom-patch/cli/qoder-foreground-output.md +14 -8
- package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
- package/dist/axom-patch/cli/qoder-steering.md +20 -25
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
- package/dist/axom-patch/cli/runtime.json +16 -18
- package/dist/axom-patch/cli/zcode-account-limits.json +10 -11
- package/dist/axom-patch/cli/zcode-models.json +1 -2
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +9 -10
- package/dist/cli.js +4 -36
- package/dist/commands/knowledge.js +47 -0
- package/dist/commands/update.js +65 -3
- package/dist/commands/workstation.js +73 -7
- package/dist/install/claude/commands.js +13 -13
- package/dist/mcp.js +1 -1
- package/dist/native/darwin-arm64/axom-quic +0 -0
- package/dist/workstation/acp/acpAgents.js +102 -98
- package/dist/workstation/acp/acpClient.js +26 -0
- package/dist/workstation/acp/builds/install/npm.js +99 -10
- package/dist/workstation/acp/initializeResponse.js +6 -1
- package/dist/workstation/acp/managedPatches.js +189 -25
- package/dist/workstation/acp/managedRuntime.js +314 -0
- package/dist/workstation/acp/patchAssets.js +40 -0
- package/dist/workstation/acp/processor/checkpoint.js +25 -34
- package/dist/workstation/acp/processor/control.js +5 -78
- package/dist/workstation/acp/processor/runtime.js +5 -81
- package/dist/workstation/acp/processor/session.js +51 -80
- package/dist/workstation/acp/processor/state.js +6 -13
- package/dist/workstation/acp/processor/step.js +3 -20
- package/dist/workstation/acp/processor/test/fixtureLog.js +9 -16
- package/dist/workstation/acp/processor/updates.js +131 -105
- package/dist/workstation/acp/processorSession.js +8 -172
- package/dist/workstation/acp/registry/catalog.js +65 -39
- package/dist/workstation/acp/registry/launch.js +16 -1
- package/dist/workstation/acp/switching.js +0 -3
- package/dist/workstation/acp/test/fakeAgent.js +13 -0
- package/dist/workstation/acp/test/keeperWorld.js +28 -0
- package/dist/workstation/acp/test/raw/sources.js +16 -130
- package/dist/workstation/acp/test/realAdapter.js +1 -47
- package/dist/workstation/acp/test/scriptAgent.js +11 -3
- package/dist/workstation/agentFlow.js +1 -1
- package/dist/workstation/agentSessions.js +38 -143
- package/dist/workstation/channels/management.js +95 -11
- package/dist/workstation/channels/url.js +1 -1
- package/dist/workstation/config.js +16 -4
- package/dist/workstation/connection/encryptedChannel.js +4 -1
- package/dist/workstation/control/bindingStore.js +200 -0
- package/dist/workstation/control/commands.js +124 -0
- package/dist/workstation/control/daemonIdentity.js +53 -0
- package/dist/workstation/control/enrollment.js +106 -0
- package/dist/workstation/control/inspect.js +71 -0
- package/dist/workstation/control/locks.js +63 -0
- package/dist/workstation/control/protocol.js +40 -0
- package/dist/workstation/control/restartGuard.js +46 -0
- package/dist/workstation/control/runtimeClient.js +97 -0
- package/dist/workstation/control/runtimeHost.js +148 -0
- package/dist/workstation/control/serverIdentity.js +35 -0
- package/dist/workstation/daemon.js +106 -6
- package/dist/workstation/daemonctl.js +77 -14
- package/dist/workstation/desktopControl/audio.js +99 -0
- package/dist/workstation/desktopControl/capabilities.js +92 -0
- package/dist/workstation/desktopControl/captureModes.js +9 -0
- package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
- package/dist/workstation/desktopControl/deviceSockets.js +246 -0
- package/dist/workstation/desktopControl/helperProcess.js +353 -0
- package/dist/workstation/desktopControl/lobby.js +129 -0
- package/dist/workstation/desktopControl/macProvider.js +155 -0
- package/dist/workstation/desktopControl/media.js +331 -0
- package/dist/workstation/desktopControl/nativeProvider.js +1018 -0
- package/dist/workstation/desktopControl/networkEvidence.js +43 -0
- package/dist/workstation/desktopControl/overview.js +58 -0
- package/dist/workstation/desktopControl/protocol.js +302 -0
- package/dist/workstation/desktopControl/quicMedia.js +240 -0
- package/dist/workstation/desktopControl/rateController.js +226 -0
- package/dist/workstation/desktopControl/supervisor.js +1681 -0
- package/dist/workstation/desktopControl/transport.js +27 -0
- package/dist/workstation/desktopControl/wallpaper.js +78 -0
- package/dist/workstation/files/credentials.js +142 -0
- package/dist/workstation/files/text.js +42 -8
- package/dist/workstation/keepers/kit/bundle.js +21 -17
- package/dist/workstation/keepers/kit/launcher.js +47 -7
- package/dist/workstation/keepers/kit/paths.js +2 -1
- package/dist/workstation/managedInstall.js +57 -0
- package/dist/workstation/protocol/agent.js +2 -15
- package/dist/workstation/protocol/management.js +93 -4
- package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
- package/dist/workstation/rd/artifact.js +119 -0
- package/dist/workstation/rd/control.js +119 -0
- package/dist/workstation/rd/display.js +29 -0
- package/dist/workstation/rd/provider.js +222 -0
- package/dist/workstation/rd/sessions.js +391 -0
- package/dist/workstation/rd/state.js +45 -0
- package/dist/workstation/rd/update.js +171 -0
- package/dist/workstation/rd/upkeep.js +38 -0
- package/dist/workstation/register.js +27 -37
- package/dist/workstation/service/installer.js +104 -13
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/storageMetrics.js +118 -0
- package/dist/workstation/systemMetrics.js +44 -92
- package/dist/workstation/toolchain.js +94 -0
- package/dist/workstation/tools/browser.js +28 -4
- package/dist/workstation/update/artifact.js +2 -1
- package/dist/workstation/update/globalInstall.js +116 -0
- package/dist/workstation/update/npmInstaller.js +49 -23
- package/dist/workstation/update/rdRollback.js +145 -0
- package/dist/workstation/update/runUpdate.js +3 -2
- package/dist/workstation/update/watchdog.js +16790 -334
- package/package.json +5 -5
- package/skills/axom/SKILL.md +11 -11
- package/skills/axom-definition/SKILL.md +3 -3
- package/skills/axom-platform/SKILL.md +3 -3
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -61
- package/dist/axom-patch/cli/qoder-steering.json +0 -52
- package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
- package/dist/commands/guide.js +0 -38
- package/dist/runtime-deps/qoder-sdk/package-lock.json +0 -1189
- package/dist/runtime-deps/qoder-sdk/package.json +0 -8
- package/dist/workstation/acp/delegationMcp.js +0 -24
- package/dist/workstation/acp/processor/sideChannel.js +0 -80
- package/dist/workstation/acp/qoderStartup.js +0 -45
- package/dist/workstation/acp/test/raw/qoderCaptures.js +0 -125
- package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -118
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -193
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +0 -372
- package/dist/workstation/qoderAdapter/callbackContext.js +0 -76
- package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
- package/dist/workstation/qoderAdapter/contract.js +0 -78
- package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -36
- package/dist/workstation/qoderAdapter/history.js +0 -85
- package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
- package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
- package/dist/workstation/qoderAdapter/main.js +0 -95
- package/dist/workstation/qoderAdapter/mapper.js +0 -117
- package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
- package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
- package/dist/workstation/qoderAdapter/permissions.js +0 -61
- package/dist/workstation/qoderAdapter/profile.js +0 -93
- package/dist/workstation/qoderAdapter/sdkDriver.js +0 -48
- package/dist/workstation/qoderAdapter/sdkRuntime.js +0 -93
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +0 -145
- package/dist/workstation/qoderAdapter/session.js +0 -319
- package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
- package/dist/workstation/qoderAdapter/subwork.js +0 -85
- package/dist/workstation/qoderAdapter/taskControl.js +0 -38
- package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
- package/dist/workstation/qoderAdapter/usage.js +0 -37
- /package/dist/workstation/{qoderAdapter/types.js → desktopControl/provider.js} +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { assertLockOwnership } from './locks.js';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { loadClientConfig } from '../config.js';
|
|
7
|
+
import { privateDirectory } from '../keepers/kit/paths.js';
|
|
8
|
+
import { bindingSchema, operationRecordSchema, LocalControlError } from './protocol.js';
|
|
9
|
+
import { normalizeWorkstationServer } from './serverIdentity.js';
|
|
10
|
+
const IDENTITY_FILES = ['client.key', 'server.pub', 'config.yml'];
|
|
11
|
+
const missing = (error) => error.code === 'ENOENT';
|
|
12
|
+
async function readPrivateFile(file) {
|
|
13
|
+
const stat = await fs.lstat(file);
|
|
14
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || stat.size > 1_048_576)
|
|
15
|
+
throw new Error('Unsafe workstation identity file');
|
|
16
|
+
return fs.readFile(file, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
async function exists(file) {
|
|
19
|
+
try {
|
|
20
|
+
await fs.lstat(file);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (missing(error))
|
|
25
|
+
return false;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function atomicWrite(file, value) {
|
|
30
|
+
assertLockOwnership();
|
|
31
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
34
|
+
try {
|
|
35
|
+
await handle.writeFile(value);
|
|
36
|
+
await handle.sync();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await handle.close();
|
|
40
|
+
}
|
|
41
|
+
await fs.rename(temporary, file);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await fs.rm(temporary, { force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function syncDirectory(directory) {
|
|
48
|
+
const handle = await fs.open(directory, 'r');
|
|
49
|
+
try {
|
|
50
|
+
await handle.sync();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function validateKeys(keys) {
|
|
57
|
+
for (const value of [keys.clientKey, keys.serverPub]) {
|
|
58
|
+
if (!/^[A-Za-z0-9+/]{43}=$/.test(value) || Buffer.from(value, 'base64').length !== 32 || Buffer.from(value, 'base64').toString('base64') !== value) {
|
|
59
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Workstation connection keys are invalid');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function bindingOf(config) {
|
|
64
|
+
return bindingSchema.parse({ server: normalizeWorkstationServer(config.server), workstationId: config.workstationId, ...(config.ownerUserId ? { ownerUserId: config.ownerUserId } : {}) });
|
|
65
|
+
}
|
|
66
|
+
/** Caller holds the shared operation lock for every mutating method. */
|
|
67
|
+
export class BindingStore {
|
|
68
|
+
directory;
|
|
69
|
+
pending;
|
|
70
|
+
constructor(directory) {
|
|
71
|
+
this.directory = directory;
|
|
72
|
+
this.pending = path.join(directory, 'identity.pending');
|
|
73
|
+
}
|
|
74
|
+
async inspect() {
|
|
75
|
+
const pending = await exists(this.pending);
|
|
76
|
+
const present = await Promise.all(IDENTITY_FILES.map(file => exists(path.join(this.directory, file))));
|
|
77
|
+
if (!present.some(Boolean))
|
|
78
|
+
return { binding: null, revision: 'unregistered', identityState: pending ? 'incomplete' : 'absent' };
|
|
79
|
+
let readableBinding = null;
|
|
80
|
+
let configDigest = 'corrupt';
|
|
81
|
+
try {
|
|
82
|
+
configDigest = createHash('sha256').update(await readPrivateFile(path.join(this.directory, 'config.yml'))).digest('hex');
|
|
83
|
+
await readPrivateFile(path.join(this.directory, 'config.yml'));
|
|
84
|
+
const binding = bindingOf(await loadClientConfig(this.directory));
|
|
85
|
+
readableBinding = binding;
|
|
86
|
+
const keys = { clientKey: (await readPrivateFile(path.join(this.directory, 'client.key'))).trim(), serverPub: (await readPrivateFile(path.join(this.directory, 'server.pub'))).trim() };
|
|
87
|
+
validateKeys(keys);
|
|
88
|
+
const revision = createHash('sha256').update(JSON.stringify(binding)).update('\0').update(keys.clientKey).update('\0').update(keys.serverPub).digest('hex');
|
|
89
|
+
return { binding, revision, identityState: pending ? 'incomplete' : 'ready' };
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { binding: readableBinding, revision: configDigest, identityState: pending ? 'incomplete' : 'corrupt' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async history() {
|
|
96
|
+
try {
|
|
97
|
+
const data = JSON.parse(await readPrivateFile(path.join(this.directory, 'binding-history.json')));
|
|
98
|
+
if (!Array.isArray(data))
|
|
99
|
+
throw new Error('Invalid binding history');
|
|
100
|
+
return data.map(value => bindingSchema.parse(value));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (missing(error))
|
|
104
|
+
return [];
|
|
105
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Workstation binding history is unreadable');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async recover() {
|
|
109
|
+
if (!await exists(this.pending))
|
|
110
|
+
return;
|
|
111
|
+
const stat = await fs.lstat(this.pending);
|
|
112
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
113
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Unsafe pending workstation identity');
|
|
114
|
+
let marker;
|
|
115
|
+
try {
|
|
116
|
+
marker = await readPrivateFile(path.join(this.pending, 'ready'));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration needs verification: incomplete local transaction');
|
|
120
|
+
}
|
|
121
|
+
if (marker === 'clear') {
|
|
122
|
+
await this.finishClear();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (marker !== '1')
|
|
126
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Unknown workstation identity transaction');
|
|
127
|
+
const configText = await readPrivateFile(path.join(this.pending, 'config.yml'));
|
|
128
|
+
const config = await loadClientConfig(this.pending);
|
|
129
|
+
bindingOf(config);
|
|
130
|
+
const keys = { clientKey: (await readPrivateFile(path.join(this.pending, 'client.key'))).trim(), serverPub: (await readPrivateFile(path.join(this.pending, 'server.pub'))).trim() };
|
|
131
|
+
validateKeys(keys);
|
|
132
|
+
const operation = operationRecordSchema.parse(JSON.parse(await readPrivateFile(path.join(this.pending, 'operation.json'))));
|
|
133
|
+
let history;
|
|
134
|
+
if (await exists(path.join(this.pending, 'binding-history.json'))) {
|
|
135
|
+
const staged = JSON.parse(await readPrivateFile(path.join(this.pending, 'binding-history.json')));
|
|
136
|
+
if (!Array.isArray(staged))
|
|
137
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Invalid staged binding history');
|
|
138
|
+
history = staged.map(value => bindingSchema.parse(value));
|
|
139
|
+
}
|
|
140
|
+
// All staging is validated before any existing identity file changes.
|
|
141
|
+
for (const [name, value] of [['client.key', keys.clientKey], ['server.pub', keys.serverPub], ['config.yml', configText]])
|
|
142
|
+
await atomicWrite(path.join(this.directory, name), value);
|
|
143
|
+
if (history)
|
|
144
|
+
await atomicWrite(path.join(this.directory, 'binding-history.json'), JSON.stringify(history));
|
|
145
|
+
await atomicWrite(path.join(this.directory, 'operation.json'), JSON.stringify(operation));
|
|
146
|
+
await syncDirectory(this.directory);
|
|
147
|
+
await fs.rm(this.pending, { recursive: true });
|
|
148
|
+
await syncDirectory(this.directory);
|
|
149
|
+
}
|
|
150
|
+
async commit(config, keys, operation, repair = false) {
|
|
151
|
+
const binding = bindingOf(config);
|
|
152
|
+
validateKeys(keys);
|
|
153
|
+
const safeOperation = operationRecordSchema.parse(operation);
|
|
154
|
+
await privateDirectory(this.directory);
|
|
155
|
+
await this.recover();
|
|
156
|
+
const current = await this.inspect();
|
|
157
|
+
if (current.identityState === 'corrupt' && !(repair && current.binding && (!current.binding.ownerUserId || current.binding.ownerUserId === binding.ownerUserId) && current.binding.server === binding.server && current.binding.workstationId === binding.workstationId))
|
|
158
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Existing workstation identity needs repair');
|
|
159
|
+
const history = await this.history();
|
|
160
|
+
for (const previous of [current.binding, binding])
|
|
161
|
+
if (previous) {
|
|
162
|
+
const i = history.findIndex(item => item.server === previous.server && item.ownerUserId === previous.ownerUserId);
|
|
163
|
+
if (i >= 0)
|
|
164
|
+
history.splice(i, 1);
|
|
165
|
+
history.push(previous);
|
|
166
|
+
}
|
|
167
|
+
await fs.mkdir(this.pending, { mode: 0o700 });
|
|
168
|
+
for (const [name, value] of Object.entries({
|
|
169
|
+
'config.yml': yaml.dump({ ...config, server: binding.server }), 'client.key': keys.clientKey, 'server.pub': keys.serverPub,
|
|
170
|
+
'operation.json': JSON.stringify(safeOperation), 'binding-history.json': JSON.stringify(history),
|
|
171
|
+
}))
|
|
172
|
+
await atomicWrite(path.join(this.pending, name), value);
|
|
173
|
+
await atomicWrite(path.join(this.pending, 'ready'), '1');
|
|
174
|
+
await syncDirectory(this.pending);
|
|
175
|
+
await syncDirectory(this.directory);
|
|
176
|
+
await this.recover();
|
|
177
|
+
}
|
|
178
|
+
async clear(expectedRevision) {
|
|
179
|
+
await this.recover();
|
|
180
|
+
const current = await this.inspect();
|
|
181
|
+
if (current.revision !== expectedRevision || current.identityState !== 'ready')
|
|
182
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Workstation identity changed; check it again');
|
|
183
|
+
await fs.mkdir(this.pending, { mode: 0o700 });
|
|
184
|
+
await atomicWrite(path.join(this.pending, 'clear.json'), JSON.stringify(current.binding));
|
|
185
|
+
await atomicWrite(path.join(this.pending, 'ready'), 'clear');
|
|
186
|
+
await syncDirectory(this.pending);
|
|
187
|
+
await syncDirectory(this.directory);
|
|
188
|
+
await this.finishClear();
|
|
189
|
+
}
|
|
190
|
+
async finishClear() {
|
|
191
|
+
const removed = bindingSchema.parse(JSON.parse(await readPrivateFile(path.join(this.pending, 'clear.json'))));
|
|
192
|
+
const history = (await this.history()).filter(item => item.server !== removed.server || item.workstationId !== removed.workstationId);
|
|
193
|
+
await atomicWrite(path.join(this.directory, 'binding-history.json'), JSON.stringify(history));
|
|
194
|
+
for (const name of [...IDENTITY_FILES, 'operation.json'])
|
|
195
|
+
await fs.rm(path.join(this.directory, name), { force: true });
|
|
196
|
+
await syncDirectory(this.directory);
|
|
197
|
+
await fs.rm(this.pending, { recursive: true });
|
|
198
|
+
await syncDirectory(this.directory);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { assertIdentityCanStart } from './daemonIdentity.js';
|
|
4
|
+
import { normalizeWorkstationServer } from './serverIdentity.js';
|
|
5
|
+
import { saveOperation } from './enrollment.js';
|
|
6
|
+
import { clientDir, loadClientConfig, readKeys } from '../config.js';
|
|
7
|
+
import { createDaemonController } from '../daemonctl.js';
|
|
8
|
+
import { BindingStore } from './bindingStore.js';
|
|
9
|
+
import { inspectWorkstation } from './inspect.js';
|
|
10
|
+
import { enrollWorkstation, enrollmentFailure } from './enrollment.js';
|
|
11
|
+
import { withWorkstationLock } from './locks.js';
|
|
12
|
+
import { bindingGuardSchema, LocalControlError } from './protocol.js';
|
|
13
|
+
const lifecycleRequest = z.object({ operationId: z.string().min(1).max(128), guard: bindingGuardSchema }).strict();
|
|
14
|
+
export async function readLocalRequest(input) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
for await (const part of input) {
|
|
18
|
+
const chunk = Buffer.from(part);
|
|
19
|
+
size += chunk.length;
|
|
20
|
+
if (size > 65_536)
|
|
21
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local command input');
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Invalid local command input');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function executeLocalCommand(action, input, directory, controllerFactory, inspect = inspectWorkstation) {
|
|
32
|
+
if (action === 'register')
|
|
33
|
+
return enrollWorkstation(input, directory);
|
|
34
|
+
try {
|
|
35
|
+
const request = action === 'adopt-owner' ? lifecycleRequest.extend({ server: z.string(), expectedOwnerUserId: z.string().min(1), workstationId: z.string().min(1) }).parse(input) : action === 'set-enabled' ? lifecycleRequest.extend({ enabled: z.boolean() }).parse(input) : lifecycleRequest.parse(input);
|
|
36
|
+
return await withWorkstationLock(directory, 'operation', async () => {
|
|
37
|
+
const before = await inspect(directory);
|
|
38
|
+
if (before.revision !== request.guard.revision || before.runtime.generation !== request.guard.runtimeGeneration)
|
|
39
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Workstation changed; check it again');
|
|
40
|
+
if (action === 'recover') {
|
|
41
|
+
if (before.runtime.state !== 'stopped' || before.keepers !== 'stopped')
|
|
42
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Stop and verify the workstation before recovering its identity');
|
|
43
|
+
await new BindingStore(directory).recover();
|
|
44
|
+
return { status: 'succeeded', inspection: await inspect(directory) };
|
|
45
|
+
}
|
|
46
|
+
if (before.pending?.kind === 'remove' && !['begin-remove', 'forget', 'stop'].includes(action))
|
|
47
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Finish removing this workstation before continuing');
|
|
48
|
+
if (before.runtime.state === 'unknown')
|
|
49
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Cannot verify the running workstation');
|
|
50
|
+
if (action === 'adopt-owner') {
|
|
51
|
+
const ownerRequest = lifecycleRequest.extend({ server: z.string(), expectedOwnerUserId: z.string().min(1), workstationId: z.string().min(1) }).parse(input);
|
|
52
|
+
if (before.identityState !== 'ready' || !before.binding || before.binding.ownerUserId || before.binding.server !== normalizeWorkstationServer(ownerRequest.server) || before.binding.workstationId !== ownerRequest.workstationId)
|
|
53
|
+
throw new LocalControlError('BINDING_CONFLICT', 'The local connection changed');
|
|
54
|
+
const config = await loadClientConfig(directory);
|
|
55
|
+
const keys = await readKeys(directory);
|
|
56
|
+
await new BindingStore(directory).commit({ ...config, ownerUserId: ownerRequest.expectedOwnerUserId }, { clientKey: keys.clientKey.toString('base64'), serverPub: keys.serverPub.toString('base64') }, { id: request.operationId, kind: 'register', phase: 'owner-confirmed', target: { server: before.binding.server, ownerUserId: ownerRequest.expectedOwnerUserId }, workstationId: before.binding.workstationId, outcome: 'succeeded' });
|
|
57
|
+
}
|
|
58
|
+
else if (action === 'forget') {
|
|
59
|
+
if (before.runtime.state !== 'stopped' || before.keepers !== 'stopped')
|
|
60
|
+
throw new LocalControlError('INSTANCE_OCCUPIED', 'Stop the workstation and its sessions before removing the connection');
|
|
61
|
+
await new BindingStore(directory).clear(request.guard.revision);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
if (before.identityState !== 'ready')
|
|
65
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'The workstation needs registration or repair');
|
|
66
|
+
const controller = controllerFactory?.() ?? createDaemonController({ identity: () => new BindingStore(directory).inspect(), runtime: async () => (await inspect(directory)).runtime });
|
|
67
|
+
if (action === 'set-enabled') {
|
|
68
|
+
const enabled = lifecycleRequest.extend({ enabled: z.boolean() }).parse(input).enabled;
|
|
69
|
+
if (enabled)
|
|
70
|
+
await assertIdentityCanStart(directory);
|
|
71
|
+
if (!controller.setEnabled)
|
|
72
|
+
throw new LocalControlError('UNSUPPORTED_PROTOCOL', 'Update the CLI to manage service enablement');
|
|
73
|
+
await controller.setEnabled(enabled, request);
|
|
74
|
+
}
|
|
75
|
+
else if (action === 'begin-remove') {
|
|
76
|
+
if (before.runtime.state !== 'stopped' || before.keepers !== 'stopped')
|
|
77
|
+
await controller.stop(request);
|
|
78
|
+
const stopped = await inspect(directory);
|
|
79
|
+
if (stopped.runtime.state !== 'stopped' || stopped.keepers !== 'stopped')
|
|
80
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Workstation shutdown could not be confirmed');
|
|
81
|
+
await saveOperation(directory, { id: request.operationId, kind: 'remove', phase: 'removing', target: { server: before.binding.server, ownerUserId: before.binding.ownerUserId ?? null }, workstationId: before.binding.workstationId, outcome: 'pending' });
|
|
82
|
+
}
|
|
83
|
+
else if (action === 'install') {
|
|
84
|
+
if (before.runtime.state !== 'stopped')
|
|
85
|
+
throw new LocalControlError('INSTANCE_OCCUPIED', 'Stop the workstation before changing its service');
|
|
86
|
+
await controller.install();
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
if (action === 'start' || action === 'restart')
|
|
90
|
+
await assertIdentityCanStart(directory);
|
|
91
|
+
await controller[action](request);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const after = await inspect(directory);
|
|
95
|
+
if (action === 'set-enabled') {
|
|
96
|
+
const expected = lifecycleRequest.extend({ enabled: z.boolean() }).parse(input).enabled;
|
|
97
|
+
if (!after.service || after.service.enabled !== expected || after.service.reason || (!expected && (after.runtime.state !== 'stopped' || after.keepers !== 'stopped'))) {
|
|
98
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Service enablement could not be confirmed');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (action === 'stop' && (after.runtime.state !== 'stopped' || after.keepers !== 'stopped'))
|
|
102
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Workstation shutdown could not be confirmed');
|
|
103
|
+
return { status: 'succeeded', inspection: after };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return enrollmentFailure(error, directory);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function runLocalJsonCommand(action, input = process.stdin, write = value => { process.stdout.write(value); }, directory = clientDir(), controllerFactory, inspect = inspectWorkstation) {
|
|
111
|
+
let operationId = randomUUID();
|
|
112
|
+
let result;
|
|
113
|
+
try {
|
|
114
|
+
const request = await readLocalRequest(input);
|
|
115
|
+
if (typeof request === 'object' && request && 'operationId' in request && typeof request.operationId === 'string' && /^[\w-]{1,128}$/.test(request.operationId))
|
|
116
|
+
operationId = request.operationId;
|
|
117
|
+
result = await executeLocalCommand(action, request, directory, controllerFactory, inspect);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
result = await enrollmentFailure(error, directory);
|
|
121
|
+
}
|
|
122
|
+
const event = { schemaVersion: 1, kind: 'result', operationId, result };
|
|
123
|
+
write(JSON.stringify(event) + '\n');
|
|
124
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadClientConfig, readKeys } from '../config.js';
|
|
3
|
+
import { BindingStore } from './bindingStore.js';
|
|
4
|
+
import { withWorkstationLock } from './locks.js';
|
|
5
|
+
import { inspectRuntime } from './runtimeClient.js';
|
|
6
|
+
import { readControlFile } from './runtimeHost.js';
|
|
7
|
+
import { LocalControlError, operationRecordSchema } from './protocol.js';
|
|
8
|
+
export async function assertIdentityCanStart(directory) {
|
|
9
|
+
try {
|
|
10
|
+
const operation = operationRecordSchema.parse(JSON.parse(await readControlFile(path.join(directory, 'operation.json'))));
|
|
11
|
+
if (operation.outcome === 'pending' || operation.outcome === 'unknown')
|
|
12
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', operation.kind === 'remove' ? 'Finish removing this workstation before starting it' : 'The workstation operation needs verification');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT')
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function recoverStoppedIdentity(directory) {
|
|
20
|
+
const store = new BindingStore(directory);
|
|
21
|
+
if ((await store.inspect()).identityState === 'incomplete') {
|
|
22
|
+
const runtime = await inspectRuntime(directory);
|
|
23
|
+
const { inspectKeepers } = await import('./inspect.js');
|
|
24
|
+
if (runtime.state !== 'stopped' || await inspectKeepers(directory) !== 'stopped')
|
|
25
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Stop and verify the existing workstation before recovering its identity');
|
|
26
|
+
}
|
|
27
|
+
await store.recover();
|
|
28
|
+
}
|
|
29
|
+
/** Publish runtime ownership under the same lock as identity writes; initialization uses this snapshot only. */
|
|
30
|
+
export async function claimDaemonIdentity(directory, claim) {
|
|
31
|
+
const deadline = Date.now() + 10_000;
|
|
32
|
+
for (;;)
|
|
33
|
+
try {
|
|
34
|
+
return await withWorkstationLock(directory, 'operation', async () => {
|
|
35
|
+
const runtime = await inspectRuntime(directory);
|
|
36
|
+
if (runtime.state !== 'stopped')
|
|
37
|
+
throw new LocalControlError(runtime.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'The existing workstation must be verified before startup');
|
|
38
|
+
const store = new BindingStore(directory);
|
|
39
|
+
await recoverStoppedIdentity(directory);
|
|
40
|
+
await assertIdentityCanStart(directory);
|
|
41
|
+
if ((await store.inspect()).identityState !== 'ready')
|
|
42
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'The workstation identity needs registration or repair');
|
|
43
|
+
const config = await loadClientConfig(directory);
|
|
44
|
+
const keys = await readKeys(directory);
|
|
45
|
+
return claim({ config, ...keys });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!(error instanceof LocalControlError) || error.code !== 'BUSY' || Date.now() >= deadline)
|
|
50
|
+
throw error;
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { sanitizeName } from '../names.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { requestWorkstationRegistration } from '../register.js';
|
|
8
|
+
import { BindingStore } from './bindingStore.js';
|
|
9
|
+
import { inspectRuntime } from './runtimeClient.js';
|
|
10
|
+
import { inspectWorkstation } from './inspect.js';
|
|
11
|
+
import { bindingGuardSchema, LocalControlError, operationRecordSchema } from './protocol.js';
|
|
12
|
+
import { assertLockOwnership, withWorkstationLock } from './locks.js';
|
|
13
|
+
import { normalizeWorkstationServer } from './serverIdentity.js';
|
|
14
|
+
import { privateDirectory } from '../keepers/kit/paths.js';
|
|
15
|
+
import { KeeperLauncher } from '../keepers/kit/launcher.js';
|
|
16
|
+
import { stopKeepers } from '../keepers/stop.js';
|
|
17
|
+
export const registrationInputSchema = z.object({ operationId: z.string().min(1).max(128), server: z.string(), expectedOwnerUserId: z.string().min(1), name: z.string().min(1).max(200).optional(), workstationId: z.string().min(1).optional(), workspace: z.object({ name: z.string(), cwd: z.string(), shell: z.string() }).optional(), guard: bindingGuardSchema, switchConfirmed: z.boolean(), accessToken: z.string().min(1).max(32_768) }).strict();
|
|
18
|
+
export async function saveOperation(directory, operation) {
|
|
19
|
+
assertLockOwnership();
|
|
20
|
+
await privateDirectory(directory);
|
|
21
|
+
const file = path.join(directory, 'operation.json');
|
|
22
|
+
const temporary = file + '.' + randomUUID();
|
|
23
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
24
|
+
try {
|
|
25
|
+
await handle.writeFile(JSON.stringify(operationRecordSchema.parse(operation)));
|
|
26
|
+
await handle.sync();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
assertLockOwnership();
|
|
32
|
+
await fs.rename(temporary, file);
|
|
33
|
+
}
|
|
34
|
+
export async function enrollmentFailure(error, directory) {
|
|
35
|
+
const e = error instanceof LocalControlError ? error : new LocalControlError('SERVICE_FAILED', 'The local workstation operation failed. Check its status and try again.');
|
|
36
|
+
return { status: e.code === 'BUSY' ? 'busy' : e.code === 'OUTCOME_UNKNOWN' ? 'pending' : ['BINDING_CONFLICT', 'INSTANCE_OCCUPIED'].includes(e.code) ? 'conflict' : 'failed', inspection: await inspectWorkstation(directory).catch(() => null), error: { code: e.code, message: e.message } };
|
|
37
|
+
}
|
|
38
|
+
async function defaultShell() {
|
|
39
|
+
const candidates = [process.env.SHELL, os.userInfo().shell, process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh'];
|
|
40
|
+
for (const shell of candidates)
|
|
41
|
+
if (shell && path.isAbsolute(shell) && !/[\x00-\x1f]/.test(shell)) {
|
|
42
|
+
try {
|
|
43
|
+
await fs.access(shell, fs.constants.X_OK);
|
|
44
|
+
if ((await fs.stat(shell)).isFile())
|
|
45
|
+
return shell;
|
|
46
|
+
}
|
|
47
|
+
catch { /* Try the user's login shell, then the platform fallback. */ }
|
|
48
|
+
}
|
|
49
|
+
throw new LocalControlError('UNSUPPORTED_INSTALLATION', 'No executable login shell is available');
|
|
50
|
+
}
|
|
51
|
+
async function enroll(input, directory, deps) {
|
|
52
|
+
const server = normalizeWorkstationServer(input.server);
|
|
53
|
+
const store = new BindingStore(directory);
|
|
54
|
+
await store.recover();
|
|
55
|
+
const identity = await store.inspect();
|
|
56
|
+
const runtime = await deps.runtime();
|
|
57
|
+
if (runtime.state !== 'stopped')
|
|
58
|
+
throw new LocalControlError(runtime.state === 'running' ? 'INSTANCE_OCCUPIED' : 'OUTCOME_UNKNOWN', 'Stop the existing workstation before registering this computer');
|
|
59
|
+
if (input.guard && (identity.revision !== input.guard.revision || runtime.generation !== input.guard.runtimeGeneration))
|
|
60
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Workstation changed; check it again');
|
|
61
|
+
if (identity.identityState === 'corrupt' && (!identity.binding || !input.expectedOwnerUserId || (identity.binding.ownerUserId && identity.binding.ownerUserId !== input.expectedOwnerUserId) || identity.binding.server !== server))
|
|
62
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'The saved workstation identity needs verification');
|
|
63
|
+
const switching = identity.binding && (identity.binding.server !== server || (input.expectedOwnerUserId && identity.binding.ownerUserId !== input.expectedOwnerUserId));
|
|
64
|
+
if (switching && !input.switchConfirmed)
|
|
65
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Confirm switching this computer to the selected server and account');
|
|
66
|
+
const inspection = await inspectWorkstation(directory);
|
|
67
|
+
if (inspection.pending && ['pending', 'unknown'].includes(inspection.pending.outcome))
|
|
68
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'An earlier registration needs verification. Refresh the workstation list before retrying.');
|
|
69
|
+
if (identity.binding) {
|
|
70
|
+
const reports = await stopKeepers(new KeeperLauncher({ configDir: directory, ...identity.binding }));
|
|
71
|
+
if (reports.some(report => !report.result.stopped))
|
|
72
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Existing workstation sessions could not be stopped');
|
|
73
|
+
}
|
|
74
|
+
const history = await store.history();
|
|
75
|
+
const known = input.expectedOwnerUserId ? history.find(item => item.server === server && item.ownerUserId === input.expectedOwnerUserId) : undefined;
|
|
76
|
+
const same = identity.binding?.server === server && (!input.expectedOwnerUserId || identity.binding.ownerUserId === input.expectedOwnerUserId);
|
|
77
|
+
const workstationId = input.workstationId ?? known?.workstationId ?? (same ? identity.binding?.workstationId : undefined);
|
|
78
|
+
const operation = { id: input.operationId, kind: switching ? 'switch' : 'register', phase: 'registering', target: { server, ownerUserId: input.expectedOwnerUserId ?? null }, outcome: 'pending' };
|
|
79
|
+
await saveOperation(directory, operation);
|
|
80
|
+
try {
|
|
81
|
+
const workspace = input.workspace;
|
|
82
|
+
const shell = workspace?.shell ?? await defaultShell();
|
|
83
|
+
const response = await requestWorkstationRegistration(server, input.name ?? sanitizeName(os.hostname(), 'computer'), input.accessToken, workspace, workstationId, shell);
|
|
84
|
+
if (input.expectedOwnerUserId && response.ownerUserId !== input.expectedOwnerUserId)
|
|
85
|
+
throw new LocalControlError('OWNER_MISMATCH', 'The server returned a different workstation owner');
|
|
86
|
+
assertLockOwnership();
|
|
87
|
+
await store.commit({ server, workstationId: response.workstationId, ...(response.ownerUserId ? { ownerUserId: response.ownerUserId } : {}), workspaces: workspace ? [{ name: workspace.name, cwd: workspace.cwd }] : [] }, response, { ...operation, workstationId: response.workstationId, phase: 'registered', outcome: 'succeeded' }, identity.identityState === 'corrupt');
|
|
88
|
+
return { status: 'succeeded', inspection: await inspectWorkstation(directory) };
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await saveOperation(directory, { ...operation, outcome: error instanceof LocalControlError && error.code !== 'OUTCOME_UNKNOWN' ? 'failed' : 'unknown' });
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function enrollWorkstation(input, directory, deps = { runtime: () => inspectRuntime(directory) }) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = registrationInputSchema.parse(input);
|
|
98
|
+
return await withWorkstationLock(directory, 'operation', () => enroll(parsed, directory, deps));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return enrollmentFailure(error, directory);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function enrollLegacyWorkstation(input, directory) {
|
|
105
|
+
await withWorkstationLock(directory, 'operation', () => enroll(input, directory, { runtime: () => inspectRuntime(directory) }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { createServiceManager } from '../service/installer.js';
|
|
3
|
+
import { serviceContext } from '../daemonctl.js';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { BindingStore } from './bindingStore.js';
|
|
8
|
+
import { inspectRuntime } from './runtimeClient.js';
|
|
9
|
+
import { localInspectionSchema, operationRecordSchema } from './protocol.js';
|
|
10
|
+
import { readControlFile } from './runtimeHost.js';
|
|
11
|
+
import { updateServiceContext } from '../config.js';
|
|
12
|
+
import { CLIENT_VERSION } from '../version.js';
|
|
13
|
+
import { readPidManifest, manifestProcessLive } from '../keepers/kit/lock.js';
|
|
14
|
+
export async function inspectKeepers(directory) {
|
|
15
|
+
try {
|
|
16
|
+
let running = false;
|
|
17
|
+
for (const kind of ['acp', 'terminal']) {
|
|
18
|
+
const root = path.join(directory, 'keepers', kind, 'run');
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await fs.readdir(root);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (e.code === 'ENOENT')
|
|
25
|
+
continue;
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const manifest = await readPidManifest(path.join(root, entry, 'keeper.pid.json'));
|
|
30
|
+
if (manifest && await manifestProcessLive(manifest))
|
|
31
|
+
running = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return running ? 'running' : 'stopped';
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function inspectWorkstation(directory, deps = {}) {
|
|
41
|
+
const identity = await new BindingStore(directory).inspect();
|
|
42
|
+
let pending = null;
|
|
43
|
+
try {
|
|
44
|
+
const operation = operationRecordSchema.parse(JSON.parse(await readControlFile(path.join(directory, 'operation.json'))));
|
|
45
|
+
if (operation.outcome !== 'succeeded')
|
|
46
|
+
pending = operation;
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (e.code !== 'ENOENT')
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../cli.js');
|
|
53
|
+
const context = updateServiceContext(directory);
|
|
54
|
+
let runtime = await inspectRuntime(directory);
|
|
55
|
+
const standardHome = os.userInfo().homedir;
|
|
56
|
+
const standardDirectory = path.join(standardHome, '.axom/workstation');
|
|
57
|
+
const probeLegacy = deps.legacyServiceRunning ?? (process.platform === 'darwin' && path.resolve(directory) === standardDirectory && os.homedir() === standardHome
|
|
58
|
+
? async () => (await createServiceManager(serviceContext()).status()).running : undefined);
|
|
59
|
+
if (runtime.state === 'stopped' && probeLegacy) {
|
|
60
|
+
try {
|
|
61
|
+
if (await probeLegacy())
|
|
62
|
+
runtime = { ...runtime, state: 'unknown', connection: 'unknown' };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
runtime = { ...runtime, state: 'unknown', connection: 'unknown' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const service = (process.platform === 'darwin' || process.platform === 'linux') && path.resolve(directory) === standardDirectory && os.homedir() === standardHome
|
|
69
|
+
? await createServiceManager(serviceContext()).enablement() : undefined;
|
|
70
|
+
return localInspectionSchema.parse({ schemaVersion: 1, ...identity, installation: { source: context.managed ? 'managed' : 'existing', version: CLIENT_VERSION, nodeVersion: process.versions.node, nodePath: context.nodePath, cliPath, npmPath: context.npmPath, npmPrefix: context.npmPrefix }, runtime, ...(service ? { service } : {}), keepers: await inspectKeepers(directory), pending });
|
|
71
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
import lockfile from 'proper-lockfile';
|
|
7
|
+
import { privateDirectory } from '../keepers/kit/paths.js';
|
|
8
|
+
import { pidAlive, processCommand } from '../keepers/kit/lock.js';
|
|
9
|
+
import { LocalControlError } from './protocol.js';
|
|
10
|
+
const ownership = new AsyncLocalStorage();
|
|
11
|
+
export function assertLockOwnership() {
|
|
12
|
+
if (ownership.getStore()?.valid === false)
|
|
13
|
+
throw new LocalControlError('BUSY', 'Workstation lock ownership was lost');
|
|
14
|
+
}
|
|
15
|
+
export async function withPrivateLock(stem, work) {
|
|
16
|
+
await privateDirectory(path.dirname(stem));
|
|
17
|
+
const ownerFile = stem + '.owner.json';
|
|
18
|
+
// proper-lockfile's time lease alone is insufficient: a suspended live app still owns its operation.
|
|
19
|
+
let lockPresent = false;
|
|
20
|
+
try {
|
|
21
|
+
const stat = await fs.lstat(stem + '.lock');
|
|
22
|
+
lockPresent = true;
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
24
|
+
throw Error('Unsafe lock');
|
|
25
|
+
if (Date.now() - stat.mtimeMs > 30_000) {
|
|
26
|
+
const owner = JSON.parse(await fs.readFile(ownerFile, 'utf8'));
|
|
27
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.command !== 'string')
|
|
28
|
+
throw Error('Unknown owner');
|
|
29
|
+
if (pidAlive(owner.pid))
|
|
30
|
+
throw Error('Live or unverifiable owner');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (lockPresent || error.code !== 'ENOENT')
|
|
35
|
+
throw new LocalControlError('BUSY', 'Another workstation operation is active');
|
|
36
|
+
}
|
|
37
|
+
const lease = { valid: true };
|
|
38
|
+
const nonce = randomUUID();
|
|
39
|
+
let release;
|
|
40
|
+
try {
|
|
41
|
+
release = await lockfile.lock(stem, { realpath: false, stale: 30_000, update: 2_000, retries: 0, onCompromised: () => { lease.valid = false; } });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new LocalControlError('BUSY', 'Another workstation operation is active');
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await fs.writeFile(ownerFile, JSON.stringify({ schemaVersion: 1, pid: process.pid, command: await processCommand(process.pid), nonce }), { mode: 0o600 });
|
|
48
|
+
return await ownership.run(lease, async () => { const result = await work(); assertLockOwnership(); return result; });
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
if (lease.valid) {
|
|
52
|
+
try {
|
|
53
|
+
if (JSON.parse(await fs.readFile(ownerFile, 'utf8')).nonce === nonce)
|
|
54
|
+
await fs.rm(ownerFile);
|
|
55
|
+
}
|
|
56
|
+
catch { /* release still required */ }
|
|
57
|
+
await release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function withWorkstationLock(directory, name, work) {
|
|
62
|
+
return withPrivateLock(name === 'operation' ? path.join(directory, 'control/operation') : path.join(os.homedir(), '.axom/locks/cli-install'), work);
|
|
63
|
+
}
|