@platless/axom-cli 0.1.0 → 0.5.9
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 +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +132 -0
- 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 +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -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/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +63 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +36 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- 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 +32 -14
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2514 -681
- package/dist/workstation/acp/acpClient.js +95 -318
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +28 -356
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- 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 +235 -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/delegationMcp.js +24 -0
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +144 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +80 -0
- package/dist/workstation/acp/permission.js +335 -37
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -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 +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -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 +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +385 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +171 -0
- 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 +185 -9
- package/dist/workstation/acp/test/keeperWorld.js +267 -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/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -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/acp/validate.js +33 -5
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1686 -348
- package/dist/workstation/channels/agent.js +14 -6
- package/dist/workstation/channels/computer.js +203 -7
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +86 -6
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +309 -6
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -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/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/files/text.js +356 -0
- 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 +120 -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 +249 -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 +75 -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/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +201 -8
- package/dist/workstation/protocol/common.js +36 -0
- package/dist/workstation/protocol/computer.js +107 -7
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
- package/dist/workstation/qoderAdapter/history.js +85 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +95 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +85 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +277 -0
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +124 -0
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +86 -0
- package/dist/workstation/update/coordinator.js +32 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +45 -0
- package/dist/workstation/update/taskRegistry.js +141 -23
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +20 -7
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
- package/dist/workstation/acp/acpSession.js +0 -1047
- package/dist/workstation/acp/contract.js +0 -494
- package/dist/workstation/acp/draftInterceptor.js +0 -154
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { base64, CHUNK_BYTES, identifier, integer, keys, string } from '../tools/validation.js';
|
|
4
|
+
import { openMediaSource, pinned, resolveAbsolute } from './fence.js';
|
|
5
|
+
import { configuredRoots, filesystemScope, inside, READ_ONLY } from './scope.js';
|
|
6
|
+
import { MAX_UPLOAD_BYTES, TEMP_PREFIX, UPLOAD_TTL_MS, UploadStore } from './uploads.js';
|
|
7
|
+
import { FileIndexCache } from './find/fileIndex.js';
|
|
8
|
+
import { rankPaths } from './find/fuzzy.js';
|
|
9
|
+
export const LIST_MAX_ENTRIES = 2000;
|
|
10
|
+
export const LIST_MAX_VISITED = 10000;
|
|
11
|
+
export const FIND_DEFAULT_LIMIT = 50;
|
|
12
|
+
export const FIND_MAX_LIMIT = 200;
|
|
13
|
+
/**
|
|
14
|
+
* The Files tab's file semantics (`files.*` tool requests): absolute paths in the `configured-roots` scope, or in the
|
|
15
|
+
* `read-only` scope when a request says `readOnly: true`. Errors are thrown with the messages callers see today
|
|
16
|
+
* (`tool_result.error`). Tools never compose a shell command.
|
|
17
|
+
*/
|
|
18
|
+
export class StructuredFiles {
|
|
19
|
+
scope;
|
|
20
|
+
uploads;
|
|
21
|
+
// Home and its ancestors walk instead of asking git: a dotfiles repository at `~` (often ignoring
|
|
22
|
+
// `*`) would answer for the whole home with just its tracked dotfiles.
|
|
23
|
+
indexes = new FileIndexCache(name => name.startsWith(TEMP_PREFIX), Date.now, scope => { const relative = path.relative(scope, this.home); return !relative.startsWith('..') && !path.isAbsolute(relative); });
|
|
24
|
+
home;
|
|
25
|
+
roots;
|
|
26
|
+
downloads;
|
|
27
|
+
constructor(scope, ttl, stateDir) {
|
|
28
|
+
this.scope = scope;
|
|
29
|
+
this.home = scope.home;
|
|
30
|
+
this.roots = [...scope.roots];
|
|
31
|
+
this.downloads = path.join(scope.home, 'Downloads');
|
|
32
|
+
this.uploads = new UploadStore(scope.roots, ttl, stateDir);
|
|
33
|
+
}
|
|
34
|
+
static async create(home, roots, ttl = UPLOAD_TTL_MS, stateDir, filesystem = false) {
|
|
35
|
+
const scope = filesystem ? await filesystemScope(home) : await configuredRoots(home, roots);
|
|
36
|
+
if (stateDir)
|
|
37
|
+
await UploadStore.prepareStateDir(stateDir);
|
|
38
|
+
const files = new StructuredFiles(scope, ttl, stateDir);
|
|
39
|
+
await files.uploads.recover((raw, missingLeaf, mutation) => files.resolve(raw, missingLeaf, mutation));
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
42
|
+
/** Uploads in flight. */
|
|
43
|
+
get activeUploads() { return this.uploads.size; }
|
|
44
|
+
scopeFor(readOnly) { return readOnly ? READ_ONLY : this.scope; }
|
|
45
|
+
/** Validates an absolute path in this instance's configured-roots scope (or the read-only scope). */
|
|
46
|
+
resolve(raw, missingLeaf = false, mutation = false, readOnly = false, followLeaf = true) {
|
|
47
|
+
return resolveAbsolute(this.scopeFor(readOnly), raw, missingLeaf, mutation, followLeaf);
|
|
48
|
+
}
|
|
49
|
+
/** The descriptor stays pinned after cwd is restored; media children inherit it, never re-open the path. */
|
|
50
|
+
openMediaSource(raw, readOnly = false) {
|
|
51
|
+
return openMediaSource(this.scopeFor(readOnly), raw);
|
|
52
|
+
}
|
|
53
|
+
async dispatch(method, p) {
|
|
54
|
+
if (p.readOnly !== undefined && typeof p.readOnly !== 'boolean')
|
|
55
|
+
throw new Error('Invalid readOnly');
|
|
56
|
+
if (p.readOnly !== undefined && !['files.list', 'files.find', 'files.read'].includes(method))
|
|
57
|
+
throw new Error('Read-only file access cannot modify files');
|
|
58
|
+
await this.expire();
|
|
59
|
+
if (method === 'files.find')
|
|
60
|
+
return this.find(p);
|
|
61
|
+
// Any write may add, move or remove a path a cached search list still holds. Invalidate before and again after
|
|
62
|
+
// the mutation: a search building its list concurrently (find takes no pin) then never caches a pre-mutation list.
|
|
63
|
+
if (!['files.list', 'files.read'].includes(method)) {
|
|
64
|
+
this.indexes.invalidate();
|
|
65
|
+
try {
|
|
66
|
+
return await this.locate(method, p);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
this.indexes.invalidate();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return this.locate(method, p);
|
|
73
|
+
}
|
|
74
|
+
async locate(method, p) {
|
|
75
|
+
let parent;
|
|
76
|
+
if (method === 'files.upload.chunk' || method === 'files.upload.finish' || method === 'files.upload.cancel') {
|
|
77
|
+
const upload = this.uploads.get(identifier(p, 'uploadId'));
|
|
78
|
+
if (!upload && method === 'files.upload.cancel') {
|
|
79
|
+
keys(p, ['uploadId']);
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
if (!upload)
|
|
83
|
+
throw new Error('Upload not found or expired');
|
|
84
|
+
parent = path.dirname(upload.target);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const target = await this.resolve(string(p, 'path', 4096, method === 'files.list' ? this.home : undefined), method === 'files.mkdir' || method === 'files.upload.begin', !['files.list', 'files.read'].includes(method), p.readOnly === true, !['files.rename', 'files.delete'].includes(method));
|
|
88
|
+
parent = method === 'files.list' ? target : path.dirname(target);
|
|
89
|
+
}
|
|
90
|
+
return pinned(parent, async (pin) => {
|
|
91
|
+
if (method.startsWith('files.upload.') && method !== 'files.upload.begin') {
|
|
92
|
+
// Re-read: the upload may have finished, been cancelled or expired while this call waited for the pin.
|
|
93
|
+
const upload = this.uploads.get(identifier(p, 'uploadId'));
|
|
94
|
+
if (!upload && method === 'files.upload.cancel') {
|
|
95
|
+
keys(p, ['uploadId']);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
if (!upload)
|
|
99
|
+
throw new Error('Upload not found or expired');
|
|
100
|
+
const stat = await fs.stat('.');
|
|
101
|
+
if (stat.dev !== upload.parent.dev || stat.ino !== upload.parent.ino) {
|
|
102
|
+
if (method === 'files.upload.cancel') {
|
|
103
|
+
await this.uploads.abandon(upload);
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
throw new Error('Upload parent changed');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return this.perform(method, p, pin);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async perform(method, p, pin) {
|
|
113
|
+
switch (method) {
|
|
114
|
+
case 'files.list': {
|
|
115
|
+
keys(p, ['path', 'readOnly']);
|
|
116
|
+
const target = await this.resolve(string(p, 'path', 4096, this.home), false, false, p.readOnly === true);
|
|
117
|
+
const displayedPath = this.scope.kind === 'filesystem' ? string(p, 'path', 4096, this.home) : target;
|
|
118
|
+
const listing = await fs.opendir(pin.leaf(target));
|
|
119
|
+
const entries = [];
|
|
120
|
+
let visited = 0;
|
|
121
|
+
try {
|
|
122
|
+
for await (const entry of listing) {
|
|
123
|
+
if (++visited > LIST_MAX_VISITED || entries.length >= LIST_MAX_ENTRIES)
|
|
124
|
+
throw new Error('Directory entry limit exceeded');
|
|
125
|
+
if (entry.name.startsWith(TEMP_PREFIX) || entry.isSymbolicLink())
|
|
126
|
+
continue;
|
|
127
|
+
const entryPath = path.join(target, entry.name);
|
|
128
|
+
let stat;
|
|
129
|
+
try {
|
|
130
|
+
await this.resolve(entryPath, false, false, p.readOnly === true);
|
|
131
|
+
stat = await fs.lstat(pin.leaf(entryPath));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!stat.isFile() && !stat.isDirectory())
|
|
137
|
+
continue;
|
|
138
|
+
entries.push({ name: entry.name, path: path.join(displayedPath, entry.name), directory: stat.isDirectory(), size: stat.size, modifiedAt: stat.mtimeMs, createdAt: stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.ctimeMs > 0 ? stat.ctimeMs : stat.mtimeMs,
|
|
139
|
+
...(this.scope.kind === 'filesystem' ? { writable: await fs.access(entryPath, constants.W_OK).then(() => true, () => false) } : {}) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await listing.close().catch(() => { });
|
|
144
|
+
}
|
|
145
|
+
entries.sort((a, b) => Number(b.directory) - Number(a.directory) || String(a.name).localeCompare(String(b.name)));
|
|
146
|
+
const parent = path.dirname(displayedPath);
|
|
147
|
+
return { path: displayedPath, parent: p.readOnly === true || this.scope.kind === 'filesystem' ? (parent === displayedPath ? null : parent) : this.roots.includes(target) || !this.roots.some(root => inside(parent, root)) ? null : parent, entries, roots: this.roots };
|
|
148
|
+
}
|
|
149
|
+
case 'files.read': {
|
|
150
|
+
keys(p, ['path', 'offset', 'length', 'readOnly']);
|
|
151
|
+
const offset = integer(p, 'offset', 0, Number.MAX_SAFE_INTEGER, 0);
|
|
152
|
+
const length = integer(p, 'length', 1, CHUNK_BYTES, CHUNK_BYTES);
|
|
153
|
+
const target = await this.resolve(string(p, 'path'), false, false, p.readOnly === true);
|
|
154
|
+
const handle = await pin.regularHandle(target);
|
|
155
|
+
try {
|
|
156
|
+
const stat = await handle.stat();
|
|
157
|
+
const buffer = Buffer.alloc(length);
|
|
158
|
+
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
|
159
|
+
return { data: buffer.subarray(0, bytesRead).toString('base64'), size: stat.size, eof: offset + bytesRead >= stat.size, name: path.basename(target),
|
|
160
|
+
...(this.scope.kind === 'filesystem' ? { writable: await fs.access(pin.leaf(target), constants.W_OK).then(() => true, () => false) } : {}) };
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await handle.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
case 'files.mkdir': {
|
|
167
|
+
keys(p, ['path']);
|
|
168
|
+
const target = await this.resolve(string(p, 'path'), true, true);
|
|
169
|
+
await fs.mkdir(pin.leaf(target));
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}
|
|
172
|
+
case 'files.rename': {
|
|
173
|
+
keys(p, ['path', 'name']);
|
|
174
|
+
const target = await this.resolve(string(p, 'path'), false, true, false, false);
|
|
175
|
+
const name = string(p, 'name', 255);
|
|
176
|
+
if (name === '.' || name === '..' || /[/\\]/.test(name) || name.startsWith(TEMP_PREFIX))
|
|
177
|
+
throw new Error('Invalid new name');
|
|
178
|
+
const destination = await this.resolve(path.join(path.dirname(target), name), true, true);
|
|
179
|
+
// Node rename() overwrites files. Hard-link commits are exclusive for files; for directories,
|
|
180
|
+
// reserve the destination with mkdir and replace only that empty reservation atomically.
|
|
181
|
+
const stat = await fs.lstat(pin.leaf(target));
|
|
182
|
+
if (stat.isFile()) {
|
|
183
|
+
await fs.link(pin.leaf(target), pin.leaf(destination));
|
|
184
|
+
await fs.unlink(pin.leaf(target));
|
|
185
|
+
}
|
|
186
|
+
else if (stat.isDirectory()) {
|
|
187
|
+
await fs.mkdir(pin.leaf(destination), { mode: stat.mode });
|
|
188
|
+
try {
|
|
189
|
+
await fs.rename(pin.leaf(target), pin.leaf(destination));
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
await fs.rmdir(pin.leaf(destination)).catch(() => { });
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
throw new Error('Not a regular file or directory');
|
|
198
|
+
return { ok: true };
|
|
199
|
+
}
|
|
200
|
+
case 'files.delete': {
|
|
201
|
+
keys(p, ['path']);
|
|
202
|
+
const target = await this.resolve(string(p, 'path'), false, true, false, false);
|
|
203
|
+
const stat = await fs.lstat(pin.leaf(target));
|
|
204
|
+
if (stat.isDirectory())
|
|
205
|
+
await fs.rmdir(pin.leaf(target));
|
|
206
|
+
else if (stat.isFile())
|
|
207
|
+
await fs.unlink(pin.leaf(target));
|
|
208
|
+
else
|
|
209
|
+
throw new Error('Not a regular file or directory');
|
|
210
|
+
return { ok: true };
|
|
211
|
+
}
|
|
212
|
+
case 'files.upload.begin': {
|
|
213
|
+
keys(p, ['path', 'size', 'overwrite']);
|
|
214
|
+
const size = integer(p, 'size', 0, MAX_UPLOAD_BYTES);
|
|
215
|
+
if (p.overwrite !== undefined && typeof p.overwrite !== 'boolean')
|
|
216
|
+
throw new Error('Invalid overwrite');
|
|
217
|
+
return this.uploads.begin(pin, () => this.resolve(string(p, 'path'), true, true), size, p.overwrite === true, this.scope.kind === 'filesystem', string(p, 'path'));
|
|
218
|
+
}
|
|
219
|
+
case 'files.upload.chunk': {
|
|
220
|
+
keys(p, ['uploadId', 'offset', 'data']);
|
|
221
|
+
const upload = this.uploads.use(identifier(p, 'uploadId'));
|
|
222
|
+
const offset = integer(p, 'offset', 0, MAX_UPLOAD_BYTES);
|
|
223
|
+
const bytes = base64(p.data);
|
|
224
|
+
return this.uploads.chunk(upload, offset, bytes);
|
|
225
|
+
}
|
|
226
|
+
case 'files.upload.finish': {
|
|
227
|
+
keys(p, ['uploadId']);
|
|
228
|
+
const upload = this.uploads.use(identifier(p, 'uploadId'));
|
|
229
|
+
return this.uploads.finish(pin, upload, (raw, missingLeaf, mutation) => this.resolve(raw, missingLeaf, mutation));
|
|
230
|
+
}
|
|
231
|
+
case 'files.upload.cancel': {
|
|
232
|
+
keys(p, ['uploadId']);
|
|
233
|
+
return this.uploads.cancel(identifier(p, 'uploadId'));
|
|
234
|
+
}
|
|
235
|
+
default: throw new Error('Unsupported file tool');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Fuzzy file-name search below one directory (`path`, default home). Ranking runs over the cached
|
|
240
|
+
* relative-path list; only the best candidates are re-validated against the same root/symlink fence
|
|
241
|
+
* as `files.list` before they are returned, so a path git still remembers but the disk lost, or one
|
|
242
|
+
* that became a symbolic link, never reaches the client. Uses no cwd: absolute paths throughout, so it
|
|
243
|
+
* never enters (or waits for) a `pinned()` section.
|
|
244
|
+
*/
|
|
245
|
+
async find(p) {
|
|
246
|
+
keys(p, ['path', 'query', 'limit', 'readOnly']);
|
|
247
|
+
const readOnly = p.readOnly === true;
|
|
248
|
+
const query = string(p, 'query', 256).trim();
|
|
249
|
+
if (!query)
|
|
250
|
+
throw new Error('Invalid query');
|
|
251
|
+
const limit = integer(p, 'limit', 1, FIND_MAX_LIMIT, FIND_DEFAULT_LIMIT);
|
|
252
|
+
const scope = await this.resolve(string(p, 'path', 4096, this.home), false, false, readOnly);
|
|
253
|
+
const displayedScope = this.scope.kind === 'filesystem' ? string(p, 'path', 4096, this.home) : scope;
|
|
254
|
+
if (!(await fs.stat(scope)).isDirectory())
|
|
255
|
+
throw new Error('Search scope is not a directory');
|
|
256
|
+
const index = await this.indexes.get(scope);
|
|
257
|
+
const ranked = rankPaths(index.paths, query);
|
|
258
|
+
const entries = [];
|
|
259
|
+
let checked = 0;
|
|
260
|
+
// Bounded: a stale list full of vanished paths must not turn one search into a disk scan.
|
|
261
|
+
for (const hit of ranked.slice(0, limit * 4)) {
|
|
262
|
+
if (entries.length >= limit)
|
|
263
|
+
break;
|
|
264
|
+
checked++;
|
|
265
|
+
const relative = index.paths[hit.index];
|
|
266
|
+
const target = path.join(scope, relative);
|
|
267
|
+
let stat;
|
|
268
|
+
try {
|
|
269
|
+
await this.resolve(target, false, false, readOnly);
|
|
270
|
+
stat = await fs.lstat(target);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!stat.isFile() && !stat.isDirectory())
|
|
276
|
+
continue;
|
|
277
|
+
entries.push({ name: path.basename(relative), path: path.join(displayedScope, relative), directory: stat.isDirectory(), match: relative, positions: hit.positions });
|
|
278
|
+
}
|
|
279
|
+
// Candidates the fence dropped are not matches; the unchecked tail is counted as is.
|
|
280
|
+
return { path: displayedScope, entries, total: entries.length + ranked.length - checked, truncated: index.truncated, source: index.source };
|
|
281
|
+
}
|
|
282
|
+
async expire() { await this.uploads.expire(); }
|
|
283
|
+
async dispose() { await this.uploads.dispose(); }
|
|
284
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, rename, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { canonicalRoot, FileError, pinned, resolvePinnedDir, resolvePinnedDirCreate, resolvePinnedLeaf, resolveRelativeDir, resolveRelativeDirCreate, resolveRelativeFile, throwIfAborted, toFileError, withinRoot, } from './fence.js';
|
|
6
|
+
import { assertScopeAllows } from './scope.js';
|
|
7
|
+
/** Text file semantics of the platform agent tools (`file_*_request`): request-root scope only. */
|
|
8
|
+
export const MAX_READ_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const MAX_PAGE_BYTES = 256 * 1024;
|
|
10
|
+
export const MAX_WRITE_BYTES = 1024 * 1024;
|
|
11
|
+
// Transport-free engines (formerly fileOps.ts). Untrusted requests must go through the scope entry points at the
|
|
12
|
+
// bottom of this file (or the legacy fork-per-request worker), which pin the parent directory before invoking these
|
|
13
|
+
// with a pinned leaf resolver; pathname-only resolution here is retained for unit tests and trusted callers, not as a
|
|
14
|
+
// security boundary against rename races.
|
|
15
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
async function snapshotFile(file, signal) {
|
|
17
|
+
let handle;
|
|
18
|
+
try {
|
|
19
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
20
|
+
const before = await handle.stat({ bigint: true });
|
|
21
|
+
if (!before.isFile())
|
|
22
|
+
throw new FileError('not_regular_file', 'Target is not a regular file');
|
|
23
|
+
if (before.size > BigInt(MAX_READ_BYTES))
|
|
24
|
+
throw new FileError('too_large', 'File exceeds 8 MiB');
|
|
25
|
+
const parts = [];
|
|
26
|
+
let total = 0;
|
|
27
|
+
while (true) {
|
|
28
|
+
throwIfAborted(signal);
|
|
29
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_READ_BYTES + 1 - total));
|
|
30
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
31
|
+
if (!bytesRead)
|
|
32
|
+
break;
|
|
33
|
+
total += bytesRead;
|
|
34
|
+
if (total > MAX_READ_BYTES)
|
|
35
|
+
throw new FileError('too_large', 'File exceeds 8 MiB');
|
|
36
|
+
parts.push(chunk.subarray(0, bytesRead));
|
|
37
|
+
}
|
|
38
|
+
const after = await handle.stat({ bigint: true });
|
|
39
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || BigInt(total) !== after.size) {
|
|
40
|
+
throw new FileError('conflict', 'File changed during read');
|
|
41
|
+
}
|
|
42
|
+
return Buffer.concat(parts, total);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw toFileError(error);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await handle?.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function readTextFile(request, signal, resolvePath = resolveRelativeFile) {
|
|
52
|
+
if (!Number.isSafeInteger(request.startLine) || request.startLine < 1 || !Number.isSafeInteger(request.maxLines) || request.maxLines < 1 || request.maxLines > 2000) {
|
|
53
|
+
throw new FileError('invalid_argument', 'Invalid line range');
|
|
54
|
+
}
|
|
55
|
+
const file = await resolvePath(request.root, request.path, false);
|
|
56
|
+
let bytes;
|
|
57
|
+
try {
|
|
58
|
+
bytes = await snapshotFile(file, signal);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (!(error instanceof FileError) || error.code !== 'conflict')
|
|
62
|
+
throw error;
|
|
63
|
+
bytes = await snapshotFile(file, signal);
|
|
64
|
+
}
|
|
65
|
+
let source;
|
|
66
|
+
try {
|
|
67
|
+
source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new FileError('invalid_encoding', 'File is not valid UTF-8');
|
|
71
|
+
}
|
|
72
|
+
const lines = [];
|
|
73
|
+
for (let begin = 0; begin < source.length;) {
|
|
74
|
+
const newline = source.indexOf('\n', begin);
|
|
75
|
+
const end = newline < 0 ? source.length : newline + 1;
|
|
76
|
+
lines.push(source.slice(begin, end));
|
|
77
|
+
begin = end;
|
|
78
|
+
}
|
|
79
|
+
const first = request.startLine - 1;
|
|
80
|
+
let content = '';
|
|
81
|
+
let selected = 0;
|
|
82
|
+
let pageBytes = 0;
|
|
83
|
+
for (let index = first; index < lines.length && selected < request.maxLines; index++) {
|
|
84
|
+
const lineBytes = Buffer.byteLength(lines[index], 'utf8');
|
|
85
|
+
if (lineBytes > MAX_PAGE_BYTES)
|
|
86
|
+
throw new FileError('line_too_long', 'A line exceeds 256 KiB');
|
|
87
|
+
if (pageBytes + lineBytes > MAX_PAGE_BYTES)
|
|
88
|
+
break;
|
|
89
|
+
content += lines[index];
|
|
90
|
+
pageBytes += lineBytes;
|
|
91
|
+
selected++;
|
|
92
|
+
}
|
|
93
|
+
const next = first + selected < lines.length ? request.startLine + selected : undefined;
|
|
94
|
+
return {
|
|
95
|
+
path: request.path, content, startLine: request.startLine,
|
|
96
|
+
endLine: selected ? request.startLine + selected - 1 : 0,
|
|
97
|
+
...(next !== undefined ? { nextLine: next } : {}), truncated: next !== undefined,
|
|
98
|
+
sizeBytes: bytes.length, sha256: hash(bytes),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const writeLocks = new Map();
|
|
102
|
+
async function serialWrite(target, action) {
|
|
103
|
+
const previous = writeLocks.get(target) ?? Promise.resolve();
|
|
104
|
+
let release;
|
|
105
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
106
|
+
const queued = previous.then(() => gate);
|
|
107
|
+
writeLocks.set(target, queued);
|
|
108
|
+
await previous;
|
|
109
|
+
try {
|
|
110
|
+
return await action();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
release();
|
|
114
|
+
if (writeLocks.get(target) === queued)
|
|
115
|
+
writeLocks.delete(target);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function validUnicode(content) {
|
|
119
|
+
for (let index = 0; index < content.length; index++) {
|
|
120
|
+
const code = content.charCodeAt(index);
|
|
121
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
122
|
+
const next = content.charCodeAt(++index);
|
|
123
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
else if (code >= 0xdc00 && code <= 0xdfff)
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
export async function writeTextFile(request, signal, resolvePath = resolveRelativeFile) {
|
|
132
|
+
if (request.content.includes('\0') || !validUnicode(request.content)) {
|
|
133
|
+
throw new FileError('invalid_encoding', 'Content must be valid UTF-8 text without NUL');
|
|
134
|
+
}
|
|
135
|
+
const bytes = Buffer.from(request.content, 'utf8');
|
|
136
|
+
if (bytes.length > MAX_WRITE_BYTES)
|
|
137
|
+
throw new FileError('too_large', 'Content exceeds 1 MiB');
|
|
138
|
+
if (request.mode !== 'create' && request.mode !== 'replace')
|
|
139
|
+
throw new FileError('invalid_argument', 'Invalid write mode');
|
|
140
|
+
if (request.mode === 'create' ? request.expectedSha256 !== undefined : !/^[0-9a-f]{64}$/.test(request.expectedSha256 ?? '')) {
|
|
141
|
+
throw new FileError('invalid_argument', 'create forbids expected_sha256; replace requires lowercase SHA-256');
|
|
142
|
+
}
|
|
143
|
+
throwIfAborted(signal);
|
|
144
|
+
const file = await resolvePath(request.root, request.path, request.mode === 'create');
|
|
145
|
+
return serialWrite(file, async () => {
|
|
146
|
+
throwIfAborted(signal);
|
|
147
|
+
await resolvePath(request.root, request.path, request.mode === 'create');
|
|
148
|
+
let mode = 0o666;
|
|
149
|
+
if (request.mode === 'replace') {
|
|
150
|
+
const current = await snapshotFile(file, signal);
|
|
151
|
+
if (hash(current) !== request.expectedSha256)
|
|
152
|
+
throw new FileError('conflict', 'File changed since it was read');
|
|
153
|
+
try {
|
|
154
|
+
mode = (await stat(file)).mode & 0o777;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
throw toFileError(error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const parent = dirname(file);
|
|
161
|
+
const temporary = join(parent, `.${basename(file)}.axom-${randomUUID()}.tmp`);
|
|
162
|
+
let handle;
|
|
163
|
+
try {
|
|
164
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), mode);
|
|
165
|
+
if (request.mode === 'replace' && process.platform !== 'win32')
|
|
166
|
+
await handle.chmod(mode);
|
|
167
|
+
await handle.writeFile(bytes);
|
|
168
|
+
await handle.sync();
|
|
169
|
+
await handle.close();
|
|
170
|
+
handle = undefined;
|
|
171
|
+
throwIfAborted(signal);
|
|
172
|
+
await resolvePath(request.root, request.path, request.mode === 'create');
|
|
173
|
+
if (request.mode === 'create')
|
|
174
|
+
await link(temporary, file);
|
|
175
|
+
else
|
|
176
|
+
await rename(temporary, file);
|
|
177
|
+
return { path: request.path, sizeBytes: bytes.length, sha256: hash(bytes), created: request.mode === 'create' };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
throw toFileError(error);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await handle?.close();
|
|
184
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
185
|
+
throw toFileError(error); });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export async function listTextDir(request, signal, resolvePath = resolveRelativeDir) {
|
|
190
|
+
if (!Number.isSafeInteger(request.maxEntries) || request.maxEntries < 1 || request.maxEntries > 2000) {
|
|
191
|
+
throw new FileError('invalid_argument', 'Invalid entry range');
|
|
192
|
+
}
|
|
193
|
+
throwIfAborted(signal);
|
|
194
|
+
const dir = await resolvePath(request.root, request.path);
|
|
195
|
+
throwIfAborted(signal);
|
|
196
|
+
const dirents = (await readdir(dir, { withFileTypes: true }))
|
|
197
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
198
|
+
const entries = [];
|
|
199
|
+
let truncated = false;
|
|
200
|
+
for (const dirent of dirents) {
|
|
201
|
+
if (request.startAfter !== undefined && !(dirent.name > request.startAfter))
|
|
202
|
+
continue;
|
|
203
|
+
if (entries.length >= request.maxEntries) {
|
|
204
|
+
truncated = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
let kind;
|
|
208
|
+
if (dirent.isFile())
|
|
209
|
+
kind = 'file';
|
|
210
|
+
else if (dirent.isDirectory())
|
|
211
|
+
kind = 'directory';
|
|
212
|
+
else if (dirent.isSymbolicLink())
|
|
213
|
+
kind = 'symlink';
|
|
214
|
+
else {
|
|
215
|
+
// Filesystems that report DT_UNKNOWN leave the kind to a fresh lstat.
|
|
216
|
+
try {
|
|
217
|
+
const entry = await lstat(join(dir, dirent.name));
|
|
218
|
+
kind = entry.isFile() ? 'file' : entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'other';
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error.code !== 'ENOENT')
|
|
222
|
+
throw toFileError(error);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const entry = { name: dirent.name, kind };
|
|
227
|
+
if (kind === 'file') {
|
|
228
|
+
try {
|
|
229
|
+
entry.sizeBytes = (await lstat(join(dir, dirent.name))).size;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
// A file that vanished between readdir and lstat simply drops out of the snapshot.
|
|
233
|
+
if (error.code !== 'ENOENT')
|
|
234
|
+
throw toFileError(error);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
entries.push(entry);
|
|
239
|
+
}
|
|
240
|
+
const nextAfter = truncated ? entries[entries.length - 1]?.name : undefined;
|
|
241
|
+
return { path: request.path, entries, truncated, ...(nextAfter !== undefined ? { nextAfter } : {}) };
|
|
242
|
+
}
|
|
243
|
+
export async function createTextDir(request, signal, resolvePlan = resolveRelativeDirCreate) {
|
|
244
|
+
throwIfAborted(signal);
|
|
245
|
+
const plan = await resolvePlan(request.root, request.path, request.parents);
|
|
246
|
+
if (plan.missing.length === 0) {
|
|
247
|
+
if (!request.parents)
|
|
248
|
+
throw new FileError('conflict', 'Target already exists');
|
|
249
|
+
return { path: request.path, created: false };
|
|
250
|
+
}
|
|
251
|
+
if (!request.parents && plan.missing.length > 1) {
|
|
252
|
+
throw new FileError('not_found', 'Parent directory not found');
|
|
253
|
+
}
|
|
254
|
+
let cursor = plan.anchor;
|
|
255
|
+
let created = false;
|
|
256
|
+
for (const component of plan.missing) {
|
|
257
|
+
throwIfAborted(signal);
|
|
258
|
+
const target = join(cursor, component);
|
|
259
|
+
try {
|
|
260
|
+
await mkdir(target);
|
|
261
|
+
created = true;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error.code !== 'EEXIST')
|
|
265
|
+
throw toFileError(error);
|
|
266
|
+
if (!request.parents)
|
|
267
|
+
throw new FileError('conflict', 'Target already exists');
|
|
268
|
+
}
|
|
269
|
+
// A component we just made (or found racing us) must be a real directory, never a symlink stand-in.
|
|
270
|
+
const entry = await lstat(target).catch(error => { throw toFileError(error); });
|
|
271
|
+
if (entry.isSymbolicLink() || !entry.isDirectory())
|
|
272
|
+
throw new FileError('conflict', 'Path is not a directory');
|
|
273
|
+
cursor = target;
|
|
274
|
+
}
|
|
275
|
+
return { path: request.path, created };
|
|
276
|
+
}
|
|
277
|
+
const targetLocks = new Map();
|
|
278
|
+
async function serialTarget(target, action) {
|
|
279
|
+
const previous = targetLocks.get(target) ?? Promise.resolve();
|
|
280
|
+
let release;
|
|
281
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
282
|
+
const queued = previous.then(() => gate);
|
|
283
|
+
targetLocks.set(target, queued);
|
|
284
|
+
await previous;
|
|
285
|
+
try {
|
|
286
|
+
return await action();
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
release();
|
|
290
|
+
if (targetLocks.get(target) === queued)
|
|
291
|
+
targetLocks.delete(target);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// The worker client checked for cancellation after pathname validation, when it was about to start the operation.
|
|
295
|
+
async function throwIfAbortedThen(signal, run) {
|
|
296
|
+
throwIfAborted(signal);
|
|
297
|
+
return run();
|
|
298
|
+
}
|
|
299
|
+
function requireRequestRoot(scope, operation) {
|
|
300
|
+
if (scope?.kind !== 'request-root')
|
|
301
|
+
throw new FileError('invalid_argument', 'Text file operations require a request root');
|
|
302
|
+
assertScopeAllows(scope, operation);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Runs `run` with the cwd pinned to `anchor`, which must still be inside the canonical root after chdir. Cancellation
|
|
306
|
+
* keeps the file worker's contract: a request cancelled once it is under way reports `outcome_unknown` for mutations
|
|
307
|
+
* and `workstation_offline` for reads.
|
|
308
|
+
*/
|
|
309
|
+
async function anchored(operation, root, anchor, signal, hooks, run) {
|
|
310
|
+
try {
|
|
311
|
+
await hooks?.afterValidation?.();
|
|
312
|
+
return await pinned(anchor, async () => {
|
|
313
|
+
await hooks?.afterPinned?.();
|
|
314
|
+
throwIfAborted(signal);
|
|
315
|
+
return run();
|
|
316
|
+
}, { verify: withinRoot(root), signal });
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (signal?.aborted)
|
|
320
|
+
throw new FileError(operation === 'write' || operation === 'mkdir' ? 'outcome_unknown' : 'workstation_offline', 'File operation was cancelled');
|
|
321
|
+
throw toFileError(error);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export async function readText(scope, request, signal, hooks) {
|
|
325
|
+
requireRequestRoot(scope, 'read');
|
|
326
|
+
const root = await canonicalRoot(scope.root);
|
|
327
|
+
const target = await resolveRelativeFile(root, request.path, false);
|
|
328
|
+
throwIfAborted(signal);
|
|
329
|
+
// As the file worker did, the result names the leaf it read.
|
|
330
|
+
return anchored('read', root, dirname(target), signal, hooks, () => readTextFile({ ...request, root: '.', path: basename(target) }, signal, resolvePinnedLeaf));
|
|
331
|
+
}
|
|
332
|
+
export async function writeText(scope, request, signal, hooks) {
|
|
333
|
+
requireRequestRoot(scope, 'write');
|
|
334
|
+
const root = await canonicalRoot(scope.root);
|
|
335
|
+
const target = await resolveRelativeFile(root, request.path, request.mode === 'create');
|
|
336
|
+
return serialTarget(target, () => throwIfAbortedThen(signal, () => anchored('write', root, dirname(target), signal, hooks, () => writeTextFile({ ...request, root: '.', path: basename(target) }, signal, resolvePinnedLeaf))));
|
|
337
|
+
}
|
|
338
|
+
export async function listDir(scope, request, signal, hooks) {
|
|
339
|
+
requireRequestRoot(scope, 'list');
|
|
340
|
+
const root = await canonicalRoot(scope.root);
|
|
341
|
+
const target = await resolveRelativeDir(root, request.path);
|
|
342
|
+
throwIfAborted(signal);
|
|
343
|
+
// Listing the root pins the root itself; '' is the zero-component leaf.
|
|
344
|
+
const anchor = target === root ? root : dirname(target);
|
|
345
|
+
const leaf = target === root ? '' : basename(target);
|
|
346
|
+
const data = await anchored('list', root, anchor, signal, hooks, () => listTextDir({ ...request, root: '.', path: leaf }, signal, resolvePinnedDir));
|
|
347
|
+
return { ...data, path: request.path };
|
|
348
|
+
}
|
|
349
|
+
export async function makeDir(scope, request, signal, hooks) {
|
|
350
|
+
requireRequestRoot(scope, 'mkdir');
|
|
351
|
+
const root = await canonicalRoot(scope.root);
|
|
352
|
+
const plan = await resolveRelativeDirCreate(root, request.path, request.parents);
|
|
353
|
+
throwIfAborted(signal);
|
|
354
|
+
const data = await anchored('mkdir', root, plan.anchor, signal, hooks, () => createTextDir({ ...request, root: '.', path: plan.missing.join('/') }, signal, resolvePinnedDirCreate));
|
|
355
|
+
return { ...data, path: request.path };
|
|
356
|
+
}
|