@platless/axom-cli 0.3.13 → 0.6.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/dist/axom-patch/cli/README.md +119 -9
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +123 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +81 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- 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 +194 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- 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/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1591 -0
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +360 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +13 -5
- package/dist/axom-patch/cli/qoder-foreground-output.md +14 -7
- 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 +39 -15
- package/dist/axom-patch/cli/zcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/zcode-models.json +37 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +72 -0
- package/dist/cli.js +4 -9
- package/dist/commands/knowledge.js +47 -0
- package/dist/commands/update.js +65 -3
- package/dist/commands/workstation.js +73 -7
- package/dist/install/claude/commands.js +13 -13
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/mcp.js +1 -1
- package/dist/native/darwin-arm64/axom-quic +0 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2192 -636
- package/dist/workstation/acp/acpClient.js +84 -495
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +324 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +290 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +239 -159
- package/dist/workstation/acp/managedRuntime.js +314 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +60 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +78 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +282 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +105 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +496 -0
- package/dist/workstation/acp/processor/state.js +67 -0
- package/dist/workstation/acp/processor/step.js +442 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +58 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +390 -0
- package/dist/workstation/acp/processorSession.js +1348 -0
- package/dist/workstation/acp/registry/catalog.js +189 -41
- package/dist/workstation/acp/registry/launch.js +76 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +111 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/fakeAgent.js +13 -0
- package/dist/workstation/acp/test/keeperWorld.js +295 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +135 -0
- package/dist/workstation/acp/test/realAdapter.js +331 -0
- package/dist/workstation/acp/test/scriptAgent.js +214 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1289 -544
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +172 -8
- package/dist/workstation/channels/url.js +1 -1
- package/dist/workstation/config.js +16 -4
- package/dist/workstation/connection/encryptedChannel.js +4 -1
- package/dist/workstation/control/bindingStore.js +200 -0
- package/dist/workstation/control/commands.js +124 -0
- package/dist/workstation/control/daemonIdentity.js +53 -0
- package/dist/workstation/control/enrollment.js +106 -0
- package/dist/workstation/control/inspect.js +71 -0
- package/dist/workstation/control/locks.js +63 -0
- package/dist/workstation/control/protocol.js +40 -0
- package/dist/workstation/control/restartGuard.js +46 -0
- package/dist/workstation/control/runtimeClient.js +97 -0
- package/dist/workstation/control/runtimeHost.js +148 -0
- package/dist/workstation/control/serverIdentity.js +35 -0
- package/dist/workstation/daemon.js +393 -12
- package/dist/workstation/daemonctl.js +103 -14
- package/dist/workstation/desktopControl/audio.js +99 -0
- package/dist/workstation/desktopControl/capabilities.js +92 -0
- package/dist/workstation/desktopControl/captureModes.js +9 -0
- package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
- package/dist/workstation/desktopControl/deviceSockets.js +246 -0
- package/dist/workstation/desktopControl/helperProcess.js +353 -0
- package/dist/workstation/desktopControl/lobby.js +129 -0
- package/dist/workstation/desktopControl/macProvider.js +155 -0
- package/dist/workstation/desktopControl/media.js +331 -0
- package/dist/workstation/desktopControl/nativeProvider.js +1018 -0
- package/dist/workstation/desktopControl/networkEvidence.js +43 -0
- package/dist/workstation/desktopControl/overview.js +58 -0
- package/dist/workstation/desktopControl/protocol.js +302 -0
- package/dist/workstation/desktopControl/quicMedia.js +240 -0
- package/dist/workstation/desktopControl/rateController.js +226 -0
- package/dist/workstation/desktopControl/supervisor.js +1681 -0
- package/dist/workstation/desktopControl/transport.js +27 -0
- package/dist/workstation/desktopControl/wallpaper.js +78 -0
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/credentials.js +142 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +152 -39
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +124 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +289 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +76 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/managedInstall.js +57 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +50 -19
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +141 -4
- package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
- package/dist/workstation/rd/artifact.js +119 -0
- package/dist/workstation/rd/control.js +119 -0
- package/dist/workstation/rd/display.js +29 -0
- package/dist/workstation/rd/provider.js +222 -0
- package/dist/workstation/rd/sessions.js +391 -0
- package/dist/workstation/rd/state.js +45 -0
- package/dist/workstation/rd/update.js +171 -0
- package/dist/workstation/rd/upkeep.js +38 -0
- package/dist/workstation/register.js +27 -37
- package/dist/workstation/service/installer.js +104 -13
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/storageMetrics.js +118 -0
- package/dist/workstation/systemMetrics.js +154 -93
- package/dist/workstation/toolchain.js +94 -0
- package/dist/workstation/tools/browser.js +28 -4
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/artifact.js +2 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/globalInstall.js +116 -0
- package/dist/workstation/update/npmInstaller.js +49 -23
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/rdRollback.js +145 -0
- package/dist/workstation/update/runUpdate.js +3 -2
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/dist/workstation/update/watchdog.js +16790 -334
- package/package.json +11 -8
- 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 -49
- 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/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/delegationMcp.js +0 -24
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/qoderStartup.js +0 -45
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -106
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -262
- package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
- package/dist/workstation/qoderAdapter/contract.js +0 -78
- package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -30
- package/dist/workstation/qoderAdapter/history.js +0 -87
- package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
- package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
- package/dist/workstation/qoderAdapter/main.js +0 -93
- 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 -49
- package/dist/workstation/qoderAdapter/session.js +0 -319
- package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
- package/dist/workstation/qoderAdapter/subwork.js +0 -77
- package/dist/workstation/qoderAdapter/taskControl.js +0 -38
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
- package/dist/workstation/qoderAdapter/usage.js +0 -37
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
- /package/dist/workstation/{qoderAdapter/types.js → desktopControl/provider.js} +0 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { privateDirectory } from '../tools/client.js';
|
|
7
|
+
import { cancelledFilePayload, fileErrorFrame } from './adapters/computerFileFrames.js';
|
|
8
|
+
import { FILES_MUTATING_METHODS } from './adapters/toolRequests.js';
|
|
9
|
+
import { FILES_HOST_MAX_IN_FLIGHT } from './hostProtocol.js';
|
|
10
|
+
/*
|
|
11
|
+
* Daemon side of the files host (files/host.ts): spawns it, multiplexes requests over its IPC channel, cancels, samples
|
|
12
|
+
* its activity, and keeps it alive. A crash (or the hung-IO watchdog's kill) fails every request already sent to it —
|
|
13
|
+
* mutations as outcome-unknown, everything else as offline — and the host is respawned with 1 s -> 30 s exponential
|
|
14
|
+
* backoff; the new host's start runs uploads recover(). Requests that arrive while no host is ready wait for one,
|
|
15
|
+
* bounded. A configuration change restarts the host once it is idle.
|
|
16
|
+
*/
|
|
17
|
+
export const TOOL_DEADLINE_MS = 7_500;
|
|
18
|
+
export const DEFAULT_FILES_HOST_TIMINGS = {
|
|
19
|
+
backoffInitialMs: 1_000, backoffMaxMs: 30_000, backoffResetAfterMs: 30_000, hungPinMs: 60_000, readyWaitMs: 10_000,
|
|
20
|
+
cancelGraceMs: 2_000, stopBudgetMs: 6_000, restartUploadGraceMs: 60_000, readyDeadlineMs: 60_000, spawnPrepareMs: 10_000,
|
|
21
|
+
killExitGraceMs: 5_000, watchdogIntervalMs: 1_000,
|
|
22
|
+
};
|
|
23
|
+
function bounded(work, ms, message) {
|
|
24
|
+
let timer;
|
|
25
|
+
const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); });
|
|
26
|
+
return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
|
|
27
|
+
}
|
|
28
|
+
const defaultScript = () => fileURLToPath(new URL('./host.js', import.meta.url));
|
|
29
|
+
export class FilesHostClient {
|
|
30
|
+
options;
|
|
31
|
+
hostDir;
|
|
32
|
+
timings;
|
|
33
|
+
child;
|
|
34
|
+
/** Requests may be sent to the current host. */
|
|
35
|
+
ready = false;
|
|
36
|
+
/** The current host reported ready (stays true while a pending restart drains it). */
|
|
37
|
+
hostUp = false;
|
|
38
|
+
readyAt = 0;
|
|
39
|
+
optionsReadAt = 0;
|
|
40
|
+
spawning = false;
|
|
41
|
+
stopped = false;
|
|
42
|
+
failures = 0;
|
|
43
|
+
respawnTimer;
|
|
44
|
+
watchdog;
|
|
45
|
+
lastInfo;
|
|
46
|
+
lastStatus;
|
|
47
|
+
lastStatusAt = 0;
|
|
48
|
+
lastActivity = 0;
|
|
49
|
+
/** Requests sent to the current host and not yet answered. */
|
|
50
|
+
sent = new Map();
|
|
51
|
+
/** Requests waiting for a ready host (not yet sent). */
|
|
52
|
+
waiting = 0;
|
|
53
|
+
readyWaiters = new Set();
|
|
54
|
+
restartRequestedAt = null;
|
|
55
|
+
plannedExit = false;
|
|
56
|
+
/** The current host is being shut down or killed: its status and ready messages no longer describe a live host. */
|
|
57
|
+
retiring = false;
|
|
58
|
+
spawnedAt = 0;
|
|
59
|
+
exited;
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.options = options;
|
|
62
|
+
this.hostDir = path.join(path.resolve(options.configDir), 'files-host');
|
|
63
|
+
this.timings = { ...DEFAULT_FILES_HOST_TIMINGS, ...options.timings };
|
|
64
|
+
this.watchdog = setInterval(() => this.tick(), this.timings.watchdogIntervalMs);
|
|
65
|
+
this.watchdog.unref();
|
|
66
|
+
}
|
|
67
|
+
/** Starts the host (idempotent). Failures are retried with backoff; requests wait for readiness, bounded. */
|
|
68
|
+
start() {
|
|
69
|
+
if (this.stopped || this.child || this.spawning || this.respawnTimer)
|
|
70
|
+
return;
|
|
71
|
+
void this.spawn();
|
|
72
|
+
}
|
|
73
|
+
/** The `tools.info` file fields of the last host that became ready (undefined before the first one). */
|
|
74
|
+
info() { return this.lastInfo; }
|
|
75
|
+
/** Live sample for TaskRegistry: in-progress uploads + running media encodes of the current host. */
|
|
76
|
+
activity() {
|
|
77
|
+
if (!this.child || !this.hostUp || !this.lastStatus)
|
|
78
|
+
return 0;
|
|
79
|
+
return this.lastStatus.uploads + this.lastStatus.encodes;
|
|
80
|
+
}
|
|
81
|
+
/** Process id of the current host (diagnostics and tests). */
|
|
82
|
+
get pid() { return this.child?.pid; }
|
|
83
|
+
get isReady() { return this.ready; }
|
|
84
|
+
/** The configured roots changed: restart the host once it is idle (or drained, after a grace). */
|
|
85
|
+
reconfigure() {
|
|
86
|
+
if (this.stopped)
|
|
87
|
+
return;
|
|
88
|
+
this.restartRequestedAt ??= Date.now();
|
|
89
|
+
this.tick();
|
|
90
|
+
}
|
|
91
|
+
/** One `file_*_request`; never rejects — every failure is a result frame with one of the wire codes. */
|
|
92
|
+
async runFrame(request, signal) {
|
|
93
|
+
const mutation = request.t === 'file_write_request' || request.t === 'file_mkdir_request';
|
|
94
|
+
const outcome = await this.call({ op: 'frame', params: request, deadlineAt: null }, mutation, null, signal);
|
|
95
|
+
if (outcome.ok)
|
|
96
|
+
return outcome.result;
|
|
97
|
+
if (outcome.reason === 'cancelled')
|
|
98
|
+
return fileErrorFrame(request, cancelledFilePayload(request.t));
|
|
99
|
+
if (outcome.reason === 'lost' && mutation)
|
|
100
|
+
return fileErrorFrame(request, { code: 'outcome_unknown', message: outcome.error });
|
|
101
|
+
return fileErrorFrame(request, { code: 'workstation_offline', message: outcome.error });
|
|
102
|
+
}
|
|
103
|
+
/** One `files.*` tool request; rejects with the `tool_result.error` text. */
|
|
104
|
+
async tool(method, params) {
|
|
105
|
+
const deadlineAt = Date.now() + TOOL_DEADLINE_MS;
|
|
106
|
+
const outcome = await this.call({ op: 'tool', params: { method, params }, deadlineAt }, FILES_MUTATING_METHODS.has(method), deadlineAt);
|
|
107
|
+
if (outcome.ok)
|
|
108
|
+
return outcome.result;
|
|
109
|
+
throw new Error(outcome.error);
|
|
110
|
+
}
|
|
111
|
+
/** `terminal.create`'s cwd (absent = home) resolved in the configured-roots scope, as the legacy keeper did; rejects with its error text. */
|
|
112
|
+
async terminalCwd(cwd) {
|
|
113
|
+
const deadlineAt = Date.now() + TOOL_DEADLINE_MS;
|
|
114
|
+
const outcome = await this.call({ op: 'terminalCwd', params: { cwd }, deadlineAt }, false, deadlineAt);
|
|
115
|
+
if (outcome.ok)
|
|
116
|
+
return outcome.result.path;
|
|
117
|
+
throw new Error(outcome.error);
|
|
118
|
+
}
|
|
119
|
+
/** Cleans orphaned upload temporaries recorded in a retired process's manifest directory (`recoverUploads` in the host). */
|
|
120
|
+
async recoverUploads(dir) {
|
|
121
|
+
const deadlineAt = Date.now() + 60_000;
|
|
122
|
+
const outcome = await this.call({ op: 'recoverUploads', params: { dir }, deadlineAt }, true, deadlineAt);
|
|
123
|
+
if (!outcome.ok)
|
|
124
|
+
throw new Error(outcome.error);
|
|
125
|
+
}
|
|
126
|
+
/** Stops the host for good: graceful shutdown within the stop budget, then SIGKILL. Bounded. */
|
|
127
|
+
async stop() {
|
|
128
|
+
this.stopped = true;
|
|
129
|
+
clearInterval(this.watchdog);
|
|
130
|
+
if (this.respawnTimer) {
|
|
131
|
+
clearTimeout(this.respawnTimer);
|
|
132
|
+
this.respawnTimer = undefined;
|
|
133
|
+
}
|
|
134
|
+
for (const wake of [...this.readyWaiters])
|
|
135
|
+
wake();
|
|
136
|
+
await this.shutdownChild();
|
|
137
|
+
}
|
|
138
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
139
|
+
call(body, mutation, deadlineAt, signal) {
|
|
140
|
+
return new Promise(resolve => {
|
|
141
|
+
let done = false;
|
|
142
|
+
let grace;
|
|
143
|
+
let deadline;
|
|
144
|
+
const id = randomUUID();
|
|
145
|
+
const message = { ...body, id };
|
|
146
|
+
const pending = { message, mutation, settle: outcome => {
|
|
147
|
+
if (done)
|
|
148
|
+
return;
|
|
149
|
+
done = true;
|
|
150
|
+
if (grace)
|
|
151
|
+
clearTimeout(grace);
|
|
152
|
+
if (deadline)
|
|
153
|
+
clearTimeout(deadline);
|
|
154
|
+
signal?.removeEventListener('abort', onAbort);
|
|
155
|
+
if (this.sent.get(id) === pending)
|
|
156
|
+
this.sent.delete(id);
|
|
157
|
+
resolve(outcome);
|
|
158
|
+
} };
|
|
159
|
+
const onAbort = () => {
|
|
160
|
+
if (!this.sent.has(id)) {
|
|
161
|
+
pending.settle({ ok: false, error: 'File operation was cancelled', reason: 'cancelled' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.post({ cancel: id });
|
|
165
|
+
// The host answers a cancelled frame itself at its next checkpoint; a hung one does not, so answer for it.
|
|
166
|
+
grace ??= setTimeout(() => pending.settle({ ok: false, error: 'File operation was cancelled', reason: 'cancelled' }), this.timings.cancelGraceMs);
|
|
167
|
+
};
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
pending.settle({ ok: false, error: 'File operation was cancelled', reason: 'cancelled' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
173
|
+
if (deadlineAt !== null) {
|
|
174
|
+
deadline = setTimeout(() => {
|
|
175
|
+
if (this.sent.has(id))
|
|
176
|
+
this.post({ cancel: id });
|
|
177
|
+
pending.settle({ ok: false, error: 'Workstation tool timed out; mutation outcome may be unknown', reason: 'timeout' });
|
|
178
|
+
}, Math.max(0, deadlineAt - Date.now()));
|
|
179
|
+
}
|
|
180
|
+
if (this.sent.size + this.waiting >= FILES_HOST_MAX_IN_FLIGHT) {
|
|
181
|
+
pending.settle({ ok: false, error: 'Workstation file service is busy; try again shortly', reason: 'unavailable' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
void (async () => {
|
|
185
|
+
this.waiting++;
|
|
186
|
+
let ready;
|
|
187
|
+
try {
|
|
188
|
+
ready = await this.whenReady(deadlineAt === null ? this.timings.readyWaitMs : Math.min(this.timings.readyWaitMs, deadlineAt - Date.now()), () => done);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
this.waiting--;
|
|
192
|
+
}
|
|
193
|
+
if (done)
|
|
194
|
+
return;
|
|
195
|
+
if (!ready || !this.child) {
|
|
196
|
+
pending.settle({ ok: false, error: 'Workstation file service is unavailable; try again shortly', reason: 'unavailable' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.sent.set(id, pending);
|
|
200
|
+
if (!this.post(message)) {
|
|
201
|
+
// Nothing reached the host: not run, so offline even for a mutation.
|
|
202
|
+
this.sent.delete(id);
|
|
203
|
+
pending.settle({ ok: false, error: 'Workstation file service is unavailable; try again shortly', reason: 'unavailable' });
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
post(message) {
|
|
209
|
+
const child = this.child;
|
|
210
|
+
if (!child?.connected)
|
|
211
|
+
return false;
|
|
212
|
+
try {
|
|
213
|
+
child.send(message);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
whenReady(timeoutMs, abandoned) {
|
|
221
|
+
if (this.ready && this.child)
|
|
222
|
+
return Promise.resolve(true);
|
|
223
|
+
if (this.stopped || timeoutMs <= 0)
|
|
224
|
+
return Promise.resolve(false);
|
|
225
|
+
this.start();
|
|
226
|
+
return new Promise(resolve => {
|
|
227
|
+
const finish = () => {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
clearInterval(poll);
|
|
230
|
+
this.readyWaiters.delete(wake);
|
|
231
|
+
resolve(this.ready && !!this.child && !this.stopped);
|
|
232
|
+
};
|
|
233
|
+
const wake = () => finish();
|
|
234
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
235
|
+
// A caller that was settled meanwhile (cancel, deadline) stops waiting.
|
|
236
|
+
const poll = setInterval(() => { if (abandoned())
|
|
237
|
+
finish(); }, 50);
|
|
238
|
+
this.readyWaiters.add(wake);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async spawn() {
|
|
242
|
+
if (this.stopped || this.child || this.spawning)
|
|
243
|
+
return;
|
|
244
|
+
this.spawning = true;
|
|
245
|
+
try {
|
|
246
|
+
const readAt = Date.now();
|
|
247
|
+
const base = await bounded((async () => { await privateDirectory(this.hostDir); return this.options.options(); })(), this.timings.spawnPrepareMs, 'preparing the files host timed out');
|
|
248
|
+
this.optionsReadAt = readAt;
|
|
249
|
+
const init = { ...base, uploadStateDir: path.join(this.hostDir, 'uploads') };
|
|
250
|
+
if (this.stopped)
|
|
251
|
+
return;
|
|
252
|
+
const child = fork(this.options.script ?? defaultScript(), [], {
|
|
253
|
+
cwd: this.hostDir, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], execArgv: [], serialization: 'json',
|
|
254
|
+
});
|
|
255
|
+
this.child = child;
|
|
256
|
+
this.ready = false;
|
|
257
|
+
this.hostUp = false;
|
|
258
|
+
this.lastStatus = undefined;
|
|
259
|
+
this.lastStatusAt = Date.now();
|
|
260
|
+
this.plannedExit = false;
|
|
261
|
+
this.retiring = false;
|
|
262
|
+
this.spawnedAt = Date.now();
|
|
263
|
+
let resolveExited;
|
|
264
|
+
this.exited = new Promise(resolve => { resolveExited = resolve; });
|
|
265
|
+
if (child.stderr) {
|
|
266
|
+
const lines = createInterface({ input: child.stderr });
|
|
267
|
+
lines.on('line', line => this.log(`[files-host] ${line.slice(0, 2048)}`));
|
|
268
|
+
}
|
|
269
|
+
child.on('message', (message) => { if (this.child === child)
|
|
270
|
+
this.onMessage(message); });
|
|
271
|
+
let gone = false;
|
|
272
|
+
const onGone = (detail) => {
|
|
273
|
+
if (gone)
|
|
274
|
+
return;
|
|
275
|
+
gone = true;
|
|
276
|
+
resolveExited();
|
|
277
|
+
if (this.child === child)
|
|
278
|
+
this.onExit(detail);
|
|
279
|
+
};
|
|
280
|
+
child.on('exit', (code, signal) => onGone(signal ? `signal ${signal}` : `code ${code}`));
|
|
281
|
+
child.on('error', error => { this.log(`[files-host] ${error.message}`); if (child.exitCode === null && child.signalCode === null && !child.pid)
|
|
282
|
+
onGone('spawn failed'); });
|
|
283
|
+
child.send({ t: 'init', options: init });
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
this.log(`[files-host] start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
287
|
+
this.scheduleRespawn();
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
this.spawning = false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
onMessage(message) {
|
|
294
|
+
if (!message || typeof message !== 'object')
|
|
295
|
+
return;
|
|
296
|
+
if ('id' in message) {
|
|
297
|
+
const pending = this.sent.get(message.id);
|
|
298
|
+
if (!pending)
|
|
299
|
+
return;
|
|
300
|
+
pending.settle(message.ok ? { ok: true, result: message.result } : { ok: false, error: message.error, reason: 'host' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Only replies are still meaningful from a host being retired (in-flight requests may finish during a graceful stop).
|
|
304
|
+
if (this.retiring)
|
|
305
|
+
return;
|
|
306
|
+
if (message.t === 'ready') {
|
|
307
|
+
this.lastInfo = message.info;
|
|
308
|
+
this.ready = true;
|
|
309
|
+
this.hostUp = true;
|
|
310
|
+
this.readyAt = Date.now();
|
|
311
|
+
this.lastStatusAt = this.readyAt;
|
|
312
|
+
for (const wake of [...this.readyWaiters])
|
|
313
|
+
wake();
|
|
314
|
+
}
|
|
315
|
+
else if (message.t === 'init_failed') {
|
|
316
|
+
this.log(`[files-host] init failed: ${message.error}`);
|
|
317
|
+
}
|
|
318
|
+
else if (message.t === 'status') {
|
|
319
|
+
this.lastStatus = message;
|
|
320
|
+
this.lastStatusAt = Date.now();
|
|
321
|
+
this.activityMayHaveChanged();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
onExit(detail) {
|
|
325
|
+
const planned = this.plannedExit;
|
|
326
|
+
const upFor = this.readyAt ? Date.now() - this.readyAt : 0;
|
|
327
|
+
this.child = undefined;
|
|
328
|
+
this.ready = false;
|
|
329
|
+
this.hostUp = false;
|
|
330
|
+
this.readyAt = 0;
|
|
331
|
+
this.lastStatus = undefined;
|
|
332
|
+
this.retiring = false;
|
|
333
|
+
for (const pending of [...this.sent.values()]) {
|
|
334
|
+
pending.settle({
|
|
335
|
+
ok: false, reason: 'lost',
|
|
336
|
+
error: pending.mutation
|
|
337
|
+
? (pending.message.op === 'frame' ? 'Workstation file service restarted; the outcome is unknown' : 'Workstation file service restarted; mutation outcome may be unknown')
|
|
338
|
+
: 'Workstation file service restarted; try again',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
this.sent.clear();
|
|
342
|
+
this.activityMayHaveChanged();
|
|
343
|
+
if (this.stopped)
|
|
344
|
+
return;
|
|
345
|
+
if (planned) {
|
|
346
|
+
this.failures = 0;
|
|
347
|
+
void this.spawn();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.log(`[files-host] exited (${detail}); restarting`);
|
|
351
|
+
if (upFor >= this.timings.backoffResetAfterMs)
|
|
352
|
+
this.failures = 0;
|
|
353
|
+
this.scheduleRespawn();
|
|
354
|
+
}
|
|
355
|
+
scheduleRespawn() {
|
|
356
|
+
if (this.stopped || this.respawnTimer)
|
|
357
|
+
return;
|
|
358
|
+
const delay = Math.min(this.timings.backoffMaxMs, this.timings.backoffInitialMs * 2 ** this.failures);
|
|
359
|
+
this.failures++;
|
|
360
|
+
this.respawnTimer = setTimeout(() => { this.respawnTimer = undefined; void this.spawn(); }, delay);
|
|
361
|
+
this.respawnTimer.unref();
|
|
362
|
+
}
|
|
363
|
+
tick() {
|
|
364
|
+
const child = this.child;
|
|
365
|
+
if (!child || this.stopped || this.retiring)
|
|
366
|
+
return;
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
if (!this.hostUp) {
|
|
369
|
+
if (now - this.spawnedAt > this.timings.readyDeadlineMs) {
|
|
370
|
+
this.log(`[files-host] not ready ${now - this.spawnedAt} ms after start; killing pid ${child.pid}`);
|
|
371
|
+
this.kill(child);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const since = now - this.lastStatusAt;
|
|
376
|
+
const pinned = this.lastStatus?.pinnedMs ? this.lastStatus.pinnedMs + since : 0;
|
|
377
|
+
if (pinned > this.timings.hungPinMs || since > this.timings.hungPinMs) {
|
|
378
|
+
// Last resort for a hung IO (NFS): nothing else releases the shared pinned() mutex. In-flight requests fail as
|
|
379
|
+
// lost (mutations outcome-unknown) when the exit arrives; the respawn runs uploads recover().
|
|
380
|
+
this.log(`[files-host] ${pinned > this.timings.hungPinMs ? `pinned section held ${pinned} ms` : `no status for ${since} ms`}; killing pid ${child.pid}`);
|
|
381
|
+
this.kill(child);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (this.restartRequestedAt === null || this.plannedExit)
|
|
385
|
+
return;
|
|
386
|
+
// The current host read its options after the change was requested: it is already current.
|
|
387
|
+
if (this.restartRequestedAt <= this.optionsReadAt) {
|
|
388
|
+
this.restartRequestedAt = null;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const overdue = now - this.restartRequestedAt > this.timings.restartUploadGraceMs;
|
|
392
|
+
// Past the grace, stop feeding it: new requests wait for the restarted host while the ones in flight finish.
|
|
393
|
+
if (overdue)
|
|
394
|
+
this.ready = false;
|
|
395
|
+
if (this.sent.size === 0 && (overdue || (this.lastStatus?.uploads ?? 0) === 0)) {
|
|
396
|
+
this.restartRequestedAt = null;
|
|
397
|
+
this.log('[files-host] workspaces changed; restarting');
|
|
398
|
+
void this.shutdownChild(true);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Graceful shutdown of the current host (bounded by SIGKILL). `planned` respawns right after it exits. */
|
|
402
|
+
async shutdownChild(planned = false) {
|
|
403
|
+
const child = this.child;
|
|
404
|
+
if (!child)
|
|
405
|
+
return;
|
|
406
|
+
this.ready = false;
|
|
407
|
+
this.plannedExit = planned;
|
|
408
|
+
this.retiring = true;
|
|
409
|
+
this.lastStatus = undefined;
|
|
410
|
+
this.activityMayHaveChanged();
|
|
411
|
+
const exited = this.exited ?? Promise.resolve();
|
|
412
|
+
if (!this.post({ t: 'shutdown' }))
|
|
413
|
+
this.kill(child);
|
|
414
|
+
let timer;
|
|
415
|
+
const budget = new Promise(resolve => { timer = setTimeout(() => resolve('timeout'), this.timings.stopBudgetMs); });
|
|
416
|
+
if (await Promise.race([exited.then(() => 'exited'), budget]) === 'timeout') {
|
|
417
|
+
this.kill(child);
|
|
418
|
+
await Promise.race([exited, new Promise(resolve => setTimeout(resolve, this.timings.killExitGraceMs + 100))]);
|
|
419
|
+
}
|
|
420
|
+
if (timer)
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* SIGKILL the host. Its 'exit' normally follows and fails what was sent to it; if it does not arrive within the grace
|
|
425
|
+
* (a zombie the OS will not reap, an exit event lost), the child is treated as gone anyway so requests settle and a
|
|
426
|
+
* successor starts.
|
|
427
|
+
*/
|
|
428
|
+
kill(child) {
|
|
429
|
+
if (this.child !== child)
|
|
430
|
+
return;
|
|
431
|
+
this.ready = false;
|
|
432
|
+
this.hostUp = false;
|
|
433
|
+
this.retiring = true;
|
|
434
|
+
this.lastStatus = undefined;
|
|
435
|
+
this.activityMayHaveChanged();
|
|
436
|
+
try {
|
|
437
|
+
child.kill('SIGKILL');
|
|
438
|
+
}
|
|
439
|
+
catch { /* already gone */ }
|
|
440
|
+
const timer = setTimeout(() => { if (this.child === child)
|
|
441
|
+
this.onExit('killed; no exit event'); }, this.timings.killExitGraceMs);
|
|
442
|
+
timer.unref();
|
|
443
|
+
}
|
|
444
|
+
activityMayHaveChanged() {
|
|
445
|
+
const now = this.activity();
|
|
446
|
+
if (now === this.lastActivity)
|
|
447
|
+
return;
|
|
448
|
+
this.lastActivity = now;
|
|
449
|
+
try {
|
|
450
|
+
this.options.onActivityChanged?.();
|
|
451
|
+
}
|
|
452
|
+
catch { /* a listener failure must not break the host client */ }
|
|
453
|
+
}
|
|
454
|
+
log(line) { (this.options.log ?? console.error)(line); }
|
|
455
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { FileError } from './errors.js';
|
|
5
|
+
/*
|
|
6
|
+
* Credential storage the platform agent file tools (`file_*_request`, the request-root scope) never read, list or
|
|
7
|
+
* change. This is a security boundary, not a policy an approval can lift: an agent that lacks a capability must not go
|
|
8
|
+
* looking for another client's identity, a token or a key to work around it.
|
|
9
|
+
*
|
|
10
|
+
* The check runs on canonical paths (the request root after realpath, components below it already refused when they
|
|
11
|
+
* are links), and again inside the `pinned()` section on what the kernel resolves: the pinned directory's realpath and
|
|
12
|
+
* each existing leaf's realpath, which return the names stored on disk. A relative path, a symlinked root, a directory
|
|
13
|
+
* swapped for a link after validation, or a leaf spelled so that a case-insensitive file system resolves it to a store
|
|
14
|
+
* (`.git-credentialſ`, `.ßh`) cannot bypass it. Names are compared folded: NFC, then full case folding (`ſ`→`s`,
|
|
15
|
+
* `ß`→`ss`, `K`→`k`), so a missing leaf created under such a spelling is refused too.
|
|
16
|
+
*
|
|
17
|
+
* The stores are named below the process's home, below any other home-like directory (`/Users/<name>`, `/home/<name>`,
|
|
18
|
+
* `/root`, `/var/root`: a daemon whose HOME is not the workspace owner's), and below the configured client
|
|
19
|
+
* directories. The lists equal `contracts/credential-stores/credential-stores.json` (a test checks it), which the
|
|
20
|
+
* server's early refusal (axom-core `ProtectedCredentialPaths`) matches too.
|
|
21
|
+
*
|
|
22
|
+
* Only the user-facing Files surfaces (configured roots, read-only previews, the owner's file manager) are not fenced
|
|
23
|
+
* here: they act for the user at the keyboard, not for an agent.
|
|
24
|
+
*/
|
|
25
|
+
export const PROTECTED_CREDENTIAL_MESSAGE = 'This path is protected credential storage. Agents may not read, list or change it by any means. Tell the user what is needed instead of looking for another way.';
|
|
26
|
+
/** Below a home directory; each entry covers itself and everything below it; a trailing `*` matches a name prefix. */
|
|
27
|
+
export const HOME_ENTRIES = [
|
|
28
|
+
// Axomwork's own identities: the desktop/app clients, the CLI's auth stores and config.
|
|
29
|
+
'.axom/clients', '.axom/auth', '.axom/config.json', '.axom/device-id',
|
|
30
|
+
// SSH, GPG, password managers and desktop keyrings.
|
|
31
|
+
'.ssh', '.gnupg', '.password-store', '.local/share/keyrings', '.config/op',
|
|
32
|
+
// Agent CLIs' credentials and session data (their instructions and settings stay readable).
|
|
33
|
+
'.claude.json', '.claude/.credentials.json', '.claude/projects', '.claude/sessions', '.claude/shell-snapshots',
|
|
34
|
+
'.claude/history.jsonl', '.claude/backups', '.claude/session-env', '.claude/file-history', '.claude/ide',
|
|
35
|
+
'.codex/auth.json', '.codex/sessions', '.codex/history.jsonl',
|
|
36
|
+
'.acpx',
|
|
37
|
+
'.gemini/oauth_creds.json', '.gemini/jetski-standalone-oauth-token', '.gemini/antigravity*',
|
|
38
|
+
'.commandcode/auth.json', '.commandcode/projects', '.commandcode/history.jsonl', '.commandcode/file-history',
|
|
39
|
+
'.qoder/.auth', '.kimi', '.zcode',
|
|
40
|
+
// Developer, database and cloud credentials.
|
|
41
|
+
'.config/gh/hosts.yml', '.git-credentials', '.netrc', '.npmrc', '.pypirc', '.docker/config.json',
|
|
42
|
+
'.pgpass', '.my.cnf', '.vault-token', '.cargo/credentials', '.cargo/credentials.toml', '.gem/credentials',
|
|
43
|
+
'.m2/settings-security.xml', '.terraform.d/credentials.tfrc.json', '.gradle/gradle.properties',
|
|
44
|
+
'.aws', '.azure', '.kube', '.config/gcloud',
|
|
45
|
+
// macOS keychains and the Axomwork credential broker's state.
|
|
46
|
+
'Library/Keychains', 'Library/Caches/com.platless.axom-credentials',
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* The workstation client directory holds the binding to the server (keys, identity, provider tokens, keeper sockets,
|
|
50
|
+
* the agent browser's profiles). Its logs, caches and update state are operational and stay readable.
|
|
51
|
+
*/
|
|
52
|
+
export const WORKSTATION_ENTRIES = [
|
|
53
|
+
'client.key', 'server.pub', 'config.yml', 'binding-history.json', 'operation.json', 'desktop-provider.json',
|
|
54
|
+
'desktop-preferences.json', 'control', 'keepers', 'agents', 'files-host', 'browser-profiles',
|
|
55
|
+
];
|
|
56
|
+
export const ABSOLUTE_ENTRIES = ['/Library/Keychains'];
|
|
57
|
+
/** Home-like directories besides the process's own; `*` is one path component. */
|
|
58
|
+
export const HOME_ROOTS = ['/Users/*', '/home/*', '/root', '/var/root', '/private/var/root'];
|
|
59
|
+
/** `/tmp/com.platless.axom-credentials.<uid>.sock` (credentialBroker.ts) and anything else the broker names so. */
|
|
60
|
+
export const BROKER_SOCKET_PREFIX = 'com.platless.axom-credentials.';
|
|
61
|
+
export const TEMP_DIRECTORIES = ['/tmp', '/private/tmp'];
|
|
62
|
+
/** NFC, then full case folding the way a case-insensitive file system compares (`ſ`→`s`, `ß`→`ss`, `K`→`k`). */
|
|
63
|
+
export function foldName(value) { return value.normalize('NFC').toUpperCase().toLowerCase(); }
|
|
64
|
+
function segments(absolute) { return foldName(path.resolve(absolute)).split(path.sep).filter(Boolean); }
|
|
65
|
+
function canonical(target) {
|
|
66
|
+
try {
|
|
67
|
+
return realpathSync(target);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return path.resolve(target);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const patterns = (entries) => entries.map(entry => foldName(entry).split('/').filter(Boolean));
|
|
74
|
+
const HOME_PATTERNS = patterns(HOME_ENTRIES);
|
|
75
|
+
const WORKSTATION_PATTERNS = patterns(WORKSTATION_ENTRIES);
|
|
76
|
+
const ABSOLUTE_PATTERNS = patterns(ABSOLUTE_ENTRIES);
|
|
77
|
+
const HOME_ROOT_PATTERNS = patterns(HOME_ROOTS);
|
|
78
|
+
const WORKSTATION_BELOW_HOME = ['.axom', 'workstation'];
|
|
79
|
+
/** [target] starts with [pattern]; `*` alone is any one component, a trailing `*` a name prefix. */
|
|
80
|
+
function startsWith(target, pattern) {
|
|
81
|
+
if (target.length < pattern.length)
|
|
82
|
+
return false;
|
|
83
|
+
return pattern.every((part, index) => part === '*' || (part.endsWith('*') ? target[index].startsWith(part.slice(0, -1)) : target[index] === part));
|
|
84
|
+
}
|
|
85
|
+
function coveredBelow(target, base, entries) {
|
|
86
|
+
if (!startsWith(target, base))
|
|
87
|
+
return false;
|
|
88
|
+
const rest = target.slice(base.length);
|
|
89
|
+
return entries.some(entry => startsWith(rest, entry));
|
|
90
|
+
}
|
|
91
|
+
let cached;
|
|
92
|
+
/** Both spellings of [value]: as given (a relative one resolved as the client resolves it) and canonical. */
|
|
93
|
+
function spellings(value) {
|
|
94
|
+
if (!value)
|
|
95
|
+
return [];
|
|
96
|
+
const unique = new Map();
|
|
97
|
+
for (const spelling of [path.resolve(value), canonical(value)])
|
|
98
|
+
unique.set(spelling, segments(spelling));
|
|
99
|
+
return [...unique.values()];
|
|
100
|
+
}
|
|
101
|
+
function bases(home) {
|
|
102
|
+
const key = JSON.stringify([home, process.env.AXOM_WORKSTATION_CONFIG_DIR ?? null, process.env.AXOM_CONFIG_DIR ?? null]);
|
|
103
|
+
if (cached?.key !== key) {
|
|
104
|
+
cached = {
|
|
105
|
+
key,
|
|
106
|
+
bases: {
|
|
107
|
+
homes: spellings(home),
|
|
108
|
+
workstations: spellings(process.env.AXOM_WORKSTATION_CONFIG_DIR),
|
|
109
|
+
// AXOM_CONFIG_DIR stands in for `~/.axom`: its config.json and device-id are the CLI's.
|
|
110
|
+
cliConfigs: spellings(process.env.AXOM_CONFIG_DIR),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return cached.bases;
|
|
115
|
+
}
|
|
116
|
+
const CLI_CONFIG_PATTERNS = patterns(['config.json', 'device-id']);
|
|
117
|
+
const TEMP_PATTERNS = patterns(TEMP_DIRECTORIES);
|
|
118
|
+
const BROKER_PREFIX = foldName(BROKER_SOCKET_PREFIX);
|
|
119
|
+
/** True when [target] (absolute) is, or lies below, protected credential storage. */
|
|
120
|
+
export function isCredentialPath(target, home = os.homedir()) {
|
|
121
|
+
const folded = segments(target);
|
|
122
|
+
if (ABSOLUTE_PATTERNS.some(entry => startsWith(folded, entry)))
|
|
123
|
+
return true;
|
|
124
|
+
if (TEMP_PATTERNS.some(dir => folded.length > dir.length && startsWith(folded, dir) && folded[dir.length].startsWith(BROKER_PREFIX)))
|
|
125
|
+
return true;
|
|
126
|
+
const { homes, workstations, cliConfigs } = bases(home);
|
|
127
|
+
// Any home-like directory the target is below, besides the process's own (a daemon running as another user).
|
|
128
|
+
const homeLike = HOME_ROOT_PATTERNS.filter(root => startsWith(folded, root)).map(root => folded.slice(0, root.length));
|
|
129
|
+
for (const base of [...homes, ...homeLike]) {
|
|
130
|
+
if (coveredBelow(folded, base, HOME_PATTERNS))
|
|
131
|
+
return true;
|
|
132
|
+
if (coveredBelow(folded, [...base, ...WORKSTATION_BELOW_HOME], WORKSTATION_PATTERNS))
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
if (workstations.some(base => coveredBelow(folded, base, WORKSTATION_PATTERNS)))
|
|
136
|
+
return true;
|
|
137
|
+
return cliConfigs.some(base => coveredBelow(folded, base, CLI_CONFIG_PATTERNS));
|
|
138
|
+
}
|
|
139
|
+
export function assertNotCredentialPath(target) {
|
|
140
|
+
if (isCredentialPath(target))
|
|
141
|
+
throw new FileError('permission_denied', PROTECTED_CREDENTIAL_MESSAGE);
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class FileError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = 'FileError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Maps an fs errno (or anything else) to a coded error; a FileError passes through unchanged. */
|
|
10
|
+
export function toFileError(error) {
|
|
11
|
+
if (error instanceof FileError)
|
|
12
|
+
return error;
|
|
13
|
+
const code = error?.code;
|
|
14
|
+
if (code === 'ENOENT')
|
|
15
|
+
return new FileError('not_found', 'File or parent directory not found');
|
|
16
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
17
|
+
return new FileError('permission_denied', 'File access denied');
|
|
18
|
+
if (code === 'EEXIST')
|
|
19
|
+
return new FileError('conflict', 'Target already exists');
|
|
20
|
+
if (code === 'ELOOP')
|
|
21
|
+
return new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
22
|
+
return new FileError('io_error', 'File operation failed');
|
|
23
|
+
}
|
|
24
|
+
export function cancelledError() { return new FileError('workstation_offline', 'File operation was cancelled'); }
|
|
25
|
+
export function throwIfAborted(signal) {
|
|
26
|
+
if (signal?.aborted)
|
|
27
|
+
throw cancelledError();
|
|
28
|
+
}
|