@platless/axom-cli 0.5.9 → 0.6.52
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 +25 -6
- package/dist/axom-patch/cli/README.md +129 -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/claude-replay-lifecycle.json +16 -0
- 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/claude-replay-lifecycle.mjs +31 -0
- 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/probes/zcode-env-account.mjs +81 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1687 -0
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +277 -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 +19 -19
- package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
- package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +10 -11
- package/dist/axom-patch/cli/zcode-env-account.json +58 -0
- package/dist/axom-patch/cli/zcode-models.json +9 -10
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -11
- package/dist/cli.js +11 -45
- package/dist/commands/auth.js +4 -1
- package/dist/commands/knowledge.js +47 -0
- package/dist/commands/update.js +126 -3
- package/dist/commands/workstation.js +101 -164
- package/dist/commands/workstationRegistrations.js +116 -0
- package/dist/credentialBroker.js +13 -2
- package/dist/credentials.js +16 -0
- package/dist/install/claude/commands.js +15 -15
- package/dist/keepers/terminal/keeper.mjs +1089 -673
- package/dist/keepers/terminal/manifest.json +4 -4
- package/dist/mcp.js +1 -1
- package/dist/native/darwin-x64/axom-quic +0 -0
- package/dist/workstation/acp/acpAgents.js +113 -102
- 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/outbox.js +12 -5
- package/dist/workstation/acp/patchAssets.js +40 -0
- package/dist/workstation/acp/permission.js +2 -2
- package/dist/workstation/acp/processor/checkpoint.js +26 -34
- package/dist/workstation/acp/processor/control.js +7 -80
- package/dist/workstation/acp/processor/jsonrpc.js +8 -0
- package/dist/workstation/acp/processor/runtime.js +5 -81
- package/dist/workstation/acp/processor/session.js +54 -82
- package/dist/workstation/acp/processor/state.js +7 -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/transport/local.js +19 -3
- package/dist/workstation/acp/processor/updates.js +156 -105
- package/dist/workstation/acp/processorSession.js +20 -173
- 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 +32 -3
- 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 +114 -166
- package/dist/workstation/channels/agent.js +9 -1
- package/dist/workstation/channels/computer.js +27 -4
- package/dist/workstation/channels/management.js +130 -15
- package/dist/workstation/channels/url.js +1 -1
- package/dist/workstation/config.js +33 -9
- package/dist/workstation/connection/encryptedChannel.js +4 -1
- package/dist/workstation/control/bindingStore.js +204 -0
- package/dist/workstation/control/commands.js +125 -0
- package/dist/workstation/control/daemonIdentity.js +143 -0
- package/dist/workstation/control/deviceCommands.js +191 -0
- package/dist/workstation/control/deviceProtocol.js +68 -0
- package/dist/workstation/control/enrollment.js +106 -0
- package/dist/workstation/control/inspect.js +71 -0
- package/dist/workstation/control/locks.js +110 -0
- package/dist/workstation/control/protocol.js +40 -0
- package/dist/workstation/control/restartGuard.js +46 -0
- package/dist/workstation/control/rootFormat.js +16 -0
- package/dist/workstation/control/runtimeClient.js +152 -0
- package/dist/workstation/control/runtimeHost.js +235 -0
- package/dist/workstation/control/serverIdentity.js +35 -0
- package/dist/workstation/control/updateTransition.js +91 -0
- package/dist/workstation/daemon.js +266 -264
- package/dist/workstation/daemonctl.js +226 -25
- package/dist/workstation/desktopControl/audio.js +99 -0
- package/dist/workstation/desktopControl/automationDevice.js +219 -0
- package/dist/workstation/desktopControl/automationJson.js +74 -0
- package/dist/workstation/desktopControl/automationLocalState.js +23 -0
- package/dist/workstation/desktopControl/automationProtocol.js +253 -0
- package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
- package/dist/workstation/desktopControl/automationSession.js +81 -0
- package/dist/workstation/desktopControl/automationTransport.js +138 -0
- package/dist/workstation/desktopControl/capabilities.js +93 -0
- package/dist/workstation/desktopControl/captureModes.js +9 -0
- package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
- package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
- package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
- package/dist/workstation/desktopControl/deviceSockets.js +246 -0
- package/dist/workstation/desktopControl/helperProcess.js +602 -0
- package/dist/workstation/desktopControl/hostGrant.js +65 -0
- package/dist/workstation/desktopControl/lobby.js +131 -0
- package/dist/workstation/desktopControl/macProvider.js +156 -0
- package/dist/workstation/desktopControl/media.js +331 -0
- package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
- package/dist/workstation/desktopControl/nativeProvider.js +1044 -0
- package/dist/workstation/desktopControl/networkEvidence.js +43 -0
- package/dist/workstation/desktopControl/overview.js +58 -0
- package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
- package/dist/workstation/desktopControl/protocol.js +311 -0
- package/dist/workstation/desktopControl/provider.js +1 -0
- package/dist/workstation/desktopControl/quicMedia.js +240 -0
- package/dist/workstation/desktopControl/rateController.js +226 -0
- package/dist/workstation/desktopControl/supervisor.js +1774 -0
- package/dist/workstation/desktopControl/transport.js +27 -0
- package/dist/workstation/desktopControl/wallpaper.js +98 -0
- package/dist/workstation/exec/policy.js +5 -0
- package/dist/workstation/files/client.js +10 -1
- package/dist/workstation/files/credentials.js +142 -0
- package/dist/workstation/files/host.js +9 -2
- package/dist/workstation/files/text.js +42 -8
- package/dist/workstation/journal.js +5 -2
- package/dist/workstation/keepers/acp/client.js +38 -2
- package/dist/workstation/keepers/kit/buildVersion.js +35 -11
- package/dist/workstation/keepers/kit/bundle.js +21 -17
- package/dist/workstation/keepers/kit/client.js +2 -2
- package/dist/workstation/keepers/kit/launcher.js +123 -7
- package/dist/workstation/keepers/kit/paths.js +2 -1
- package/dist/workstation/keepers/legacy.js +16 -2
- package/dist/workstation/keepers/policy.js +5 -34
- package/dist/workstation/keepers/terminal/client.js +115 -23
- package/dist/workstation/keepers/terminal/exec.js +6 -3
- package/dist/workstation/keepers/terminal/frames.js +73 -0
- package/dist/workstation/keepers/terminal/generations.js +5 -8
- package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
- package/dist/workstation/keepers/terminal/input.js +146 -0
- package/dist/workstation/keepers/terminal/keeper.js +22 -8
- package/dist/workstation/keepers/terminal/main.js +1 -1
- package/dist/workstation/keepers/terminal/metadata.js +138 -0
- package/dist/workstation/keepers/terminal/protocol.js +4 -28
- package/dist/workstation/keepers/terminal/pty.js +1 -330
- package/dist/workstation/keepers/terminal/screen.js +246 -0
- package/dist/workstation/keepers/terminal/sessions.js +526 -0
- package/dist/workstation/managedInstall.js +57 -0
- package/dist/workstation/oauth/deviceAuthorization.js +31 -17
- package/dist/workstation/protocol/agent.js +2 -15
- package/dist/workstation/protocol/management.js +113 -5
- package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
- package/dist/workstation/rd/artifact.js +120 -0
- package/dist/workstation/rd/control.js +117 -0
- package/dist/workstation/rd/display.js +29 -0
- package/dist/workstation/rd/host.js +213 -0
- package/dist/workstation/rd/migrationReadiness.js +46 -0
- package/dist/workstation/rd/provider.js +256 -0
- package/dist/workstation/rd/sessions.js +391 -0
- package/dist/workstation/rd/state.js +57 -0
- package/dist/workstation/rd/update.js +168 -0
- package/dist/workstation/rd/upkeep.js +80 -0
- package/dist/workstation/register.js +27 -37
- package/dist/workstation/registrations/enrollment.js +250 -0
- package/dist/workstation/registrations/enrollmentPending.js +361 -0
- package/dist/workstation/registrations/migration.js +356 -0
- package/dist/workstation/registrations/model.js +34 -0
- package/dist/workstation/registrations/removal.js +222 -0
- package/dist/workstation/registrations/store.js +737 -0
- package/dist/workstation/runtime/desktopBinding.js +20 -0
- package/dist/workstation/runtime/deviceDesktop.js +193 -0
- package/dist/workstation/runtime/deviceRuntime.js +568 -0
- package/dist/workstation/runtime/deviceServices.js +117 -0
- package/dist/workstation/runtime/events.js +20 -0
- package/dist/workstation/runtime/ownership.js +100 -0
- package/dist/workstation/runtime/registrationRuntime.js +575 -0
- package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
- package/dist/workstation/runtime/removalActivity.js +184 -0
- package/dist/workstation/service/installer.js +197 -14
- package/dist/workstation/service/launchd.js +2 -0
- package/dist/workstation/service/systemd.js +1 -0
- package/dist/workstation/storageMetrics.js +118 -0
- package/dist/workstation/systemMetrics.js +47 -92
- package/dist/workstation/toolchain.js +94 -0
- package/dist/workstation/tools/browser.js +28 -4
- package/dist/workstation/tools/client.js +8 -27
- package/dist/workstation/tools/info.js +6 -13
- package/dist/workstation/tools/terminalRouter.js +17 -172
- package/dist/workstation/tools/validation.js +55 -1
- package/dist/workstation/update/archiveEvidence.js +206 -0
- package/dist/workstation/update/artifact.js +2 -1
- package/dist/workstation/update/coordinator.js +34 -13
- package/dist/workstation/update/deviceCompatibility.js +119 -0
- package/dist/workstation/update/deviceCoordinator.js +369 -0
- package/dist/workstation/update/deviceHealth.js +104 -0
- package/dist/workstation/update/deviceHealthProtocol.js +26 -0
- package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
- package/dist/workstation/update/deviceRollback.js +78 -0
- package/dist/workstation/update/globalInstall.js +186 -0
- package/dist/workstation/update/legacyBridge.js +414 -0
- package/dist/workstation/update/legacyOwner.js +39 -0
- package/dist/workstation/update/npmInstaller.js +51 -25
- package/dist/workstation/update/privateDurability.js +61 -0
- package/dist/workstation/update/rdRollback.js +145 -0
- package/dist/workstation/update/recoveryPlan.js +124 -0
- package/dist/workstation/update/runUpdate.js +10 -3
- package/dist/workstation/update/taskRegistry.js +161 -3
- package/dist/workstation/update/watchdog.js +29806 -333
- package/dist/workstation/update/watchdogProcess.js +72 -0
- package/package.json +11 -7
- 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/automationPreparation.js} +0 -0
|
@@ -11,7 +11,8 @@ import { TaskRegistry } from '../update/taskRegistry.js';
|
|
|
11
11
|
import { toFileError } from '../files/errors.js';
|
|
12
12
|
import { object } from '../tools/validation.js';
|
|
13
13
|
import { isUnaccountedTool } from '../tools/router.js';
|
|
14
|
-
import { requestRootFileExecutors } from '../files/adapters/computerFileFrames.js';
|
|
14
|
+
import { requestRootFileExecutors, fileErrorFrame } from '../files/adapters/computerFileFrames.js';
|
|
15
|
+
import { DeviceEventRouter } from '../runtime/events.js';
|
|
15
16
|
/**
|
|
16
17
|
* Consumes computer frames without serializing long-running commands behind one another.
|
|
17
18
|
* All command failures and post-close sends are contained here because this is called detached
|
|
@@ -211,6 +212,20 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
|
|
|
211
212
|
};
|
|
212
213
|
try {
|
|
213
214
|
for await (const frame of messages) {
|
|
215
|
+
if (durable.admission?.() === false) {
|
|
216
|
+
if (frame.t === 'exec_request') {
|
|
217
|
+
safeSend({ t: 'exec_exit', id: frame.id, exitCode: null, stdout: '', stderr: 'Registration admission is paused', timedOut: false, truncated: false });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (frame.t === 'tool_request' && frame.method !== 'tools.info') {
|
|
221
|
+
safeSend({ t: 'tool_result', id: frame.id, ok: false, result: {}, error: 'Registration admission is paused' });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (frame.t === 'file_write_request' || frame.t === 'file_mkdir_request') {
|
|
225
|
+
safeSend(fileErrorFrame(frame, { code: 'workstation_offline', message: 'Registration admission is paused' }));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
214
229
|
if (frame.t.startsWith('desktop_'))
|
|
215
230
|
desktop?.onFrame(frame);
|
|
216
231
|
else if (frame.t === 'tool_request')
|
|
@@ -279,9 +294,17 @@ export class ComputerChannel {
|
|
|
279
294
|
return { closed: channel.closed, close: () => channel.close() };
|
|
280
295
|
}
|
|
281
296
|
async dispatch(channel) {
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
const registrationId = this.options.registrationId;
|
|
298
|
+
const events = registrationId ? new DeviceEventRouter((_recipient, event) => channel.send(event.payload), new Set([registrationId])) : undefined;
|
|
299
|
+
const send = (frame) => {
|
|
300
|
+
if (events && registrationId)
|
|
301
|
+
events.publish({ scope: 'registration', registrationId, payload: frame });
|
|
302
|
+
else
|
|
303
|
+
channel.send(frame);
|
|
304
|
+
};
|
|
305
|
+
const desktop = new DesktopBridge(this.options.desktopEnabled === false ? undefined : resolveBrowserBinding(this.options.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
|
|
306
|
+
await dispatchComputerFrames(channel.messages, send, this.options.runExec ?? ((request, onOutput, signal) => runExec(request, onOutput, signal)), this.tasks, this.options, this.tools, desktop, {
|
|
307
|
+
host: this.execs, bufferedAmount: () => channel.bufferedAmount, log: this.options.log, admission: this.options.admission,
|
|
285
308
|
});
|
|
286
309
|
}
|
|
287
310
|
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { deriveSessionKey, randomBytes } from '../crypto.js';
|
|
2
2
|
import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
|
|
3
3
|
import { CLIENT_PACKAGE_NAME, CLIENT_VERSION } from '../version.js';
|
|
4
|
-
import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_GPU_VERSION, MANAGEMENT_WIRE_KEEPERS_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, } from '../protocol/management.js';
|
|
4
|
+
import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_GPU_VERSION, MANAGEMENT_WIRE_KEEPERS_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, ManagementFrameAdmission, } from '../protocol/management.js';
|
|
5
5
|
import { bridgesForWire, helloRoster } from '../protocol/common.js';
|
|
6
6
|
import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_VERSION } from '../protocol/agent.js';
|
|
7
7
|
import { osInfo } from '../osinfo.js';
|
|
8
8
|
import { workstationChannelUrl } from './url.js';
|
|
9
|
+
import { DesktopCapabilityReporter } from '../desktopControl/capabilities.js';
|
|
9
10
|
const protocols = {
|
|
10
11
|
MANAGEMENT: { min: MANAGEMENT_WIRE_MIN_VERSION, max: MANAGEMENT_WIRE_VERSION },
|
|
11
12
|
// COMPUTER v5: durable exec frames (exec_started/attach/status/ack, offsets on exec_output; spec §4.3).
|
|
12
|
-
COMPUTER:
|
|
13
|
+
// COMPUTER v6: this client refuses credential storage to the agent file tools (files/credentials.ts); the server sends
|
|
14
|
+
// file frames only to v6 clients.
|
|
15
|
+
COMPUTER: { min: 1, max: 6 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
|
|
13
16
|
};
|
|
17
|
+
function toolchainField(toolchain) {
|
|
18
|
+
return toolchain ? { toolchain: { python: toolchain.python, node: toolchain.node } } : {};
|
|
19
|
+
}
|
|
14
20
|
/** Exported so tests can assert the wire frame's `clientPackage` without opening a socket. */
|
|
15
21
|
export function buildHelloFrame(options, clientNonce) {
|
|
16
22
|
return {
|
|
@@ -91,7 +97,16 @@ export function metricsForManagementWire(metrics, serverManagementWire) {
|
|
|
91
97
|
*
|
|
92
98
|
* Exported for tests; production's only caller is `routeMessages`.
|
|
93
99
|
*/
|
|
94
|
-
export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire) {
|
|
100
|
+
export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire, requiredRemoteUpdatePolicy) {
|
|
101
|
+
if (requiredRemoteUpdatePolicy && command.remoteForcePolicy !== requiredRemoteUpdatePolicy) {
|
|
102
|
+
if (channel.state === 'OPEN') {
|
|
103
|
+
try {
|
|
104
|
+
channel.send(toClientUpdateStatusFrame(command, { phase: 'FAILED', remoteForcePolicy: 'drain-only', error: 'upgrade_required: the server must support authenticated device update readiness' }, serverManagementWire));
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
}
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
}
|
|
95
110
|
return onUpdate(command)
|
|
96
111
|
.then((status) => {
|
|
97
112
|
if (channel.state !== 'OPEN')
|
|
@@ -177,9 +192,43 @@ export class ManagementChannel {
|
|
|
177
192
|
serverManagementWire;
|
|
178
193
|
/** The open channel of the current connection, for frames this client sends unprompted ({@link pushRoster}). */
|
|
179
194
|
channel;
|
|
195
|
+
desktopReporter;
|
|
196
|
+
connectionAttempt = 0;
|
|
197
|
+
generation;
|
|
180
198
|
constructor(options) {
|
|
181
199
|
this.options = options;
|
|
182
200
|
}
|
|
201
|
+
async receiveDeviceFact(event) {
|
|
202
|
+
const generation = this.generation;
|
|
203
|
+
const channel = this.channel;
|
|
204
|
+
if (event.scope !== 'device' || !generation || channel?.state !== 'OPEN')
|
|
205
|
+
return;
|
|
206
|
+
if (event.kind === 'tool-state' && this.options.onExternalAgentsScan) {
|
|
207
|
+
const bridges = await this.options.onExternalAgentsScan();
|
|
208
|
+
if (this.generation === generation && this.channel === channel)
|
|
209
|
+
this.pushRoster(bridges);
|
|
210
|
+
}
|
|
211
|
+
else if (event.kind === 'desktop-capability')
|
|
212
|
+
this.refreshDesktopCapabilities(generation);
|
|
213
|
+
}
|
|
214
|
+
refreshDesktopCapabilities(generation) {
|
|
215
|
+
if (generation === undefined || generation === this.generation)
|
|
216
|
+
this.desktopReporter?.refresh();
|
|
217
|
+
}
|
|
218
|
+
/** T10 reports only on its authenticated current management socket. */
|
|
219
|
+
sendDesktopState(generation, frame) {
|
|
220
|
+
if (frame.t === 'desktop_ownership_result' && (this.serverManagementWire ?? 0) < 10)
|
|
221
|
+
return false;
|
|
222
|
+
if (generation !== this.generation || (this.serverManagementWire ?? 0) < 9 || this.channel?.state !== 'OPEN')
|
|
223
|
+
return false;
|
|
224
|
+
try {
|
|
225
|
+
this.channel.send(frame);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
183
232
|
/**
|
|
184
233
|
* Tell the server about a roster that changed without a scan asking (adapter builds moved by Axom,
|
|
185
234
|
* spec §6.1): the same `external_agents_scan_result` frame a scan answers with, under a client-made
|
|
@@ -199,14 +248,22 @@ export class ManagementChannel {
|
|
|
199
248
|
}
|
|
200
249
|
}
|
|
201
250
|
async connect(signal) {
|
|
251
|
+
const attempt = ++this.connectionAttempt;
|
|
252
|
+
this.desktopReporter?.dispose();
|
|
253
|
+
this.desktopReporter = undefined;
|
|
254
|
+
this.generation = undefined;
|
|
255
|
+
this.channel = undefined;
|
|
202
256
|
this.serverManagementWire = undefined;
|
|
203
257
|
const nonce = randomBytes(16);
|
|
204
258
|
let initialHeartbeat = true;
|
|
259
|
+
const admission = new ManagementFrameAdmission(() => attempt === this.connectionAttempt && !signal?.aborted);
|
|
205
260
|
const channel = await connectEncryptedChannel({
|
|
206
261
|
url: workstationChannelUrl(this.options.server, 'management', this.options.allowInsecureWs),
|
|
207
262
|
signal,
|
|
208
263
|
encode: encodeManagementFrame,
|
|
209
264
|
decode: decodeManagementFrame,
|
|
265
|
+
decodeBytes: decodeManagementFrame,
|
|
266
|
+
admitFrame: frame => admission.admit(frame),
|
|
210
267
|
heartbeat: (seq, sentAtEpochMs) => this.heartbeat(seq, sentAtEpochMs),
|
|
211
268
|
initialFrame: () => {
|
|
212
269
|
initialHeartbeat = false;
|
|
@@ -226,16 +283,41 @@ export class ManagementChannel {
|
|
|
226
283
|
if (initialHeartbeat)
|
|
227
284
|
throw new Error('Management proof heartbeat was not sent');
|
|
228
285
|
const ready = await this.nextReady(channel);
|
|
286
|
+
if (attempt !== this.connectionAttempt || channel.state !== 'OPEN') {
|
|
287
|
+
channel.close();
|
|
288
|
+
throw new Error('Management connection superseded');
|
|
289
|
+
}
|
|
229
290
|
this.serverManagementWire = ready.protocols.MANAGEMENT;
|
|
291
|
+
this.generation = ready.generation;
|
|
292
|
+
this.channel = channel;
|
|
293
|
+
const reporter = new DesktopCapabilityReporter({ selectedWire: this.serverManagementWire, generation: ready.generation,
|
|
294
|
+
provider: this.options.desktopProvider, send: report => {
|
|
295
|
+
if (attempt === this.connectionAttempt && this.channel === channel && channel.state === 'OPEN')
|
|
296
|
+
channel.send({ t: 'workstation_capabilities', ...report });
|
|
297
|
+
} });
|
|
298
|
+
this.desktopReporter = reporter;
|
|
299
|
+
// Starts before the existing awaited update hook; heartbeat and reporting never await discovery.
|
|
300
|
+
reporter.refresh();
|
|
301
|
+
const retire = () => {
|
|
302
|
+
admission.dispose();
|
|
303
|
+
reporter.dispose();
|
|
304
|
+
try {
|
|
305
|
+
this.options.onClosed?.(ready.generation);
|
|
306
|
+
}
|
|
307
|
+
catch { /* transport retirement must complete */ }
|
|
308
|
+
if (this.channel === channel) {
|
|
309
|
+
this.channel = undefined;
|
|
310
|
+
this.generation = undefined;
|
|
311
|
+
this.desktopReporter = undefined;
|
|
312
|
+
this.serverManagementWire = undefined;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
void channel.closed.then(retire, retire);
|
|
230
316
|
// A readiness record is evidence from this encrypted, proof-of-key management generation — it
|
|
231
317
|
// is never driven by the local browser/device-authorization flow.
|
|
232
|
-
await this.options.onReady?.({ generation: ready.generation, release: ready.release });
|
|
318
|
+
await this.options.onReady?.({ generation: ready.generation, selectedWire: ready.protocols.MANAGEMENT, release: ready.release });
|
|
233
319
|
const pending = new Map();
|
|
234
|
-
this.channel
|
|
235
|
-
void channel.closed.then(() => { if (this.channel === channel)
|
|
236
|
-
this.channel = undefined; }, () => { if (this.channel === channel)
|
|
237
|
-
this.channel = undefined; });
|
|
238
|
-
void this.routeMessages(channel, pending);
|
|
320
|
+
void this.routeMessages(channel, pending, ready.generation);
|
|
239
321
|
return {
|
|
240
322
|
generation: ready.generation,
|
|
241
323
|
closed: channel.closed,
|
|
@@ -255,6 +337,11 @@ export class ManagementChannel {
|
|
|
255
337
|
};
|
|
256
338
|
}
|
|
257
339
|
async heartbeat(seq, sentAtEpochMs) {
|
|
340
|
+
const selectedWire = this.serverManagementWire;
|
|
341
|
+
// Availability may change without restarting the CLI (Desktop sharing is process-owned).
|
|
342
|
+
// Discovery stays bounded and never delays the heartbeat or creates a capture session.
|
|
343
|
+
this.desktopReporter?.refresh();
|
|
344
|
+
const revision = this.desktopReporter?.revision;
|
|
258
345
|
const snapshot = await this.options.snapshot();
|
|
259
346
|
return {
|
|
260
347
|
t: 'heartbeat', seq, sentAtEpochMs, clientVersion: CLIENT_VERSION,
|
|
@@ -266,11 +353,13 @@ export class ManagementChannel {
|
|
|
266
353
|
},
|
|
267
354
|
activeShellTasks: snapshot.activeShellTasks, activeAgentTasks: snapshot.activeAgentTasks,
|
|
268
355
|
protocols, os: osInfo(),
|
|
269
|
-
...(this.options.
|
|
270
|
-
|
|
356
|
+
...toolchainField(this.options.toolchain?.() ?? null),
|
|
357
|
+
...(this.options.metrics && (selectedWire ?? 0) >= MANAGEMENT_WIRE_METRICS_VERSION
|
|
358
|
+
? { metrics: metricsForManagementWire(this.options.metrics(), selectedWire) }
|
|
271
359
|
: {}),
|
|
272
|
-
...(snapshot.update ? { update: statusForManagementWire(snapshot.update,
|
|
273
|
-
...await keepersForManagementWire(this.options.keepers,
|
|
360
|
+
...(snapshot.update ? { update: statusForManagementWire(snapshot.update, selectedWire) } : {}),
|
|
361
|
+
...await keepersForManagementWire(this.options.keepers, selectedWire, this.options.keepersCapMs),
|
|
362
|
+
...((selectedWire ?? 0) >= 9 && revision !== undefined ? { capabilitiesRevision: revision } : {}),
|
|
274
363
|
};
|
|
275
364
|
}
|
|
276
365
|
async nextReady(channel) {
|
|
@@ -279,9 +368,35 @@ export class ManagementChannel {
|
|
|
279
368
|
return frame;
|
|
280
369
|
throw new Error('Management channel closed before ready');
|
|
281
370
|
}
|
|
282
|
-
async routeMessages(channel, pending) {
|
|
371
|
+
async routeMessages(channel, pending, generation) {
|
|
283
372
|
try {
|
|
284
373
|
for await (const frame of channel.messages) {
|
|
374
|
+
if (channel !== this.channel)
|
|
375
|
+
break;
|
|
376
|
+
if (frame.t === 'desktop_automation_prepare') {
|
|
377
|
+
if (frame.deviceGeneration === this.generation && this.generation && this.options.onAutomationPrepare)
|
|
378
|
+
void this.options.onAutomationPrepare(frame, this.generation).catch(() => { });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (frame.t === 'workstation_capabilities_request') {
|
|
382
|
+
if ((this.serverManagementWire ?? 0) >= 9 && frame.generation === this.generation)
|
|
383
|
+
this.desktopReporter?.refresh();
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if ((frame.t === 'desktop_background' || frame.t === 'desktop_lobby' || frame.t === 'desktop_prepare' || frame.t === 'desktop_cancel' || frame.t === 'desktop_ownership') && (this.serverManagementWire ?? 0) >= 9) {
|
|
387
|
+
if (frame.t === 'desktop_ownership' && (this.serverManagementWire ?? 0) < 10)
|
|
388
|
+
continue;
|
|
389
|
+
if (frame.t !== 'desktop_cancel' && frame.deviceGeneration !== this.generation)
|
|
390
|
+
continue;
|
|
391
|
+
if (this.options.onDesktopCommand && this.generation) {
|
|
392
|
+
// Capture the authenticated generation now: later callbacks cannot borrow a reconnect.
|
|
393
|
+
try {
|
|
394
|
+
void this.options.onDesktopCommand(frame, this.generation).catch(() => undefined);
|
|
395
|
+
}
|
|
396
|
+
catch { /* bounded desktop admission failure */ }
|
|
397
|
+
}
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
285
400
|
if (frame.t === 'channel_ticket') {
|
|
286
401
|
const queue = pending.get(frame.channel);
|
|
287
402
|
queue?.shift()?.resolve(frame.ticket);
|
|
@@ -290,7 +405,7 @@ export class ManagementChannel {
|
|
|
290
405
|
if (frame.t === 'client_update' && this.options.onUpdate) {
|
|
291
406
|
// Deliberately not awaited — see dispatchUpdateCommand for why inline handling both
|
|
292
407
|
// stalled this loop for the whole update and threw the final status away.
|
|
293
|
-
void dispatchUpdateCommand(channel, frame, this.options.onUpdate, this.serverManagementWire);
|
|
408
|
+
void dispatchUpdateCommand(channel, frame, command => this.options.onUpdate(command, generation), this.serverManagementWire, this.options.requiredRemoteUpdatePolicy);
|
|
294
409
|
continue;
|
|
295
410
|
}
|
|
296
411
|
if (frame.t === 'keeper_control' && this.options.onKeeperControl) {
|
|
@@ -11,7 +11,7 @@ function ipv4Octets(hostname) {
|
|
|
11
11
|
return octets.every((octet) => octet <= 255) ? octets : null;
|
|
12
12
|
}
|
|
13
13
|
/** Deliberately classifies literal names/IPs only: DNS answers must never downgrade transport security. */
|
|
14
|
-
function isTrustedPlaintextHost(rawHostname) {
|
|
14
|
+
export function isTrustedPlaintextHost(rawHostname) {
|
|
15
15
|
const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
16
16
|
if (TRUSTED_PLAINTEXT_NAMES.has(hostname) || hostname.endsWith('.localhost'))
|
|
17
17
|
return true;
|
|
@@ -1,20 +1,34 @@
|
|
|
1
|
+
import { loadManagedCliInstallSync, npmExecutionContext } from './managedInstall.js';
|
|
1
2
|
import { resolveBrowserBinding } from './desktop/binding.js';
|
|
2
3
|
import { promises as fs } from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import yaml from 'js-yaml';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { withWorkstationLock } from './control/locks.js';
|
|
10
|
+
import { assertLegacyRoot } from './control/rootFormat.js';
|
|
11
|
+
const localPath = z.string().refine(p => path.isAbsolute(p) && !p.includes('\0'));
|
|
12
|
+
const nativeConfig = z.strictObject({ kind: z.literal('linux-native'), helperPath: localPath, runtimeDir: localPath });
|
|
7
13
|
export function updateServiceContext(configDir = clientDir(), overrides = {}) {
|
|
8
14
|
// config.ts lives in dist/workstation/ after packaging, so two levels up is the installed package root.
|
|
9
15
|
const packageRoot = overrides.packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
|
-
const
|
|
16
|
+
const managed = loadManagedCliInstallSync(packageRoot);
|
|
17
|
+
const nodePath = managed?.nodePath ?? overrides.nodePath ?? process.execPath;
|
|
18
|
+
const npmPrefix = managed?.prefix ?? overrides.npmPrefix ?? process.env.AXOM_WORKSTATION_NPM_PREFIX ?? path.resolve(packageRoot, '../../../..');
|
|
19
|
+
// A previous sudo npm command can leave the interactive user's cache root-owned. Updates and
|
|
20
|
+
// their detached rollback watchdog share a cache owned by this workstation service instead.
|
|
21
|
+
const env = { ...npmExecutionContext({ prefix: npmPrefix, nodePath }, process.env), NPM_CONFIG_CACHE: path.join(configDir, 'update/npm-cache') };
|
|
22
|
+
delete env.npm_config_cache;
|
|
11
23
|
return {
|
|
12
24
|
configDir,
|
|
25
|
+
...(managed ? { managed } : {}),
|
|
26
|
+
env,
|
|
13
27
|
platform: overrides.platform ?? (process.platform === 'darwin' ? 'darwin' : 'linux'),
|
|
14
28
|
nodePath,
|
|
15
|
-
npmPath: overrides.npmPath ?? process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(path.dirname(nodePath), 'npm'),
|
|
29
|
+
npmPath: managed?.npmPath ?? overrides.npmPath ?? process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(path.dirname(nodePath), 'npm'),
|
|
16
30
|
// packageRoot is under lib/node_modules/@platless; climb to npm's global prefix.
|
|
17
|
-
npmPrefix
|
|
31
|
+
npmPrefix,
|
|
18
32
|
packageRoot,
|
|
19
33
|
};
|
|
20
34
|
}
|
|
@@ -25,8 +39,11 @@ export function clientDir() {
|
|
|
25
39
|
return process.env.AXOM_WORKSTATION_CONFIG_DIR ?? path.join(os.homedir(), '.axom', 'workstation');
|
|
26
40
|
}
|
|
27
41
|
export async function saveClientConfig(dir, cfg) {
|
|
28
|
-
await
|
|
29
|
-
|
|
42
|
+
await withWorkstationLock(dir, 'operation', async () => {
|
|
43
|
+
await assertLegacyRoot(dir);
|
|
44
|
+
await fs.mkdir(dir, { recursive: true });
|
|
45
|
+
await fs.writeFile(path.join(dir, 'config.yml'), yaml.dump(cfg), { mode: 0o600 });
|
|
46
|
+
});
|
|
30
47
|
}
|
|
31
48
|
export async function loadClientConfig(dir) {
|
|
32
49
|
const raw = await fs.readFile(path.join(dir, 'config.yml'), 'utf8');
|
|
@@ -35,12 +52,19 @@ export async function loadClientConfig(dir) {
|
|
|
35
52
|
throw new Error('Invalid workstation config: server and workstationId are required; run axom auth again');
|
|
36
53
|
}
|
|
37
54
|
const browserBinding = parsed.browserBinding ? resolveBrowserBinding(parsed.browserBinding, {}) : undefined;
|
|
38
|
-
|
|
55
|
+
const desktopProvider = parsed.desktopProvider ? nativeConfig.parse({ ...parsed.desktopProvider,
|
|
56
|
+
helperPath: parsed.desktopProvider.helperPath === '/usr/local/bin/axom-desktop-helper' ? '/usr/local/bin/axom-desktop-keeper' : parsed.desktopProvider.helperPath,
|
|
57
|
+
}) : process.env.AXOM_DESKTOP_NATIVE_PROVIDER === '1' ?
|
|
58
|
+
nativeConfig.parse({ kind: 'linux-native', helperPath: '/usr/local/bin/axom-desktop-keeper', runtimeDir: '/run/axom-desktop' }) : undefined;
|
|
59
|
+
return { ...(browserBinding ? { browserBinding } : {}), ...(desktopProvider ? { desktopProvider } : {}), server: parsed.server, workstationId: parsed.workstationId, ...(typeof parsed.ownerUserId === 'string' && parsed.ownerUserId ? { ownerUserId: parsed.ownerUserId } : {}), ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
|
|
39
60
|
}
|
|
40
61
|
export async function writeKeys(dir, clientKeyB64, serverPubB64) {
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
|
|
62
|
+
await withWorkstationLock(dir, 'operation', async () => {
|
|
63
|
+
await assertLegacyRoot(dir);
|
|
64
|
+
await fs.mkdir(dir, { recursive: true });
|
|
65
|
+
await fs.writeFile(path.join(dir, 'client.key'), clientKeyB64, { mode: 0o600 });
|
|
66
|
+
await fs.writeFile(path.join(dir, 'server.pub'), serverPubB64);
|
|
67
|
+
});
|
|
44
68
|
}
|
|
45
69
|
export async function readKeys(dir) {
|
|
46
70
|
const clientKey = Buffer.from((await fs.readFile(path.join(dir, 'client.key'), 'utf8')).trim(), 'base64');
|
|
@@ -279,7 +279,10 @@ export function connectEncryptedChannel(options) {
|
|
|
279
279
|
if (!isBinary)
|
|
280
280
|
return;
|
|
281
281
|
try {
|
|
282
|
-
const
|
|
282
|
+
const plaintext = crypto.open(sessionKey, bytes);
|
|
283
|
+
const frame = options.decodeBytes ? options.decodeBytes(plaintext) : options.decode(plaintext.toString());
|
|
284
|
+
if (options.admitFrame && !options.admitFrame(frame))
|
|
285
|
+
return;
|
|
283
286
|
const acknowledged = [...outstandingAcks].some((sequence) => options.isHeartbeatAck(frame, sequence));
|
|
284
287
|
// A correctly encrypted and decoded frame proves the peer is still reachable. Heartbeat
|
|
285
288
|
// acknowledgements are one such frame, but computer streaming/agent events may legitimately
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
import { assertLegacyRoot } from './rootFormat.js';
|
|
11
|
+
const IDENTITY_FILES = ['client.key', 'server.pub', 'config.yml'];
|
|
12
|
+
const missing = (error) => error.code === 'ENOENT';
|
|
13
|
+
async function readPrivateFile(file) {
|
|
14
|
+
const stat = await fs.lstat(file);
|
|
15
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || stat.size > 1_048_576)
|
|
16
|
+
throw new Error('Unsafe workstation identity file');
|
|
17
|
+
return fs.readFile(file, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
async function exists(file) {
|
|
20
|
+
try {
|
|
21
|
+
await fs.lstat(file);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (missing(error))
|
|
26
|
+
return false;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function atomicWrite(file, value) {
|
|
31
|
+
assertLockOwnership();
|
|
32
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
const handle = await fs.open(temporary, 'wx', 0o600);
|
|
35
|
+
try {
|
|
36
|
+
await handle.writeFile(value);
|
|
37
|
+
await handle.sync();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
await fs.rename(temporary, file);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await fs.rm(temporary, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function syncDirectory(directory) {
|
|
49
|
+
const handle = await fs.open(directory, 'r');
|
|
50
|
+
try {
|
|
51
|
+
await handle.sync();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function validateKeys(keys) {
|
|
58
|
+
for (const value of [keys.clientKey, keys.serverPub]) {
|
|
59
|
+
if (!/^[A-Za-z0-9+/]{43}=$/.test(value) || Buffer.from(value, 'base64').length !== 32 || Buffer.from(value, 'base64').toString('base64') !== value) {
|
|
60
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Workstation connection keys are invalid');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function bindingOf(config) {
|
|
65
|
+
return bindingSchema.parse({ server: normalizeWorkstationServer(config.server), workstationId: config.workstationId, ...(config.ownerUserId ? { ownerUserId: config.ownerUserId } : {}) });
|
|
66
|
+
}
|
|
67
|
+
/** Caller holds the shared operation lock for every mutating method. */
|
|
68
|
+
export class BindingStore {
|
|
69
|
+
directory;
|
|
70
|
+
pending;
|
|
71
|
+
constructor(directory) {
|
|
72
|
+
this.directory = directory;
|
|
73
|
+
this.pending = path.join(directory, 'identity.pending');
|
|
74
|
+
}
|
|
75
|
+
async inspect() {
|
|
76
|
+
const pending = await exists(this.pending);
|
|
77
|
+
const present = await Promise.all(IDENTITY_FILES.map(file => exists(path.join(this.directory, file))));
|
|
78
|
+
if (!present.some(Boolean))
|
|
79
|
+
return { binding: null, revision: 'unregistered', identityState: pending ? 'incomplete' : 'absent' };
|
|
80
|
+
let readableBinding = null;
|
|
81
|
+
let configDigest = 'corrupt';
|
|
82
|
+
try {
|
|
83
|
+
configDigest = createHash('sha256').update(await readPrivateFile(path.join(this.directory, 'config.yml'))).digest('hex');
|
|
84
|
+
await readPrivateFile(path.join(this.directory, 'config.yml'));
|
|
85
|
+
const binding = bindingOf(await loadClientConfig(this.directory));
|
|
86
|
+
readableBinding = binding;
|
|
87
|
+
const keys = { clientKey: (await readPrivateFile(path.join(this.directory, 'client.key'))).trim(), serverPub: (await readPrivateFile(path.join(this.directory, 'server.pub'))).trim() };
|
|
88
|
+
validateKeys(keys);
|
|
89
|
+
const revision = createHash('sha256').update(JSON.stringify(binding)).update('\0').update(keys.clientKey).update('\0').update(keys.serverPub).digest('hex');
|
|
90
|
+
return { binding, revision, identityState: pending ? 'incomplete' : 'ready' };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { binding: readableBinding, revision: configDigest, identityState: pending ? 'incomplete' : 'corrupt' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async history() {
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(await readPrivateFile(path.join(this.directory, 'binding-history.json')));
|
|
99
|
+
if (!Array.isArray(data))
|
|
100
|
+
throw new Error('Invalid binding history');
|
|
101
|
+
return data.map(value => bindingSchema.parse(value));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (missing(error))
|
|
105
|
+
return [];
|
|
106
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Workstation binding history is unreadable');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async recover() {
|
|
110
|
+
await assertLegacyRoot(this.directory);
|
|
111
|
+
if (!await exists(this.pending))
|
|
112
|
+
return;
|
|
113
|
+
const stat = await fs.lstat(this.pending);
|
|
114
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
115
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Unsafe pending workstation identity');
|
|
116
|
+
let marker;
|
|
117
|
+
try {
|
|
118
|
+
marker = await readPrivateFile(path.join(this.pending, 'ready'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration needs verification: incomplete local transaction');
|
|
122
|
+
}
|
|
123
|
+
if (marker === 'clear') {
|
|
124
|
+
await this.finishClear();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (marker !== '1')
|
|
128
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Unknown workstation identity transaction');
|
|
129
|
+
const configText = await readPrivateFile(path.join(this.pending, 'config.yml'));
|
|
130
|
+
const config = await loadClientConfig(this.pending);
|
|
131
|
+
bindingOf(config);
|
|
132
|
+
const keys = { clientKey: (await readPrivateFile(path.join(this.pending, 'client.key'))).trim(), serverPub: (await readPrivateFile(path.join(this.pending, 'server.pub'))).trim() };
|
|
133
|
+
validateKeys(keys);
|
|
134
|
+
const operation = operationRecordSchema.parse(JSON.parse(await readPrivateFile(path.join(this.pending, 'operation.json'))));
|
|
135
|
+
let history;
|
|
136
|
+
if (await exists(path.join(this.pending, 'binding-history.json'))) {
|
|
137
|
+
const staged = JSON.parse(await readPrivateFile(path.join(this.pending, 'binding-history.json')));
|
|
138
|
+
if (!Array.isArray(staged))
|
|
139
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Invalid staged binding history');
|
|
140
|
+
history = staged.map(value => bindingSchema.parse(value));
|
|
141
|
+
}
|
|
142
|
+
// All staging is validated before any existing identity file changes.
|
|
143
|
+
for (const [name, value] of [['client.key', keys.clientKey], ['server.pub', keys.serverPub], ['config.yml', configText]])
|
|
144
|
+
await atomicWrite(path.join(this.directory, name), value);
|
|
145
|
+
if (history)
|
|
146
|
+
await atomicWrite(path.join(this.directory, 'binding-history.json'), JSON.stringify(history));
|
|
147
|
+
await atomicWrite(path.join(this.directory, 'operation.json'), JSON.stringify(operation));
|
|
148
|
+
await syncDirectory(this.directory);
|
|
149
|
+
await fs.rm(this.pending, { recursive: true });
|
|
150
|
+
await syncDirectory(this.directory);
|
|
151
|
+
}
|
|
152
|
+
async commit(config, keys, operation, repair = false) {
|
|
153
|
+
await assertLegacyRoot(this.directory);
|
|
154
|
+
const binding = bindingOf(config);
|
|
155
|
+
validateKeys(keys);
|
|
156
|
+
const safeOperation = operationRecordSchema.parse(operation);
|
|
157
|
+
await privateDirectory(this.directory);
|
|
158
|
+
await this.recover();
|
|
159
|
+
const current = await this.inspect();
|
|
160
|
+
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))
|
|
161
|
+
throw new LocalControlError('CORRUPT_IDENTITY', 'Existing workstation identity needs repair');
|
|
162
|
+
const history = await this.history();
|
|
163
|
+
for (const previous of [current.binding, binding])
|
|
164
|
+
if (previous) {
|
|
165
|
+
const i = history.findIndex(item => item.server === previous.server && item.ownerUserId === previous.ownerUserId);
|
|
166
|
+
if (i >= 0)
|
|
167
|
+
history.splice(i, 1);
|
|
168
|
+
history.push(previous);
|
|
169
|
+
}
|
|
170
|
+
await fs.mkdir(this.pending, { mode: 0o700 });
|
|
171
|
+
for (const [name, value] of Object.entries({
|
|
172
|
+
'config.yml': yaml.dump({ ...config, server: binding.server }), 'client.key': keys.clientKey, 'server.pub': keys.serverPub,
|
|
173
|
+
'operation.json': JSON.stringify(safeOperation), 'binding-history.json': JSON.stringify(history),
|
|
174
|
+
}))
|
|
175
|
+
await atomicWrite(path.join(this.pending, name), value);
|
|
176
|
+
await atomicWrite(path.join(this.pending, 'ready'), '1');
|
|
177
|
+
await syncDirectory(this.pending);
|
|
178
|
+
await syncDirectory(this.directory);
|
|
179
|
+
await this.recover();
|
|
180
|
+
}
|
|
181
|
+
async clear(expectedRevision) {
|
|
182
|
+
await assertLegacyRoot(this.directory);
|
|
183
|
+
await this.recover();
|
|
184
|
+
const current = await this.inspect();
|
|
185
|
+
if (current.revision !== expectedRevision || current.identityState !== 'ready')
|
|
186
|
+
throw new LocalControlError('BINDING_CONFLICT', 'Workstation identity changed; check it again');
|
|
187
|
+
await fs.mkdir(this.pending, { mode: 0o700 });
|
|
188
|
+
await atomicWrite(path.join(this.pending, 'clear.json'), JSON.stringify(current.binding));
|
|
189
|
+
await atomicWrite(path.join(this.pending, 'ready'), 'clear');
|
|
190
|
+
await syncDirectory(this.pending);
|
|
191
|
+
await syncDirectory(this.directory);
|
|
192
|
+
await this.finishClear();
|
|
193
|
+
}
|
|
194
|
+
async finishClear() {
|
|
195
|
+
const removed = bindingSchema.parse(JSON.parse(await readPrivateFile(path.join(this.pending, 'clear.json'))));
|
|
196
|
+
const history = (await this.history()).filter(item => item.server !== removed.server || item.workstationId !== removed.workstationId);
|
|
197
|
+
await atomicWrite(path.join(this.directory, 'binding-history.json'), JSON.stringify(history));
|
|
198
|
+
for (const name of [...IDENTITY_FILES, 'operation.json'])
|
|
199
|
+
await fs.rm(path.join(this.directory, name), { force: true });
|
|
200
|
+
await syncDirectory(this.directory);
|
|
201
|
+
await fs.rm(this.pending, { recursive: true });
|
|
202
|
+
await syncDirectory(this.directory);
|
|
203
|
+
}
|
|
204
|
+
}
|