@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
|
@@ -42,8 +42,11 @@ function requireString(value, field, description) {
|
|
|
42
42
|
throw new DeviceAuthorizationError(`${description} missing ${field}`);
|
|
43
43
|
return value;
|
|
44
44
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
45
|
+
function deviceEndpoint(server, endpoint, identity) {
|
|
46
|
+
return identity ? new URL(server + endpoint) : new URL(endpoint, server);
|
|
47
|
+
}
|
|
48
|
+
function websocketUrl(server, identity) {
|
|
49
|
+
const url = deviceEndpoint(server, '/oauth/device/ws', identity);
|
|
47
50
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
48
51
|
return url.toString();
|
|
49
52
|
}
|
|
@@ -89,23 +92,33 @@ function waitForNotification(socket, sessionId, notificationTicket, deadline, no
|
|
|
89
92
|
socket.on('error', () => finish('reconnect'));
|
|
90
93
|
});
|
|
91
94
|
}
|
|
92
|
-
async function exchangeDeviceCode(server, clientId, deviceCode, fetch_, requestedScopes) {
|
|
93
|
-
const response = await requestJson(fetch_,
|
|
95
|
+
async function exchangeDeviceCode(server, clientId, deviceCode, fetch_, requestedScopes, identity) {
|
|
96
|
+
const response = await requestJson(fetch_, deviceEndpoint(server, '/oauth/token', identity), {
|
|
94
97
|
method: 'POST',
|
|
95
98
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
96
99
|
body: new URLSearchParams({
|
|
97
100
|
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
98
101
|
client_id: clientId,
|
|
99
102
|
device_code: deviceCode,
|
|
100
|
-
device_id: await loadDeviceId(),
|
|
103
|
+
device_id: identity?.deviceId ?? await loadDeviceId(),
|
|
101
104
|
}),
|
|
102
105
|
}, 200, 'Device token exchange');
|
|
103
|
-
return validateTokenResponse(server, response, requestedScopes, fetch_);
|
|
106
|
+
return validateTokenResponse(server, response, requestedScopes, fetch_, identity);
|
|
104
107
|
}
|
|
105
|
-
async function validateTokenResponse(server, response, requestedScopes, fetch_) {
|
|
106
|
-
|
|
108
|
+
async function validateTokenResponse(server, response, requestedScopes, fetch_, identity) {
|
|
109
|
+
let accessToken;
|
|
110
|
+
try {
|
|
111
|
+
accessToken = requireString(response.access_token, 'access_token', 'Device token response');
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (identity && typeof response.refresh_token === 'string' && response.refresh_token)
|
|
115
|
+
await revokeRefreshToken(server, response.refresh_token, fetch_);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
107
118
|
const refreshToken = requireString(response.refresh_token, 'refresh_token', 'Device token response');
|
|
108
119
|
if (!Number.isFinite(response.expires_in) || response.expires_in <= 0) {
|
|
120
|
+
if (identity)
|
|
121
|
+
await revokeRefreshToken(server, refreshToken, fetch_);
|
|
109
122
|
throw new DeviceAuthorizationError('Device token response has invalid expires_in');
|
|
110
123
|
}
|
|
111
124
|
let scopes;
|
|
@@ -118,15 +131,15 @@ async function validateTokenResponse(server, response, requestedScopes, fetch_)
|
|
|
118
131
|
}
|
|
119
132
|
return { accessToken, refreshToken, expiresInSeconds: response.expires_in, scopes };
|
|
120
133
|
}
|
|
121
|
-
async function pollDeviceCode(server, clientId, deviceCode, fetch_, requestedScopes) {
|
|
122
|
-
const response = await fetch_(
|
|
134
|
+
async function pollDeviceCode(server, clientId, deviceCode, fetch_, requestedScopes, identity) {
|
|
135
|
+
const response = await fetch_(deviceEndpoint(server, '/oauth/token', identity), {
|
|
123
136
|
method: 'POST',
|
|
124
137
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
125
138
|
body: new URLSearchParams({
|
|
126
139
|
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
127
140
|
client_id: clientId,
|
|
128
141
|
device_code: deviceCode,
|
|
129
|
-
device_id: await loadDeviceId(),
|
|
142
|
+
device_id: identity?.deviceId ?? await loadDeviceId(),
|
|
130
143
|
}),
|
|
131
144
|
});
|
|
132
145
|
if (response.status === 400) {
|
|
@@ -141,13 +154,13 @@ async function pollDeviceCode(server, clientId, deviceCode, fetch_, requestedSco
|
|
|
141
154
|
if (response.status !== 200)
|
|
142
155
|
throw new DeviceAuthorizationError(`Device token polling failed: ${response.status}`);
|
|
143
156
|
const token = await response.json();
|
|
144
|
-
return validateTokenResponse(server, token, requestedScopes, fetch_);
|
|
157
|
+
return validateTokenResponse(server, token, requestedScopes, fetch_, identity);
|
|
145
158
|
}
|
|
146
159
|
/**
|
|
147
160
|
* Uses OAuth Device Authorization for both desktop and headless clients. The notification socket
|
|
148
161
|
* only carries state; the tokens exist only in this call's return value.
|
|
149
162
|
*/
|
|
150
|
-
export async function authorizeDevice(server, clientName, hostname, deps = {}, requestedScopes = DEFAULT_CLI_SCOPES) {
|
|
163
|
+
export async function authorizeDevice(server, clientName, hostname, deps = {}, requestedScopes = DEFAULT_CLI_SCOPES, identity) {
|
|
151
164
|
const baseUrl = normalizedServer(server);
|
|
152
165
|
const fetch_ = deps.fetch ?? globalThis.fetch;
|
|
153
166
|
const print = deps.print ?? console.log;
|
|
@@ -155,9 +168,10 @@ export async function authorizeDevice(server, clientName, hostname, deps = {}, r
|
|
|
155
168
|
const createWebSocket = deps.createWebSocket ?? ((url) => new WebSocket(url));
|
|
156
169
|
const now = deps.now ?? Date.now;
|
|
157
170
|
const wait = deps.sleep ?? sleep;
|
|
171
|
+
const flowIdentity = identity ? Object.freeze({ deviceId: requireString(identity.deviceId, 'deviceId', 'Device authorization identity') }) : undefined;
|
|
158
172
|
void clientName;
|
|
159
173
|
const clientId = 'axom-cli';
|
|
160
|
-
const authorization = await requestJson(fetch_,
|
|
174
|
+
const authorization = await requestJson(fetch_, deviceEndpoint(baseUrl, '/oauth/device_authorization', flowIdentity), {
|
|
161
175
|
method: 'POST',
|
|
162
176
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
163
177
|
body: new URLSearchParams({ client_id: clientId, hostname, scope: requestedScopes.join(' ') }),
|
|
@@ -176,7 +190,7 @@ export async function authorizeDevice(server, clientName, hostname, deps = {}, r
|
|
|
176
190
|
while (now() < deadline) {
|
|
177
191
|
let socket;
|
|
178
192
|
try {
|
|
179
|
-
socket = createWebSocket(websocketUrl(baseUrl));
|
|
193
|
+
socket = createWebSocket(websocketUrl(baseUrl, flowIdentity));
|
|
180
194
|
}
|
|
181
195
|
catch {
|
|
182
196
|
const delay = Math.min(retry.recordFailure(), Math.max(0, deadline - now()));
|
|
@@ -187,14 +201,14 @@ export async function authorizeDevice(server, clientName, hostname, deps = {}, r
|
|
|
187
201
|
}
|
|
188
202
|
const status = await waitForNotification(socket, sessionId, notificationTicket, deadline, now, () => retry.recordSuccess());
|
|
189
203
|
if (status === 'approved') {
|
|
190
|
-
const tokens = await exchangeDeviceCode(baseUrl, clientId, deviceCode, fetch_, requestedScopes);
|
|
204
|
+
const tokens = await exchangeDeviceCode(baseUrl, clientId, deviceCode, fetch_, requestedScopes, flowIdentity);
|
|
191
205
|
return { clientId, ...tokens };
|
|
192
206
|
}
|
|
193
207
|
if (status === 'denied')
|
|
194
208
|
throw new DeviceAuthorizationError('Device authorization was denied');
|
|
195
209
|
if (status === 'expired')
|
|
196
210
|
break;
|
|
197
|
-
const polled = await pollDeviceCode(baseUrl, clientId, deviceCode, fetch_, requestedScopes);
|
|
211
|
+
const polled = await pollDeviceCode(baseUrl, clientId, deviceCode, fetch_, requestedScopes, flowIdentity);
|
|
198
212
|
if (polled)
|
|
199
213
|
return { clientId, ...polled };
|
|
200
214
|
const pollingFloor = Math.max(1, authorization.interval ?? 5) * 1_000;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { accountLimitsSchema } from './accountLimits.js';
|
|
2
|
-
import { isRuntimeDiagnostics } from '
|
|
2
|
+
import { isRuntimeDiagnostics } from './runtimeDiagnostics.js';
|
|
3
3
|
import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isBridgeInfoArray, isNullableAuthMethodInfoArray, isNullableString, isNumber, isObject, isString, isStringArray, isStringRecord, } from './common.js';
|
|
4
4
|
/** Current version and feature floors for the independently negotiated agent protocol. */
|
|
5
5
|
export const AGENT_WIRE_VERSION = 27;
|
|
@@ -14,9 +14,7 @@ export const AGENT_WIRE_SWITCH_IDLE_VERSION = 26;
|
|
|
14
14
|
export const AGENT_WIRE_NATIVE_DELETE_VERSION = 24;
|
|
15
15
|
export const AGENT_WIRE_PROVIDER_TASK_VERSION = 23;
|
|
16
16
|
export const AGENT_WIRE_RUNTIME_PROFILE_VERSION = 22;
|
|
17
|
-
export const AGENT_WIRE_DELEGATION_RENEW_VERSION = 21;
|
|
18
17
|
export const AGENT_WIRE_TURN_IDENTITY_VERSION = 20;
|
|
19
|
-
export const AGENT_WIRE_DELEGATION_MCP_VERSION = 19;
|
|
20
18
|
/** Scoped replay/history is enabled only when both peers preserve native origin. */
|
|
21
19
|
export const AGENT_WIRE_SCOPED_ORIGIN_VERSION = 18;
|
|
22
20
|
/** agent_open.requireSessionLoad: reject unsupported/failed load without session/new fallback. */
|
|
@@ -246,9 +244,6 @@ export const AGENT_FRAME_SHAPES = {
|
|
|
246
244
|
env: isStringRecord, pathPrepend: isStringArray, agentEnv: isStringRecord, command: isNullableString,
|
|
247
245
|
args: isStringArray, nativeSessionId: isNullableString,
|
|
248
246
|
resumeFromSeq: isNumber, backfill: isBoolean, requireSessionLoad: isBoolean,
|
|
249
|
-
delegationMcp: (value) => isObject(value) &&
|
|
250
|
-
isString(value.url) && isStringRecord(value.headers) && isNumber(value.expiresAtEpochMs) &&
|
|
251
|
-
(value.bindingId === undefined || isString(value.bindingId)),
|
|
252
247
|
},
|
|
253
248
|
},
|
|
254
249
|
agent_opened: {
|
|
@@ -265,9 +260,6 @@ export const AGENT_FRAME_SHAPES = {
|
|
|
265
260
|
// `reloaded` is `true`-or-absent for the same reason as `supportsSteering`: a `false` would
|
|
266
261
|
// be a second spelling of what its absence already says.
|
|
267
262
|
reloaded: isVerifiedTrue,
|
|
268
|
-
delegationExpiresAtEpochMs: isNumber,
|
|
269
|
-
delegationRefreshPending: isVerifiedTrue,
|
|
270
|
-
delegationInstanceId: isString, delegationBindingId: isString, delegationRenewalRevision: isNumber,
|
|
271
263
|
agentInfo: isAgentInfo, supportsImageInput: isVerifiedTrue, configOptions: isNullableSessionConfigOptionArray,
|
|
272
264
|
// Same guard as `agent_verified.authMethods`, and the same absent-vs-`[]` reading: a
|
|
273
265
|
// half-decoded method table is worse than none, because the reader would offer a login the
|
|
@@ -286,10 +278,6 @@ export const AGENT_FRAME_SHAPES = {
|
|
|
286
278
|
},
|
|
287
279
|
},
|
|
288
280
|
agent_prompt: { required: { sessionRef: isString, text: isString }, optional: { images: isImageBlockArray, platformTurnId: isString } },
|
|
289
|
-
agent_delegation_renew: { required: { id: isString, sessionRef: isString, nativeSessionId: isString,
|
|
290
|
-
instanceId: isString, bindingId: isString, renewalRevision: isNumber, expiresAtEpochMs: isNumber } },
|
|
291
|
-
agent_delegation_renewed: { required: { id: isString, ok: isBoolean }, optional: { error: isString,
|
|
292
|
-
nativeSessionId: isString, instanceId: isString, bindingId: isString, renewalRevision: isNumber, expiresAtEpochMs: isNumber } },
|
|
293
281
|
agent_answer: { required: { sessionRef: isString, requestId: isString, outcome: isString } },
|
|
294
282
|
// `content`'s interior is never validated here -- it is ACP's free-form elicitation payload, and
|
|
295
283
|
// this table is transport, not translator. `isRecord` only rejects a non-object.
|
|
@@ -773,7 +761,6 @@ export function outboundFailureReply(frame, reason) {
|
|
|
773
761
|
case 'agent_steered': return { t: 'agent_steered', id, ok: false, error };
|
|
774
762
|
case 'agent_account_read_result': return { t: 'agent_account_read_result', id, ok: false, error };
|
|
775
763
|
case 'agent_config_option_set': return { t: 'agent_config_option_set', id, ok: false, error };
|
|
776
|
-
case 'agent_delegation_renewed': return { t: 'agent_delegation_renewed', id, ok: false, error };
|
|
777
764
|
case 'agent_switch_idle_result': return { t: 'agent_switch_idle_result', id, ok: false, error };
|
|
778
765
|
case 'agent_provider_task_stopped': return { t: 'agent_provider_task_stopped', id, state: 'unknown', error };
|
|
779
766
|
case 'agent_backfill_done':
|
|
@@ -790,7 +777,7 @@ export function decodeAgentFrame(text) {
|
|
|
790
777
|
if ((frame.state === 'completed' || frame.state === 'failed' || frame.state === 'stopped') && frame.live)
|
|
791
778
|
throw new TypeError('Invalid provider task terminal live state');
|
|
792
779
|
if (frame.status !== undefined &&
|
|
793
|
-
(frame.status !== frame.state || !['completed', 'failed', 'stopped'].
|
|
780
|
+
(frame.status !== frame.state || !['completed', 'failed', 'stopped'].some(status => status === frame.status)))
|
|
794
781
|
throw new TypeError('Invalid provider task terminal status');
|
|
795
782
|
if (frame.summary !== undefined && frame.status === undefined)
|
|
796
783
|
throw new TypeError('Provider task summary requires terminal status');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { decodeDesktopManagementFrame, parseDesktopJson, DesktopProtocolError } from '../desktopControl/protocol.js';
|
|
2
|
+
import { parseAutomationJson } from '../desktopControl/automationJson.js';
|
|
1
3
|
import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isBridgeInfoArray, isNullableString, isNumber, isObject, isString, } from './common.js';
|
|
2
4
|
export const MANAGEMENT_WIRE_MIN_VERSION = 1;
|
|
3
5
|
/** Adds the `DRAINING` value to ClientUpdatePhase. */
|
|
@@ -12,7 +14,9 @@ export const MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION = 6;
|
|
|
12
14
|
export const MANAGEMENT_WIRE_GPU_VERSION = 7;
|
|
13
15
|
/** Adds `keepers` to heartbeats and the `keeper_control` / `keeper_control_result` pair (spec §8.1, Task 26). */
|
|
14
16
|
export const MANAGEMENT_WIRE_KEEPERS_VERSION = 8;
|
|
15
|
-
export const
|
|
17
|
+
export const MANAGEMENT_WIRE_DESKTOP_VERSION = 9;
|
|
18
|
+
export const MANAGEMENT_WIRE_DEVICE_OWNERSHIP_VERSION = 10;
|
|
19
|
+
export const MANAGEMENT_WIRE_VERSION = MANAGEMENT_WIRE_DEVICE_OWNERSHIP_VERSION;
|
|
16
20
|
/** Bounds of one heartbeat's `keepers` (spec §8.1). */
|
|
17
21
|
export const KEEPER_STATUS_MAX_ITEMS = 32;
|
|
18
22
|
export const KEEPER_LABEL_MAX_CHARS = 64;
|
|
@@ -57,6 +61,7 @@ function isUpdateStatus(value) {
|
|
|
57
61
|
&& hasOptional(value, {
|
|
58
62
|
commandId: isString, version: isString, releaseSha512: isString, force: isBoolean,
|
|
59
63
|
activeShellTasks: isNumber, activeAgentTasks: isNumber, error: isString,
|
|
64
|
+
remoteForcePolicy: policy => policy === 'drain-only',
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
function isOsInfo(value) {
|
|
@@ -128,7 +133,7 @@ function isManagementFrame(value) {
|
|
|
128
133
|
}) && hasOptional(value, { bridges: isBridgeInfoArray });
|
|
129
134
|
case 'management_ready': return hasRequired(value, {
|
|
130
135
|
serverNonce: isString, generation: isString, protocols: isSelectedProtocolMap,
|
|
131
|
-
}) && hasOptional(value, { release: isNullable(isRelease) });
|
|
136
|
+
}) && hasOptional(value, { release: isNullable(isRelease), ownerUserId: isString });
|
|
132
137
|
case 'heartbeat': return hasRequired(value, {
|
|
133
138
|
seq: isNumber, sentAtEpochMs: isNumber, clientVersion: isString,
|
|
134
139
|
channels: (channels) => isObject(channels) && Object.entries(channels).every(([kind, state]) => CHANNEL_KINDS.has(kind) && isChannelState(state)),
|
|
@@ -137,14 +142,16 @@ function isManagementFrame(value) {
|
|
|
137
142
|
playgroundCwd: isString,
|
|
138
143
|
protocols: (protocols) => isProtocolRangeMap(protocols, false),
|
|
139
144
|
os: isNullable(isOsInfo),
|
|
145
|
+
toolchain: (toolchain) => isObject(toolchain) && hasRequired(toolchain, { python: isNullableString, node: isNullableString }),
|
|
140
146
|
update: isNullable(isUpdateStatus),
|
|
141
147
|
metrics: isNullable(isWorkstationMetrics),
|
|
142
148
|
keepers: isNullable((keepers) => Array.isArray(keepers) && keepers.every(isKeeperStatus)),
|
|
149
|
+
capabilitiesRevision: value => Number.isSafeInteger(value) && value > 0,
|
|
143
150
|
});
|
|
144
151
|
case 'heartbeat_ack': return hasRequired(value, { seq: isNumber });
|
|
145
152
|
case 'channel_ticket_request': return hasRequired(value, { channel: isChannelKind });
|
|
146
153
|
case 'channel_ticket': return hasRequired(value, { channel: isChannelKind, ticket: isString, expiresAtEpochMs: isNumber });
|
|
147
|
-
case 'client_update': return hasRequired(value, { commandId: isString, release: isRelease, force: isBoolean });
|
|
154
|
+
case 'client_update': return hasRequired(value, { commandId: isString, release: isRelease, force: isBoolean }) && hasOptional(value, { remoteForcePolicy: value => value === 'drain-only' });
|
|
148
155
|
case 'client_update_status': return hasRequired(value, { status: isUpdateStatus });
|
|
149
156
|
case 'external_agents_scan': return hasRequired(value, { commandId: isString });
|
|
150
157
|
case 'external_agents_scan_result': return hasRequired(value, { commandId: isString, bridges: isBridgeInfoArray });
|
|
@@ -157,8 +164,109 @@ function isManagementFrame(value) {
|
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
export function encodeManagementFrame(frame) {
|
|
160
|
-
|
|
167
|
+
const text = JSON.stringify(frame);
|
|
168
|
+
if (frame.t === 'desktop_automation_prepare')
|
|
169
|
+
decodeAutomationPrepare(text);
|
|
170
|
+
if (isDesktopManagementType(frame.t))
|
|
171
|
+
decodeDesktopManagementFrame(text);
|
|
172
|
+
return text;
|
|
161
173
|
}
|
|
162
|
-
export function decodeManagementFrame(
|
|
174
|
+
export function decodeManagementFrame(source) {
|
|
175
|
+
const text = typeof source === 'string' ? source : new TextDecoder('utf-8', { fatal: true }).decode(source);
|
|
176
|
+
// Desktop extensions get the strict lexical/shape guard before any lossy object parsing.
|
|
177
|
+
const parsed = JSON.parse(text);
|
|
178
|
+
if (isObject(parsed) && parsed.t === 'desktop_automation_prepare')
|
|
179
|
+
return decodeAutomationPrepare(text);
|
|
180
|
+
if (isObject(parsed) && isDesktopManagementType(parsed.t))
|
|
181
|
+
return decodeDesktopManagementFrame(text);
|
|
182
|
+
if (isObject(parsed) && parsed.t === 'heartbeat' && Object.hasOwn(parsed, 'capabilitiesRevision'))
|
|
183
|
+
validateHeartbeatRevision(text);
|
|
163
184
|
return decodeChannelFrame(text, DISCRIMINATORS, isManagementFrame);
|
|
164
185
|
}
|
|
186
|
+
function decodeAutomationPrepare(text) {
|
|
187
|
+
const value = parseAutomationJson(text, 49152).value;
|
|
188
|
+
if (Object.keys(value).sort().join(',') !== 'automationProtocol,binding,deviceGeneration,grant,t' || value.automationProtocol !== 1 ||
|
|
189
|
+
typeof value.grant !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.grant) ||
|
|
190
|
+
value.binding?.route?.deviceGeneration !== value.deviceGeneration || !value.binding?.native || !value.binding?.preparation || typeof value.binding.executionId !== 'string')
|
|
191
|
+
throw new DesktopProtocolError('Invalid automation preparation');
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
/** Select only the new root field. JSON.parse already validates syntax; legacy nesting, size,
|
|
195
|
+
* unknown fields and duplicate legacy keys keep their established decoder behavior. */
|
|
196
|
+
function validateHeartbeatRevision(text) {
|
|
197
|
+
let depth = 0, count = 0;
|
|
198
|
+
for (let offset = 0; offset < text.length; offset++) {
|
|
199
|
+
const char = text[offset];
|
|
200
|
+
if (char === '"') {
|
|
201
|
+
const start = offset++;
|
|
202
|
+
while (offset < text.length) {
|
|
203
|
+
if (text[offset] === '\\')
|
|
204
|
+
offset += 2;
|
|
205
|
+
else if (text[offset++] === '"')
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
const end = offset--;
|
|
209
|
+
if (depth !== 1 || JSON.parse(text.slice(start, end)) !== 'capabilitiesRevision')
|
|
210
|
+
continue;
|
|
211
|
+
let tokenStart = end;
|
|
212
|
+
while (/\s/.test(text[tokenStart] ?? ''))
|
|
213
|
+
tokenStart++;
|
|
214
|
+
if (text[tokenStart] !== ':')
|
|
215
|
+
continue; // This string was a legacy field's value.
|
|
216
|
+
let tokenEnd = ++tokenStart;
|
|
217
|
+
while (tokenEnd < text.length && text[tokenEnd] !== ',' && text[tokenEnd] !== '}')
|
|
218
|
+
tokenEnd++;
|
|
219
|
+
const token = text.slice(tokenStart, tokenEnd).trim();
|
|
220
|
+
if (++count !== 1 || !/^[1-9][0-9]*$/.test(token) || !Number.isSafeInteger(Number(token))) {
|
|
221
|
+
throw new DesktopProtocolError('Invalid capabilities revision');
|
|
222
|
+
}
|
|
223
|
+
parseDesktopJson(`{"capabilitiesRevision":${token}}`);
|
|
224
|
+
}
|
|
225
|
+
else if (char === '{' || char === '[')
|
|
226
|
+
depth++;
|
|
227
|
+
else if (char === '}' || char === ']')
|
|
228
|
+
depth--;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function isDesktopManagementType(type) {
|
|
232
|
+
return typeof type === 'string' && ['desktop_background', 'desktop_lobby', 'desktop_lobby_result', 'workstation_capabilities', 'workstation_capabilities_request', 'desktop_prepare',
|
|
233
|
+
'desktop_prepare_result', 'desktop_cancel', 'desktop_isolation_result', 'desktop_session_state', 'desktop_ownership', 'desktop_ownership_result'].includes(type);
|
|
234
|
+
}
|
|
235
|
+
export class ManagementFrameAdmission {
|
|
236
|
+
isCurrent;
|
|
237
|
+
ready;
|
|
238
|
+
disposed = false;
|
|
239
|
+
constructor(isCurrent = () => true) {
|
|
240
|
+
this.isCurrent = isCurrent;
|
|
241
|
+
}
|
|
242
|
+
admit(frame) {
|
|
243
|
+
if (this.disposed || !this.isCurrent())
|
|
244
|
+
return false;
|
|
245
|
+
if (frame.t === 'management_ready') {
|
|
246
|
+
if (this.ready || !Number.isInteger(frame.protocols.MANAGEMENT) || frame.protocols.MANAGEMENT < MANAGEMENT_WIRE_MIN_VERSION
|
|
247
|
+
|| frame.protocols.MANAGEMENT > MANAGEMENT_WIRE_VERSION || !frame.generation)
|
|
248
|
+
return false;
|
|
249
|
+
this.ready = frame;
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
if (frame.t === 'heartbeat' && frame.capabilitiesRevision !== undefined)
|
|
253
|
+
return false;
|
|
254
|
+
if (frame.t === 'desktop_ownership')
|
|
255
|
+
return !!this.ready && this.ready.protocols.MANAGEMENT >= 10 && frame.deviceGeneration === this.ready.generation;
|
|
256
|
+
if (frame.t === 'desktop_automation_prepare')
|
|
257
|
+
return !!this.ready && this.ready.protocols.MANAGEMENT >= MANAGEMENT_WIRE_DESKTOP_VERSION && frame.deviceGeneration === this.ready.generation;
|
|
258
|
+
if (!isDesktopManagementType(frame.t))
|
|
259
|
+
return true;
|
|
260
|
+
if (!this.ready || this.ready.protocols.MANAGEMENT < MANAGEMENT_WIRE_DESKTOP_VERSION)
|
|
261
|
+
return false;
|
|
262
|
+
switch (frame.t) {
|
|
263
|
+
case 'workstation_capabilities_request': return frame.generation === this.ready.generation;
|
|
264
|
+
case 'desktop_lobby':
|
|
265
|
+
case 'desktop_background':
|
|
266
|
+
case 'desktop_prepare': return frame.deviceGeneration === this.ready.generation;
|
|
267
|
+
case 'desktop_cancel': return true; // Current socket binding; T10 additionally fences its session/control generation.
|
|
268
|
+
default: return false; // Device-authored reports/results/states cannot be echoed as server activity.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
dispose() { this.disposed = true; }
|
|
272
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function isRuntimeDiagnostics(value) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3
|
+
return false;
|
|
4
|
+
const input = value;
|
|
5
|
+
const bounded = (value, max) => typeof value === 'string' &&
|
|
6
|
+
value.trim().length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
|
|
7
|
+
if (!bounded(input.generation, 256) || !Number.isSafeInteger(input.revision) ||
|
|
8
|
+
input.revision < 0 || typeof input.available !== 'boolean' ||
|
|
9
|
+
!input.launch || typeof input.launch !== 'object')
|
|
10
|
+
return false;
|
|
11
|
+
const launch = input.launch;
|
|
12
|
+
return ['command-override', 'registry', 'registry-fallback', 'direct'].includes(launch.source) &&
|
|
13
|
+
bounded(launch.executable, 4096) && bounded(launch.platform, 64) && bounded(launch.architecture, 64) &&
|
|
14
|
+
launch.transport === 'stdio' && (input.profile === undefined || (typeof input.profile === 'object' && input.profile !== null && !Array.isArray(input.profile)));
|
|
15
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { promises as fs, constants } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { gunzipSync } from 'node:zlib';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { deploymentDownloadURL } from '../control/serverIdentity.js';
|
|
7
|
+
import { downloadVerified } from '../update/artifact.js';
|
|
8
|
+
import { privateDirectory } from '../keepers/kit/paths.js';
|
|
9
|
+
const legacyRdFiles = ['bin/axom-desktop-keeper', 'bin/axom-input-guardian', 'bin/axom-quic', 'xorg.conf'];
|
|
10
|
+
export const RD_DEVICE_HOST_PLATFORM = 'linux-x64-debian-13-device-host';
|
|
11
|
+
export const rdFiles = [...legacyRdFiles, 'bin/axom-display', 'bin/axom-desktop-session'];
|
|
12
|
+
const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
|
|
13
|
+
export const rdArtifactSchema = z.object({ version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,127}$/),
|
|
14
|
+
filename: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._+-]*\.(?:tar\.gz|tgz)$/),
|
|
15
|
+
url: z.string(), size: z.number().int().min(1).max(134217728), sha512: z.string().regex(/^[a-f0-9]{128}$/),
|
|
16
|
+
unpackedSize: z.number().int().min(1).max(268435456), ipcVersion: z.union([z.literal(1), z.literal(2)]),
|
|
17
|
+
files: z.record(z.string(), sha256),
|
|
18
|
+
}).refine(artifact => {
|
|
19
|
+
const files = artifact.ipcVersion === 1 ? legacyRdFiles : rdFiles;
|
|
20
|
+
const licenses = artifact.ipcVersion === 2 ? ['opus-LICENSE', 'webp-LICENSE'].filter(file => file in artifact.files) : [];
|
|
21
|
+
return Object.keys(artifact.files).length === files.length + licenses.length && files.every(file => file in artifact.files);
|
|
22
|
+
}, 'RD artifact files do not match its runtime version');
|
|
23
|
+
export function rdPlatform(osRelease, architecture) {
|
|
24
|
+
const values = Object.fromEntries(osRelease.split('\n').map(line => /^([A-Z_]+)=["']?([^"']*)["']?$/.exec(line)).filter(Boolean).map(match => [match[1], match[2]]));
|
|
25
|
+
if (values.ID !== 'debian' || values.VERSION_ID !== '13' || architecture !== 'x64') {
|
|
26
|
+
throw new Error('Remote desktop currently supports Debian 13 amd64; this distribution/architecture has no qualified RD package');
|
|
27
|
+
}
|
|
28
|
+
return RD_DEVICE_HOST_PLATFORM;
|
|
29
|
+
}
|
|
30
|
+
export function unpackRd(bytes, artifact) {
|
|
31
|
+
const archive = gunzipSync(bytes, { maxOutputLength: artifact.unpackedSize + 65536 });
|
|
32
|
+
const files = new Map();
|
|
33
|
+
let unpackedSize = 0;
|
|
34
|
+
let terminated = false;
|
|
35
|
+
for (let offset = 0; offset + 512 <= archive.length;) {
|
|
36
|
+
const header = archive.subarray(offset, offset + 512);
|
|
37
|
+
if (header.every(byte => byte === 0)) {
|
|
38
|
+
if (offset + 1024 > archive.length || archive.subarray(offset).some(byte => byte !== 0))
|
|
39
|
+
throw new Error('RD archive has an invalid terminator or trailing records');
|
|
40
|
+
terminated = true;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
const field = (start, length) => header.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, '');
|
|
44
|
+
const name = field(0, 100), prefix = field(345, 155), sizeText = field(124, 12).trim();
|
|
45
|
+
const checksum = field(148, 8).trim();
|
|
46
|
+
const actualChecksum = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
|
|
47
|
+
if (field(257, 6) !== 'ustar' || field(263, 2) !== '00' || !/^[0-7]+$/.test(checksum) || Number.parseInt(checksum, 8) !== actualChecksum)
|
|
48
|
+
throw new Error('RD archive has an invalid USTAR header');
|
|
49
|
+
if (prefix || !/^[0-7]+$/.test(sizeText) || !Object.hasOwn(artifact.files, name) || files.has(name) || ![0, 48].includes(header[156])) {
|
|
50
|
+
throw new Error('RD archive contains an unexpected file, path, link or duplicate');
|
|
51
|
+
}
|
|
52
|
+
const size = Number.parseInt(sizeText, 8);
|
|
53
|
+
offset += 512;
|
|
54
|
+
unpackedSize += size;
|
|
55
|
+
if (!Number.isSafeInteger(size) || offset + size > archive.length || unpackedSize > artifact.unpackedSize)
|
|
56
|
+
throw new Error('RD archive exceeds its declared size');
|
|
57
|
+
const contents = archive.subarray(offset, offset + size);
|
|
58
|
+
if (createHash('sha256').update(contents).digest('hex') !== artifact.files[name])
|
|
59
|
+
throw new Error(`RD component checksum mismatch: ${name}`);
|
|
60
|
+
files.set(name, contents);
|
|
61
|
+
offset += Math.ceil(size / 512) * 512;
|
|
62
|
+
}
|
|
63
|
+
if (!terminated || files.size !== Object.keys(artifact.files).length || unpackedSize !== artifact.unpackedSize)
|
|
64
|
+
throw new Error('RD archive is incomplete');
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
export async function verifyRdInstallation(directory, artifact) {
|
|
68
|
+
for (const name of Object.keys(rdArtifactSchema.parse(artifact).files)) {
|
|
69
|
+
const file = await fs.open(path.join(directory, name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
70
|
+
try {
|
|
71
|
+
const info = await file.stat();
|
|
72
|
+
if (!info.isFile() || info.uid !== process.getuid?.() || info.mode & 0o022 || info.size > artifact.unpackedSize)
|
|
73
|
+
throw new Error('Unsafe RD installation');
|
|
74
|
+
if (createHash('sha256').update(await file.readFile()).digest('hex') !== artifact.files[name])
|
|
75
|
+
throw new Error(`RD component checksum mismatch: ${name}`);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await file.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function installRd(directory, server, platform, fetchImpl = globalThis.fetch) {
|
|
83
|
+
const response = await fetchImpl(deploymentDownloadURL(server, '/download/client/desktop/manifest.json'), { redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
84
|
+
if (!response.ok)
|
|
85
|
+
throw new Error(`The server does not publish Linux RD packages (HTTP ${response.status})`);
|
|
86
|
+
const text = await response.text();
|
|
87
|
+
if (Buffer.byteLength(text) > 1048576)
|
|
88
|
+
throw new Error('RD catalog is too large');
|
|
89
|
+
const catalog = z.object({ schemaVersion: z.literal(1), artifacts: z.record(z.string(), rdArtifactSchema) }).parse(JSON.parse(text));
|
|
90
|
+
const artifact = catalog.artifacts[platform];
|
|
91
|
+
if (!artifact)
|
|
92
|
+
throw new Error(`The server has no RD package for ${platform}`);
|
|
93
|
+
if (artifact.url !== `/download/client/desktop/${artifact.filename}`)
|
|
94
|
+
throw new Error('RD artifact URL does not match its filename');
|
|
95
|
+
const target = path.join(directory, 'rd', 'versions', artifact.sha512.slice(0, 32));
|
|
96
|
+
try {
|
|
97
|
+
await verifyRdInstallation(target, artifact);
|
|
98
|
+
return { directory: target, artifact };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code !== 'ENOENT')
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const downloaded = await downloadVerified(artifact, { server, fetch: fetchImpl });
|
|
105
|
+
await privateDirectory(path.dirname(target));
|
|
106
|
+
const staging = await fs.mkdtemp(path.join(path.dirname(target), '.install-'));
|
|
107
|
+
try {
|
|
108
|
+
const files = unpackRd(await fs.readFile(downloaded.path), artifact);
|
|
109
|
+
await privateDirectory(path.join(staging, 'bin'));
|
|
110
|
+
for (const [name, contents] of files)
|
|
111
|
+
await fs.writeFile(path.join(staging, name), contents, { flag: 'wx', mode: name.startsWith('bin/') ? 0o700 : 0o600 });
|
|
112
|
+
await verifyRdInstallation(staging, artifact);
|
|
113
|
+
await fs.rename(staging, target);
|
|
114
|
+
return { directory: target, artifact };
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await downloaded.cleanup();
|
|
118
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { loadConfig, resolveServer } from '../../config.js';
|
|
7
|
+
import { loadClientConfig } from '../config.js';
|
|
8
|
+
import { normalizeWorkstationServer } from '../control/serverIdentity.js';
|
|
9
|
+
import { withPrivateLock } from '../control/locks.js';
|
|
10
|
+
import { installRd, rdPlatform } from './artifact.js';
|
|
11
|
+
import { readRdState, writeRdState } from './state.js';
|
|
12
|
+
import { chooseSession, discoverSessions } from './sessions.js';
|
|
13
|
+
const execute = promisify(execFile);
|
|
14
|
+
export function rdSessionSelection(previous, options) {
|
|
15
|
+
const session = z.enum(['auto', 'existing', 'virtual']).parse(options.rdSession ?? previous?.session ?? 'auto');
|
|
16
|
+
const display = options.rdDisplay ?? (options.rdSession === undefined ? previous?.display : undefined);
|
|
17
|
+
if (display !== undefined && !/^:\d+(?:\.\d+)?$/.test(display))
|
|
18
|
+
throw new Error('--rd-display must be a local X11 display such as :10');
|
|
19
|
+
if (session === 'virtual' && display)
|
|
20
|
+
throw new Error('--rd-display selects an existing session and cannot be combined with --rd-session virtual');
|
|
21
|
+
return { session, display };
|
|
22
|
+
}
|
|
23
|
+
export async function configureRd(directory, options) {
|
|
24
|
+
if (!options.enableRd && !options.disableRd && options.rdSession === undefined && options.rdDisplay === undefined)
|
|
25
|
+
return;
|
|
26
|
+
if (process.platform !== 'linux' || process.getuid?.() === 0)
|
|
27
|
+
throw new Error('Linux RD must be configured and run as the workstation user, not root');
|
|
28
|
+
if (options.enableRd && options.disableRd)
|
|
29
|
+
throw new Error('--enable-rd and --disable-rd cannot be combined');
|
|
30
|
+
if (!options.enableRd && !options.disableRd)
|
|
31
|
+
throw new Error('--rd-session and --rd-display require --enable-rd');
|
|
32
|
+
await withPrivateLock(path.join(directory, 'rd', 'install'), async () => {
|
|
33
|
+
const previous = await readRdState(directory);
|
|
34
|
+
const registered = await loadClientConfig(directory).catch(error => {
|
|
35
|
+
if (error.code !== 'ENOENT')
|
|
36
|
+
throw error;
|
|
37
|
+
return undefined;
|
|
38
|
+
});
|
|
39
|
+
const server = normalizeWorkstationServer(options.server ?? registered?.server ?? resolveServer(await loadConfig()));
|
|
40
|
+
if (options.disableRd) {
|
|
41
|
+
await writeRdState(directory, previous ? { ...previous, enabled: false } : { schemaVersion: 2, enabled: false, artifactSource: server, session: 'auto' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { session, display } = rdSessionSelection(previous, options);
|
|
45
|
+
const platform = rdPlatform(await fs.readFile('/etc/os-release', 'utf8'), process.arch);
|
|
46
|
+
const installed = await installRd(directory, server, platform);
|
|
47
|
+
await ensureDependencies(installed.directory, session, display, installed.artifact);
|
|
48
|
+
await writeRdState(directory, { schemaVersion: 2, enabled: true, artifactSource: server, session, ...(display ? { display } : {}),
|
|
49
|
+
installation: installed.directory, artifact: installed.artifact });
|
|
50
|
+
console.log(`Remote desktop enabled (${session}, ${installed.artifact.version}); capture starts only when an authorized viewer connects`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async function ensureDependencies(installation, session, display, artifact) {
|
|
54
|
+
let runtimeMissing = false;
|
|
55
|
+
try {
|
|
56
|
+
await execute(path.join(installation, 'bin/axom-desktop-keeper'), ['--self-test'], { timeout: 5000, maxBuffer: 8192 });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
runtimeMissing = true;
|
|
60
|
+
}
|
|
61
|
+
const selected = chooseSession(await discoverSessions(), { session, display });
|
|
62
|
+
const packages = runtimeMissing ? ['libavcodec61', 'libavutil59', 'libswscale8', 'libgstreamer-plugins-bad1.0-0', 'libx11-6', 'libxext6', 'libxdamage1', 'libxfixes3', 'libxtst6', 'libxrandr2', 'libxcomposite1', 'zlib1g'] : [];
|
|
63
|
+
if (artifact?.ipcVersion === 2)
|
|
64
|
+
packages.push('python3', 'x11-xserver-utils', 'x11-utils', 'xauth', 'libpulse0', 'pulseaudio', 'pulseaudio-utils', 'libasound2-plugins');
|
|
65
|
+
if (!selected)
|
|
66
|
+
packages.push('xserver-xorg-core', 'xserver-xorg-video-dummy', 'xfce4-session', 'xfwm4', 'xfce4-panel', 'xfdesktop4', 'xfce4-settings', 'thunar', 'xfce4-terminal', 'dbus-x11', 'xauth', 'x11-utils', 'fonts-dejavu-core');
|
|
67
|
+
if (!packages.length)
|
|
68
|
+
return;
|
|
69
|
+
const missing = [];
|
|
70
|
+
for (const name of packages) {
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execute('dpkg-query', ['-W', '-f=${db:Status-Status}', name], { timeout: 2000, maxBuffer: 4096 });
|
|
73
|
+
if (stdout.trim() !== 'installed')
|
|
74
|
+
missing.push(name);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
missing.push(name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (missing.length) {
|
|
81
|
+
console.log(`Installing remote desktop dependencies: ${missing.join(', ')}`);
|
|
82
|
+
try {
|
|
83
|
+
await execute('sudo', ['-n', 'apt-get', 'install', '-y', '--no-install-recommends', ...missing], { timeout: 180000, maxBuffer: 1048576 });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`Install the required RD packages, then retry: sudo apt-get install --no-install-recommends ${missing.join(' ')}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await execute(path.join(installation, 'bin/axom-desktop-keeper'), ['--self-test'], { timeout: 5000, maxBuffer: 8192 });
|
|
90
|
+
if (artifact?.ipcVersion === 2) {
|
|
91
|
+
for (const command of ['axom-display', 'axom-desktop-session'])
|
|
92
|
+
await execute(path.join(installation, 'bin', command), ['--self-test'], { timeout: 5000, maxBuffer: 8192 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function rdStatus(directory) {
|
|
96
|
+
const state = await readRdState(directory);
|
|
97
|
+
if (!state)
|
|
98
|
+
return { enabled: false, installed: false };
|
|
99
|
+
let observed;
|
|
100
|
+
let update;
|
|
101
|
+
try {
|
|
102
|
+
observed = JSON.parse(await fs.readFile(path.join(directory, 'rd', 'status.json'), 'utf8'));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code !== 'ENOENT')
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
update = JSON.parse(await fs.readFile(path.join(directory, 'rd', 'update-status.json'), 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error.code !== 'ENOENT')
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
return { enabled: state.enabled, installed: state.installation !== undefined, version: state.artifact?.version, selection: state.session, artifactSource: state.artifactSource,
|
|
116
|
+
...(state.display ? { requestedDisplay: state.display } : {}), ...(state.enabled ? { observed, update } : {}) };
|
|
117
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { captureSamplingModes } from '../desktopControl/captureModes.js';
|
|
2
|
+
export function parseXrandr(text) {
|
|
3
|
+
const screen = /current (\d+) x (\d+)/.exec(text);
|
|
4
|
+
const outputs = [...text.matchAll(/^(\S+) connected(?: primary)? (\d+)x(\d+)\+(\d+)\+(\d+).*$/gm)];
|
|
5
|
+
if (!screen || outputs.length !== 1 || outputs[0][4] !== '0' || outputs[0][5] !== '0' ||
|
|
6
|
+
screen[1] !== outputs[0][2] || screen[2] !== outputs[0][3])
|
|
7
|
+
throw new Error('No consistent single-output X11 desktop is available');
|
|
8
|
+
const output = outputs[0];
|
|
9
|
+
const section = text.slice(output.index + output[0].length).split(/^\S/m)[0];
|
|
10
|
+
const modes = [...section.matchAll(/^\s+(\d+)x(\d+)(?:\S*)\s/gm)]
|
|
11
|
+
.map(match => ({ width: Number(match[1]), height: Number(match[2]) }))
|
|
12
|
+
.filter(mode => mode.width >= 2 && mode.height >= 2 && mode.width <= 4096 && mode.height <= 4096 && mode.width % 2 === 0 && mode.height % 2 === 0);
|
|
13
|
+
return { output: output[1], width: Number(output[2]), height: Number(output[3]),
|
|
14
|
+
modes: modes.filter((mode, index) => modes.findIndex(other => other.width === mode.width && other.height === mode.height) === index) };
|
|
15
|
+
}
|
|
16
|
+
export function desktopDisplayControl(target, actual, managed) {
|
|
17
|
+
if (target.kind !== 'window' && target.width === actual.width && target.height === actual.height &&
|
|
18
|
+
target.width % 2 === 0 && target.height % 2 === 0 && !/^rdp\d+$/i.test(actual.output)) {
|
|
19
|
+
if (managed && /^DUMMY\d+$/i.test(actual.output))
|
|
20
|
+
return { ...target, overview: true, resize: 'dynamic',
|
|
21
|
+
resizeRange: { minWidth: 320, minHeight: 320, maxWidth: 4096, maxHeight: 4096 },
|
|
22
|
+
modes: [{ width: 1280, height: 720 }, { width: 1920, height: 1080 }, { width: 1024, height: 768 }, { width: 720, height: 1280 }] };
|
|
23
|
+
if (actual.modes.length > 1)
|
|
24
|
+
return { ...target, overview: true, resize: 'modes', modes: actual.modes };
|
|
25
|
+
}
|
|
26
|
+
return { ...target, overview: true, resize: 'dynamic', captureViewport: true, capturePixelScale: 1,
|
|
27
|
+
resizeRange: { minWidth: 2, minHeight: 2, maxWidth: target.width & ~1, maxHeight: target.height & ~1 },
|
|
28
|
+
modes: captureSamplingModes(target.width, target.height) };
|
|
29
|
+
}
|