@platless/axom-cli 0.3.13 → 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/dist/axom-patch/cli/README.md +30 -8
- 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-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
- package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
- package/dist/axom-patch/cli/qoder-steering.json +15 -15
- package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
- package/dist/axom-patch/cli/runtime.json +34 -8
- 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 +33 -6
- 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/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 +2163 -611
- package/dist/workstation/acp/acpClient.js +60 -497
- 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 +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/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 +76 -160
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +20 -3
- package/dist/workstation/acp/permission.js +42 -10
- 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 +1 -1
- package/dist/workstation/acp/registry/catalog.js +154 -32
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -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/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/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1324 -474
- 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 +84 -4
- package/dist/workstation/daemon.js +288 -7
- package/dist/workstation/daemonctl.js +28 -2
- 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/{fileOps.js → files/text.js} +117 -38
- 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/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +48 -4
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
- package/dist/workstation/qoderAdapter/history.js +1 -3
- package/dist/workstation/qoderAdapter/main.js +4 -2
- package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +1 -1
- package/dist/workstation/qoderAdapter/subwork.js +15 -7
- package/dist/workstation/systemMetrics.js +110 -1
- 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/coordinator.js +3 -0
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/package.json +10 -7
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- 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/taskEvent.js +0 -26
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
|
@@ -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
|
+
}
|
|
@@ -2,27 +2,26 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { link, lstat, mkdir, open, readdir, rename, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
|
-
import {
|
|
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. */
|
|
6
8
|
export const MAX_READ_BYTES = 8 * 1024 * 1024;
|
|
7
9
|
export const MAX_PAGE_BYTES = 256 * 1024;
|
|
8
10
|
export const MAX_WRITE_BYTES = 1024 * 1024;
|
|
9
|
-
// Transport-free
|
|
10
|
-
//
|
|
11
|
-
// is retained for unit tests and trusted callers, not as a
|
|
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.
|
|
12
15
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
13
|
-
function throwIfAborted(signal) {
|
|
14
|
-
if (signal?.aborted)
|
|
15
|
-
throw new ComputerFileError('workstation_offline', 'File operation was cancelled');
|
|
16
|
-
}
|
|
17
16
|
async function snapshotFile(file, signal) {
|
|
18
17
|
let handle;
|
|
19
18
|
try {
|
|
20
19
|
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
21
20
|
const before = await handle.stat({ bigint: true });
|
|
22
21
|
if (!before.isFile())
|
|
23
|
-
throw new
|
|
22
|
+
throw new FileError('not_regular_file', 'Target is not a regular file');
|
|
24
23
|
if (before.size > BigInt(MAX_READ_BYTES))
|
|
25
|
-
throw new
|
|
24
|
+
throw new FileError('too_large', 'File exceeds 8 MiB');
|
|
26
25
|
const parts = [];
|
|
27
26
|
let total = 0;
|
|
28
27
|
while (true) {
|
|
@@ -33,25 +32,25 @@ async function snapshotFile(file, signal) {
|
|
|
33
32
|
break;
|
|
34
33
|
total += bytesRead;
|
|
35
34
|
if (total > MAX_READ_BYTES)
|
|
36
|
-
throw new
|
|
35
|
+
throw new FileError('too_large', 'File exceeds 8 MiB');
|
|
37
36
|
parts.push(chunk.subarray(0, bytesRead));
|
|
38
37
|
}
|
|
39
38
|
const after = await handle.stat({ bigint: true });
|
|
40
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) {
|
|
41
|
-
throw new
|
|
40
|
+
throw new FileError('conflict', 'File changed during read');
|
|
42
41
|
}
|
|
43
42
|
return Buffer.concat(parts, total);
|
|
44
43
|
}
|
|
45
44
|
catch (error) {
|
|
46
|
-
throw
|
|
45
|
+
throw toFileError(error);
|
|
47
46
|
}
|
|
48
47
|
finally {
|
|
49
48
|
await handle?.close();
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
|
-
export async function
|
|
51
|
+
export async function readTextFile(request, signal, resolvePath = resolveRelativeFile) {
|
|
53
52
|
if (!Number.isSafeInteger(request.startLine) || request.startLine < 1 || !Number.isSafeInteger(request.maxLines) || request.maxLines < 1 || request.maxLines > 2000) {
|
|
54
|
-
throw new
|
|
53
|
+
throw new FileError('invalid_argument', 'Invalid line range');
|
|
55
54
|
}
|
|
56
55
|
const file = await resolvePath(request.root, request.path, false);
|
|
57
56
|
let bytes;
|
|
@@ -59,7 +58,7 @@ export async function readComputerFile(request, signal, resolvePath = resolveCom
|
|
|
59
58
|
bytes = await snapshotFile(file, signal);
|
|
60
59
|
}
|
|
61
60
|
catch (error) {
|
|
62
|
-
if (!(error instanceof
|
|
61
|
+
if (!(error instanceof FileError) || error.code !== 'conflict')
|
|
63
62
|
throw error;
|
|
64
63
|
bytes = await snapshotFile(file, signal);
|
|
65
64
|
}
|
|
@@ -68,7 +67,7 @@ export async function readComputerFile(request, signal, resolvePath = resolveCom
|
|
|
68
67
|
source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
69
68
|
}
|
|
70
69
|
catch {
|
|
71
|
-
throw new
|
|
70
|
+
throw new FileError('invalid_encoding', 'File is not valid UTF-8');
|
|
72
71
|
}
|
|
73
72
|
const lines = [];
|
|
74
73
|
for (let begin = 0; begin < source.length;) {
|
|
@@ -84,7 +83,7 @@ export async function readComputerFile(request, signal, resolvePath = resolveCom
|
|
|
84
83
|
for (let index = first; index < lines.length && selected < request.maxLines; index++) {
|
|
85
84
|
const lineBytes = Buffer.byteLength(lines[index], 'utf8');
|
|
86
85
|
if (lineBytes > MAX_PAGE_BYTES)
|
|
87
|
-
throw new
|
|
86
|
+
throw new FileError('line_too_long', 'A line exceeds 256 KiB');
|
|
88
87
|
if (pageBytes + lineBytes > MAX_PAGE_BYTES)
|
|
89
88
|
break;
|
|
90
89
|
content += lines[index];
|
|
@@ -129,17 +128,17 @@ function validUnicode(content) {
|
|
|
129
128
|
}
|
|
130
129
|
return true;
|
|
131
130
|
}
|
|
132
|
-
export async function
|
|
131
|
+
export async function writeTextFile(request, signal, resolvePath = resolveRelativeFile) {
|
|
133
132
|
if (request.content.includes('\0') || !validUnicode(request.content)) {
|
|
134
|
-
throw new
|
|
133
|
+
throw new FileError('invalid_encoding', 'Content must be valid UTF-8 text without NUL');
|
|
135
134
|
}
|
|
136
135
|
const bytes = Buffer.from(request.content, 'utf8');
|
|
137
136
|
if (bytes.length > MAX_WRITE_BYTES)
|
|
138
|
-
throw new
|
|
137
|
+
throw new FileError('too_large', 'Content exceeds 1 MiB');
|
|
139
138
|
if (request.mode !== 'create' && request.mode !== 'replace')
|
|
140
|
-
throw new
|
|
139
|
+
throw new FileError('invalid_argument', 'Invalid write mode');
|
|
141
140
|
if (request.mode === 'create' ? request.expectedSha256 !== undefined : !/^[0-9a-f]{64}$/.test(request.expectedSha256 ?? '')) {
|
|
142
|
-
throw new
|
|
141
|
+
throw new FileError('invalid_argument', 'create forbids expected_sha256; replace requires lowercase SHA-256');
|
|
143
142
|
}
|
|
144
143
|
throwIfAborted(signal);
|
|
145
144
|
const file = await resolvePath(request.root, request.path, request.mode === 'create');
|
|
@@ -150,12 +149,12 @@ export async function writeComputerFile(request, signal, resolvePath = resolveCo
|
|
|
150
149
|
if (request.mode === 'replace') {
|
|
151
150
|
const current = await snapshotFile(file, signal);
|
|
152
151
|
if (hash(current) !== request.expectedSha256)
|
|
153
|
-
throw new
|
|
152
|
+
throw new FileError('conflict', 'File changed since it was read');
|
|
154
153
|
try {
|
|
155
154
|
mode = (await stat(file)).mode & 0o777;
|
|
156
155
|
}
|
|
157
156
|
catch (error) {
|
|
158
|
-
throw
|
|
157
|
+
throw toFileError(error);
|
|
159
158
|
}
|
|
160
159
|
}
|
|
161
160
|
const parent = dirname(file);
|
|
@@ -178,18 +177,18 @@ export async function writeComputerFile(request, signal, resolvePath = resolveCo
|
|
|
178
177
|
return { path: request.path, sizeBytes: bytes.length, sha256: hash(bytes), created: request.mode === 'create' };
|
|
179
178
|
}
|
|
180
179
|
catch (error) {
|
|
181
|
-
throw
|
|
180
|
+
throw toFileError(error);
|
|
182
181
|
}
|
|
183
182
|
finally {
|
|
184
183
|
await handle?.close();
|
|
185
184
|
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
186
|
-
throw
|
|
185
|
+
throw toFileError(error); });
|
|
187
186
|
}
|
|
188
187
|
});
|
|
189
188
|
}
|
|
190
|
-
export async function
|
|
189
|
+
export async function listTextDir(request, signal, resolvePath = resolveRelativeDir) {
|
|
191
190
|
if (!Number.isSafeInteger(request.maxEntries) || request.maxEntries < 1 || request.maxEntries > 2000) {
|
|
192
|
-
throw new
|
|
191
|
+
throw new FileError('invalid_argument', 'Invalid entry range');
|
|
193
192
|
}
|
|
194
193
|
throwIfAborted(signal);
|
|
195
194
|
const dir = await resolvePath(request.root, request.path);
|
|
@@ -220,7 +219,7 @@ export async function listComputerDir(request, signal, resolvePath = resolveComp
|
|
|
220
219
|
}
|
|
221
220
|
catch (error) {
|
|
222
221
|
if (error.code !== 'ENOENT')
|
|
223
|
-
throw
|
|
222
|
+
throw toFileError(error);
|
|
224
223
|
continue;
|
|
225
224
|
}
|
|
226
225
|
}
|
|
@@ -232,7 +231,7 @@ export async function listComputerDir(request, signal, resolvePath = resolveComp
|
|
|
232
231
|
catch (error) {
|
|
233
232
|
// A file that vanished between readdir and lstat simply drops out of the snapshot.
|
|
234
233
|
if (error.code !== 'ENOENT')
|
|
235
|
-
throw
|
|
234
|
+
throw toFileError(error);
|
|
236
235
|
continue;
|
|
237
236
|
}
|
|
238
237
|
}
|
|
@@ -241,16 +240,16 @@ export async function listComputerDir(request, signal, resolvePath = resolveComp
|
|
|
241
240
|
const nextAfter = truncated ? entries[entries.length - 1]?.name : undefined;
|
|
242
241
|
return { path: request.path, entries, truncated, ...(nextAfter !== undefined ? { nextAfter } : {}) };
|
|
243
242
|
}
|
|
244
|
-
export async function
|
|
243
|
+
export async function createTextDir(request, signal, resolvePlan = resolveRelativeDirCreate) {
|
|
245
244
|
throwIfAborted(signal);
|
|
246
245
|
const plan = await resolvePlan(request.root, request.path, request.parents);
|
|
247
246
|
if (plan.missing.length === 0) {
|
|
248
247
|
if (!request.parents)
|
|
249
|
-
throw new
|
|
248
|
+
throw new FileError('conflict', 'Target already exists');
|
|
250
249
|
return { path: request.path, created: false };
|
|
251
250
|
}
|
|
252
251
|
if (!request.parents && plan.missing.length > 1) {
|
|
253
|
-
throw new
|
|
252
|
+
throw new FileError('not_found', 'Parent directory not found');
|
|
254
253
|
}
|
|
255
254
|
let cursor = plan.anchor;
|
|
256
255
|
let created = false;
|
|
@@ -263,15 +262,95 @@ export async function createComputerDir(request, signal, resolvePlan = resolveCo
|
|
|
263
262
|
}
|
|
264
263
|
catch (error) {
|
|
265
264
|
if (error.code !== 'EEXIST')
|
|
266
|
-
throw
|
|
265
|
+
throw toFileError(error);
|
|
267
266
|
if (!request.parents)
|
|
268
|
-
throw new
|
|
267
|
+
throw new FileError('conflict', 'Target already exists');
|
|
269
268
|
}
|
|
270
269
|
// A component we just made (or found racing us) must be a real directory, never a symlink stand-in.
|
|
271
|
-
const entry = await lstat(target).catch(error => { throw
|
|
270
|
+
const entry = await lstat(target).catch(error => { throw toFileError(error); });
|
|
272
271
|
if (entry.isSymbolicLink() || !entry.isDirectory())
|
|
273
|
-
throw new
|
|
272
|
+
throw new FileError('conflict', 'Path is not a directory');
|
|
274
273
|
cursor = target;
|
|
275
274
|
}
|
|
276
275
|
return { path: request.path, created };
|
|
277
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
|
+
}
|