@zaalipro/dsh-workflows 0.1.0-rc.3
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/LICENSE +21 -0
- package/NOTICE.md +224 -0
- package/README.i18n.yaml +4 -0
- package/README.md +109 -0
- package/README.zh.md +109 -0
- package/cordis.patch.yml +10 -0
- package/docs/architecture.i18n.yaml +4 -0
- package/docs/architecture.md +177 -0
- package/docs/architecture.zh.md +177 -0
- package/docs/testing.i18n.yaml +4 -0
- package/docs/testing.md +125 -0
- package/docs/testing.zh.md +125 -0
- package/docs/user-guide.i18n.yaml +4 -0
- package/docs/user-guide.md +351 -0
- package/docs/user-guide.zh.md +351 -0
- package/lib/client-types/WorkflowMemberInspector.d.ts +28 -0
- package/lib/client-types/WorkflowMemberInspector.d.ts.map +1 -0
- package/lib/client-types/WorkflowMemberInspector.js +181 -0
- package/lib/client-types/WorkflowMemberInspector.js.map +1 -0
- package/lib/client-types/WorkflowRunPanel.d.ts +43 -0
- package/lib/client-types/WorkflowRunPanel.d.ts.map +1 -0
- package/lib/client-types/WorkflowRunPanel.js +130 -0
- package/lib/client-types/WorkflowRunPanel.js.map +1 -0
- package/lib/client-types/WorkflowsDashboard.d.ts +49 -0
- package/lib/client-types/WorkflowsDashboard.d.ts.map +1 -0
- package/lib/client-types/WorkflowsDashboard.js +942 -0
- package/lib/client-types/WorkflowsDashboard.js.map +1 -0
- package/lib/client-types/adapter.d.ts +60 -0
- package/lib/client-types/adapter.d.ts.map +1 -0
- package/lib/client-types/adapter.js +117 -0
- package/lib/client-types/adapter.js.map +1 -0
- package/lib/client-types/chat-renderer.d.ts +10 -0
- package/lib/client-types/chat-renderer.d.ts.map +1 -0
- package/lib/client-types/chat-renderer.js +11 -0
- package/lib/client-types/chat-renderer.js.map +1 -0
- package/lib/client-types/contract.d.ts +210 -0
- package/lib/client-types/contract.d.ts.map +1 -0
- package/lib/client-types/contract.js +37 -0
- package/lib/client-types/contract.js.map +1 -0
- package/lib/client-types/controller.d.ts +83 -0
- package/lib/client-types/controller.d.ts.map +1 -0
- package/lib/client-types/controller.js +705 -0
- package/lib/client-types/controller.js.map +1 -0
- package/lib/client-types/index.d.ts +19 -0
- package/lib/client-types/index.d.ts.map +1 -0
- package/lib/client-types/index.js +730 -0
- package/lib/client-types/index.js.map +1 -0
- package/lib/client-types/locales.d.ts +257 -0
- package/lib/client-types/locales.d.ts.map +1 -0
- package/lib/client-types/locales.js +270 -0
- package/lib/client-types/locales.js.map +1 -0
- package/lib/client-types/slot-components.d.ts +38 -0
- package/lib/client-types/slot-components.d.ts.map +1 -0
- package/lib/client-types/slot-components.js +22 -0
- package/lib/client-types/slot-components.js.map +1 -0
- package/lib/client-types/store.d.ts +69 -0
- package/lib/client-types/store.d.ts.map +1 -0
- package/lib/client-types/store.js +135 -0
- package/lib/client-types/store.js.map +1 -0
- package/lib/client-types/workflow-definition.d.ts +52 -0
- package/lib/client-types/workflow-definition.d.ts.map +1 -0
- package/lib/client-types/workflow-definition.js +135 -0
- package/lib/client-types/workflow-definition.js.map +1 -0
- package/lib/client.js +11064 -0
- package/lib/client.js.map +1 -0
- package/lib/compat-engine/index.js +1926 -0
- package/lib/compat-engine/index.js.map +1 -0
- package/lib/compat-engine/worker.cjs +1500 -0
- package/lib/compat-engine/worker.cjs.map +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1709 -0
- package/lib/typert.remote-client.d.ts +57 -0
- package/lib/typert.remote-client.js +1662 -0
- package/lib/types/commands/aliases.d.ts +4 -0
- package/lib/types/commands/aliases.d.ts.map +1 -0
- package/lib/types/commands/aliases.js +25 -0
- package/lib/types/commands/aliases.js.map +1 -0
- package/lib/types/commands/index.d.ts +66 -0
- package/lib/types/commands/index.d.ts.map +1 -0
- package/lib/types/commands/index.js +550 -0
- package/lib/types/commands/index.js.map +1 -0
- package/lib/types/commands/parser.d.ts +17 -0
- package/lib/types/commands/parser.d.ts.map +1 -0
- package/lib/types/commands/parser.js +45 -0
- package/lib/types/commands/parser.js.map +1 -0
- package/lib/types/config.d.ts +71 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +109 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +483 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts +21 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +85 -0
- package/lib/types/invariant.js.map +1 -0
- package/lib/types/registry/definition.d.ts +24 -0
- package/lib/types/registry/definition.d.ts.map +1 -0
- package/lib/types/registry/definition.js +189 -0
- package/lib/types/registry/definition.js.map +1 -0
- package/lib/types/registry/index.d.ts +100 -0
- package/lib/types/registry/index.d.ts.map +1 -0
- package/lib/types/registry/index.js +899 -0
- package/lib/types/registry/index.js.map +1 -0
- package/lib/types/registry/names.d.ts +19 -0
- package/lib/types/registry/names.d.ts.map +1 -0
- package/lib/types/registry/names.js +69 -0
- package/lib/types/registry/names.js.map +1 -0
- package/lib/types/registry/remote.d.ts +15 -0
- package/lib/types/registry/remote.d.ts.map +1 -0
- package/lib/types/registry/remote.js +128 -0
- package/lib/types/registry/remote.js.map +1 -0
- package/lib/types/registry/roots.d.ts +44 -0
- package/lib/types/registry/roots.d.ts.map +1 -0
- package/lib/types/registry/roots.js +118 -0
- package/lib/types/registry/roots.js.map +1 -0
- package/lib/types/registry/types.d.ts +62 -0
- package/lib/types/registry/types.d.ts.map +1 -0
- package/lib/types/registry/types.js +2 -0
- package/lib/types/registry/types.js.map +1 -0
- package/lib/types/registry/watchers.d.ts +70 -0
- package/lib/types/registry/watchers.d.ts.map +1 -0
- package/lib/types/registry/watchers.js +152 -0
- package/lib/types/registry/watchers.js.map +1 -0
- package/lib/types/remote-events.d.ts +42 -0
- package/lib/types/remote-events.d.ts.map +1 -0
- package/lib/types/remote-events.js +21 -0
- package/lib/types/remote-events.js.map +1 -0
- package/lib/types/run-recorder.d.ts +44 -0
- package/lib/types/run-recorder.d.ts.map +1 -0
- package/lib/types/run-recorder.js +356 -0
- package/lib/types/run-recorder.js.map +1 -0
- package/lib/types/supervisor/canned-validate.d.ts +25 -0
- package/lib/types/supervisor/canned-validate.d.ts.map +1 -0
- package/lib/types/supervisor/canned-validate.js +122 -0
- package/lib/types/supervisor/canned-validate.js.map +1 -0
- package/lib/types/supervisor/completion-notice.d.ts +49 -0
- package/lib/types/supervisor/completion-notice.d.ts.map +1 -0
- package/lib/types/supervisor/completion-notice.js +281 -0
- package/lib/types/supervisor/completion-notice.js.map +1 -0
- package/lib/types/supervisor/cursors.d.ts +68 -0
- package/lib/types/supervisor/cursors.d.ts.map +1 -0
- package/lib/types/supervisor/cursors.js +158 -0
- package/lib/types/supervisor/cursors.js.map +1 -0
- package/lib/types/supervisor/engine-compat.d.ts +48 -0
- package/lib/types/supervisor/engine-compat.d.ts.map +1 -0
- package/lib/types/supervisor/engine-compat.js +116 -0
- package/lib/types/supervisor/engine-compat.js.map +1 -0
- package/lib/types/supervisor/index.d.ts +183 -0
- package/lib/types/supervisor/index.d.ts.map +1 -0
- package/lib/types/supervisor/index.js +2040 -0
- package/lib/types/supervisor/index.js.map +1 -0
- package/lib/types/supervisor/parallel-compat.d.ts +36 -0
- package/lib/types/supervisor/parallel-compat.d.ts.map +1 -0
- package/lib/types/supervisor/parallel-compat.js +180 -0
- package/lib/types/supervisor/parallel-compat.js.map +1 -0
- package/lib/types/supervisor/remote.d.ts +25 -0
- package/lib/types/supervisor/remote.d.ts.map +1 -0
- package/lib/types/supervisor/remote.js +478 -0
- package/lib/types/supervisor/remote.js.map +1 -0
- package/lib/types/supervisor/storage/bounded-file.d.ts +41 -0
- package/lib/types/supervisor/storage/bounded-file.d.ts.map +1 -0
- package/lib/types/supervisor/storage/bounded-file.js +186 -0
- package/lib/types/supervisor/storage/bounded-file.js.map +1 -0
- package/lib/types/supervisor/storage/details-codec.d.ts +14 -0
- package/lib/types/supervisor/storage/details-codec.d.ts.map +1 -0
- package/lib/types/supervisor/storage/details-codec.js +359 -0
- package/lib/types/supervisor/storage/details-codec.js.map +1 -0
- package/lib/types/supervisor/storage/index.d.ts +26 -0
- package/lib/types/supervisor/storage/index.d.ts.map +1 -0
- package/lib/types/supervisor/storage/index.js +100 -0
- package/lib/types/supervisor/storage/index.js.map +1 -0
- package/lib/types/supervisor/storage/lease.d.ts +34 -0
- package/lib/types/supervisor/storage/lease.d.ts.map +1 -0
- package/lib/types/supervisor/storage/lease.js +289 -0
- package/lib/types/supervisor/storage/lease.js.map +1 -0
- package/lib/types/supervisor/storage/manifest-codec.d.ts +10 -0
- package/lib/types/supervisor/storage/manifest-codec.d.ts.map +1 -0
- package/lib/types/supervisor/storage/manifest-codec.js +335 -0
- package/lib/types/supervisor/storage/manifest-codec.js.map +1 -0
- package/lib/types/supervisor/storage/manifest-store.d.ts +99 -0
- package/lib/types/supervisor/storage/manifest-store.d.ts.map +1 -0
- package/lib/types/supervisor/storage/manifest-store.js +1606 -0
- package/lib/types/supervisor/storage/manifest-store.js.map +1 -0
- package/lib/types/supervisor/storage/manifest-types.d.ts +269 -0
- package/lib/types/supervisor/storage/manifest-types.d.ts.map +1 -0
- package/lib/types/supervisor/storage/manifest-types.js +2 -0
- package/lib/types/supervisor/storage/manifest-types.js.map +1 -0
- package/lib/types/supervisor/storage/private-root.d.ts +143 -0
- package/lib/types/supervisor/storage/private-root.d.ts.map +1 -0
- package/lib/types/supervisor/storage/private-root.js +860 -0
- package/lib/types/supervisor/storage/private-root.js.map +1 -0
- package/lib/types/supervisor/storage/recovery.d.ts +14 -0
- package/lib/types/supervisor/storage/recovery.d.ts.map +1 -0
- package/lib/types/supervisor/storage/recovery.js +126 -0
- package/lib/types/supervisor/storage/recovery.js.map +1 -0
- package/lib/types/supervisor/storage/run-files.d.ts +67 -0
- package/lib/types/supervisor/storage/run-files.d.ts.map +1 -0
- package/lib/types/supervisor/storage/run-files.js +346 -0
- package/lib/types/supervisor/storage/run-files.js.map +1 -0
- package/lib/types/supervisor/types.d.ts +295 -0
- package/lib/types/supervisor/types.d.ts.map +1 -0
- package/lib/types/supervisor/types.js +3 -0
- package/lib/types/supervisor/types.js.map +1 -0
- package/lib/types/supervisor/value-view.d.ts +19 -0
- package/lib/types/supervisor/value-view.d.ts.map +1 -0
- package/lib/types/supervisor/value-view.js +183 -0
- package/lib/types/supervisor/value-view.js.map +1 -0
- package/lib/types/tool/index.d.ts +86 -0
- package/lib/types/tool/index.d.ts.map +1 -0
- package/lib/types/tool/index.js +810 -0
- package/lib/types/tool/index.js.map +1 -0
- package/lib/types/tool/schema.d.ts +158 -0
- package/lib/types/tool/schema.d.ts.map +1 -0
- package/lib/types/tool/schema.js +137 -0
- package/lib/types/tool/schema.js.map +1 -0
- package/lib/types/types.d.ts +9 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +3 -0
- package/lib/types/types.js.map +1 -0
- package/lib/types/user-questions.d.ts +9 -0
- package/lib/types/user-questions.d.ts.map +1 -0
- package/lib/types/user-questions.js +91 -0
- package/lib/types/user-questions.js.map +1 -0
- package/package.json +273 -0
- package/skills/create-workflow/SKILL.md +166 -0
- package/vendor/workflow-engine/LICENSE +21 -0
- package/vendor/workflow-engine/README.md +1 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, parse } from 'node:path';
|
|
5
|
+
import { BoundedFileError, assertSafeComponent } from './bounded-file.js';
|
|
6
|
+
/** Close every layout descriptor, retaining the first failure. */
|
|
7
|
+
export async function closeWorkflowStorageLayout(layout) {
|
|
8
|
+
let first;
|
|
9
|
+
// Close children before the lease. The order is deliberately stable so a
|
|
10
|
+
// teardown cannot race a still-open descriptor while the root is released.
|
|
11
|
+
for (const directory of [layout.sessions, layout.runs, layout.staging, layout.quarantine]) {
|
|
12
|
+
try {
|
|
13
|
+
await directory.close();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
first ??= error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
await layout.root.close();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
first ??= error;
|
|
24
|
+
}
|
|
25
|
+
if (first !== undefined)
|
|
26
|
+
throw first;
|
|
27
|
+
}
|
|
28
|
+
const O_DIRECTORY = constants.O_DIRECTORY ?? 0;
|
|
29
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW;
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
function failUnsupported() {
|
|
32
|
+
throw new BoundedFileError(`safe workflow storage is unavailable on ${process.platform}`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
33
|
+
}
|
|
34
|
+
function unsupportedCapability(detail) {
|
|
35
|
+
return new BoundedFileError(`descriptor-rooted workflow storage ${detail} is unavailable`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
36
|
+
}
|
|
37
|
+
/** Refuse malformed or incomplete Host capabilities before the package creates
|
|
38
|
+
* any category directory. A structural cast is not a security boundary: all
|
|
39
|
+
* required descriptor operations must be callable at runtime. */
|
|
40
|
+
function assertHostDirectoryDelegate(value) {
|
|
41
|
+
if (typeof value !== 'object' || value === null)
|
|
42
|
+
throw unsupportedCapability('directory capability');
|
|
43
|
+
for (const member of ['openDirectory', 'readBytes', 'writeText', 'assertIdentity', 'close']) {
|
|
44
|
+
if (typeof value[member] !== 'function')
|
|
45
|
+
throw unsupportedCapability(`directory ${member}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function assertHostStorageCapabilities(provider, root) {
|
|
49
|
+
const canList = typeof root.listEntries === 'function'
|
|
50
|
+
|| typeof provider.listDir === 'function' && root.target !== undefined;
|
|
51
|
+
if (!canList)
|
|
52
|
+
throw unsupportedCapability('directory listing');
|
|
53
|
+
if (typeof root.fileInfo !== 'function' && !(root.target !== undefined && typeof provider.fileInfoChild === 'function'))
|
|
54
|
+
throw unsupportedCapability('file metadata');
|
|
55
|
+
if (typeof root.publishDirectory !== 'function' && !(root.target !== undefined && typeof provider.publishDirectory === 'function'))
|
|
56
|
+
throw unsupportedCapability('directory publication');
|
|
57
|
+
if (typeof root.removeFile !== 'function' && !(root.target !== undefined && typeof provider.removeFile === 'function'))
|
|
58
|
+
throw unsupportedCapability('file removal');
|
|
59
|
+
if (typeof root.removeDirectory !== 'function' && !(root.target !== undefined && typeof provider.removeDirectory === 'function'))
|
|
60
|
+
throw unsupportedCapability('directory removal');
|
|
61
|
+
}
|
|
62
|
+
function component(name) {
|
|
63
|
+
return assertSafeComponent(name, 'private path component');
|
|
64
|
+
}
|
|
65
|
+
function ownerOk(info) {
|
|
66
|
+
return typeof process.getuid !== 'function' || info.uid === process.getuid();
|
|
67
|
+
}
|
|
68
|
+
function assertDirectoryInfo(info, path, allowSystemRoot = false) {
|
|
69
|
+
if (!info.isDirectory() || info.nlink < 1 || (!allowSystemRoot && !ownerOk(info)) || (!allowSystemRoot && (info.mode & 0o777) !== 0o700)) {
|
|
70
|
+
throw new BoundedFileError(`workflow storage path "${path}" is unsafe: expected owner-only directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function assertAncestorDirectoryInfo(info, path) {
|
|
74
|
+
if (!info.isDirectory() || info.nlink < 1) {
|
|
75
|
+
throw new BoundedFileError(`workflow storage path "${path}" is unsafe: expected directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function assertFileInfo(info, path) {
|
|
79
|
+
if (!info.isFile() || info.nlink !== 1 || !ownerOk(info) || (info.mode & 0o777) !== 0o600) {
|
|
80
|
+
throw new BoundedFileError(`workflow storage path "${path}" is unsafe: expected owner-only regular file`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function identityOf(info) {
|
|
84
|
+
return {
|
|
85
|
+
dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size),
|
|
86
|
+
mtimeMs: Number(info.mtimeMs), ctimeMs: Number(info.ctimeMs), mode: Number(info.mode & 0o777),
|
|
87
|
+
nlink: Number(info.nlink), ...(info.uid === undefined ? {} : { uid: Number(info.uid) }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function sameIdentity(a, b, content = false) {
|
|
91
|
+
if (Number(a.dev) !== Number(b.dev) || Number(a.ino) !== Number(b.ino))
|
|
92
|
+
return false;
|
|
93
|
+
if (!content)
|
|
94
|
+
return true;
|
|
95
|
+
return Number(a.size) === Number(b.size)
|
|
96
|
+
&& Number(a.mtimeMs) === Number(b.mtimeMs)
|
|
97
|
+
&& Number(a.ctimeMs) === Number(b.ctimeMs)
|
|
98
|
+
&& Number(a.mode) === Number(b.mode)
|
|
99
|
+
&& Number(a.nlink) === Number(b.nlink);
|
|
100
|
+
}
|
|
101
|
+
/** A retained directory descriptor. Relative operations revalidate its identity. */
|
|
102
|
+
class LocalPrivateDirectory {
|
|
103
|
+
path;
|
|
104
|
+
handle;
|
|
105
|
+
identity;
|
|
106
|
+
parent;
|
|
107
|
+
systemRoot;
|
|
108
|
+
strict;
|
|
109
|
+
closed = false;
|
|
110
|
+
handleClosed = false;
|
|
111
|
+
children = new Set();
|
|
112
|
+
closePromise;
|
|
113
|
+
constructor(path, handle, identity, parent, systemRoot = false, strict = true) {
|
|
114
|
+
this.path = path;
|
|
115
|
+
this.handle = handle;
|
|
116
|
+
this.identity = identity;
|
|
117
|
+
this.parent = parent;
|
|
118
|
+
this.systemRoot = systemRoot;
|
|
119
|
+
this.strict = strict;
|
|
120
|
+
parent?.children.add(this);
|
|
121
|
+
}
|
|
122
|
+
ensureOpen(signal) {
|
|
123
|
+
if (this.closed)
|
|
124
|
+
throw new BoundedFileError('private directory is closed');
|
|
125
|
+
signal?.throwIfAborted();
|
|
126
|
+
}
|
|
127
|
+
/** Check this descriptor and all retained ancestors without requiring them
|
|
128
|
+
* to remain open for new operations. A child capability keeps its parent
|
|
129
|
+
* object alive, so closing a parent merely releases the caller's reference;
|
|
130
|
+
* the descriptor is closed after the last child releases it. */
|
|
131
|
+
async assertRetainedIdentity(signal) {
|
|
132
|
+
signal?.throwIfAborted();
|
|
133
|
+
if (this.handleClosed)
|
|
134
|
+
throw new BoundedFileError('private directory is closed', 'WORKFLOW_STORAGE_UNSAFE');
|
|
135
|
+
const retained = await this.handle.stat();
|
|
136
|
+
const current = await lstat(this.path);
|
|
137
|
+
if (!retained.isDirectory() || !current.isDirectory() || !sameIdentity(retained, current)
|
|
138
|
+
|| retained.dev !== this.identity.dev || retained.ino !== this.identity.ino) {
|
|
139
|
+
throw new BoundedFileError(`workflow storage path "${this.path}" is unsafe: directory identity changed`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
140
|
+
}
|
|
141
|
+
if (this.strict)
|
|
142
|
+
assertDirectoryInfo(current, this.path, this.systemRoot);
|
|
143
|
+
else
|
|
144
|
+
assertAncestorDirectoryInfo(current, this.path);
|
|
145
|
+
if (this.parent !== undefined)
|
|
146
|
+
await this.parent.assertRetainedIdentity(signal);
|
|
147
|
+
}
|
|
148
|
+
async assertIdentity(signal) {
|
|
149
|
+
this.ensureOpen(signal);
|
|
150
|
+
await this.assertRetainedIdentity(signal);
|
|
151
|
+
}
|
|
152
|
+
async openDirectory(name, signal, options = { create: false }) {
|
|
153
|
+
component(name);
|
|
154
|
+
this.ensureOpen(signal);
|
|
155
|
+
await this.assertIdentity(signal);
|
|
156
|
+
if (O_NOFOLLOW === undefined || O_NOFOLLOW === 0 || process.platform === 'win32')
|
|
157
|
+
failUnsupported();
|
|
158
|
+
const childPath = join(this.path, name);
|
|
159
|
+
let created = false;
|
|
160
|
+
try {
|
|
161
|
+
const existing = await lstat(childPath);
|
|
162
|
+
if (existing.isSymbolicLink() || !existing.isDirectory()) {
|
|
163
|
+
throw new BoundedFileError(`workflow storage path "${childPath}" is unsafe: expected directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
164
|
+
}
|
|
165
|
+
assertDirectoryInfo(existing, childPath);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error.code !== 'ENOENT' || options.create === false)
|
|
169
|
+
throw error;
|
|
170
|
+
await mkdir(childPath, { mode: 0o700 });
|
|
171
|
+
created = true;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await chmod(childPath, 0o700);
|
|
175
|
+
const childHandle = await open(childPath, constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
|
176
|
+
const childInfo = await childHandle.stat();
|
|
177
|
+
assertDirectoryInfo(childInfo, childPath);
|
|
178
|
+
const pathInfo = await lstat(childPath);
|
|
179
|
+
if (!sameIdentity(childInfo, pathInfo))
|
|
180
|
+
throw new BoundedFileError(`workflow storage path "${childPath}" is unsafe: identity changed`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
181
|
+
await this.assertRetainedIdentity(signal);
|
|
182
|
+
return new LocalPrivateDirectory(childPath, childHandle, { dev: Number(childInfo.dev), ino: Number(childInfo.ino) }, this);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (created) {
|
|
186
|
+
// Remove only the directory just created; never recursively remove. A
|
|
187
|
+
// failed identity check leaves pre-existing data untouched.
|
|
188
|
+
await import('node:fs/promises').then(fs => fs.rmdir(childPath)).catch(() => undefined);
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async listEntries(signal) {
|
|
194
|
+
this.ensureOpen(signal);
|
|
195
|
+
await this.assertRetainedIdentity(signal);
|
|
196
|
+
const entries = await readdir(this.path, { withFileTypes: true });
|
|
197
|
+
const result = [];
|
|
198
|
+
for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) {
|
|
199
|
+
signal?.throwIfAborted();
|
|
200
|
+
component(entry.name);
|
|
201
|
+
const info = await lstat(join(this.path, entry.name));
|
|
202
|
+
const type = info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : info.isFile() ? 'file' : 'other';
|
|
203
|
+
result.push({ name: entry.name, type, ...(type === 'file' || type === 'directory' ? { identity: identityOf(info) } : {}) });
|
|
204
|
+
}
|
|
205
|
+
await this.assertRetainedIdentity(signal);
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
async fileInfo(name, signal) {
|
|
209
|
+
component(name);
|
|
210
|
+
this.ensureOpen(signal);
|
|
211
|
+
await this.assertRetainedIdentity(signal);
|
|
212
|
+
try {
|
|
213
|
+
const info = await lstat(join(this.path, name));
|
|
214
|
+
if (info.isSymbolicLink())
|
|
215
|
+
throw new BoundedFileError(`workflow storage path "${join(this.path, name)}" is unsafe: symbolic link`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
216
|
+
await this.assertRetainedIdentity(signal);
|
|
217
|
+
return identityOf(info);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error.code === 'ENOENT') {
|
|
221
|
+
// An ENOENT is not proof of absence when the retained directory may
|
|
222
|
+
// have been replaced concurrently. Revalidate before returning the
|
|
223
|
+
// negative observation so callers never turn an ancestor swap into a
|
|
224
|
+
// guarded create.
|
|
225
|
+
await this.assertRetainedIdentity(signal);
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async removeFile(name, expected, signal) {
|
|
232
|
+
component(name);
|
|
233
|
+
this.ensureOpen(signal);
|
|
234
|
+
await this.assertRetainedIdentity(signal);
|
|
235
|
+
const target = join(this.path, name);
|
|
236
|
+
const before = await lstat(target);
|
|
237
|
+
if (before.isSymbolicLink() || !before.isFile())
|
|
238
|
+
throw new BoundedFileError(`workflow storage path "${target}" is unsafe: destination is not a regular file`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
239
|
+
assertFileInfo(before, target);
|
|
240
|
+
if (expected !== undefined && !sameVersion(expected, before))
|
|
241
|
+
throw new BoundedFileError(`private file "${name}" has changed`, 'WORKFLOW_STALE_REVISION');
|
|
242
|
+
await unlink(target);
|
|
243
|
+
await this.assertRetainedIdentity(signal);
|
|
244
|
+
}
|
|
245
|
+
async removeDirectory(name, expected, signal) {
|
|
246
|
+
component(name);
|
|
247
|
+
this.ensureOpen(signal);
|
|
248
|
+
await this.assertRetainedIdentity(signal);
|
|
249
|
+
const target = join(this.path, name);
|
|
250
|
+
const before = await lstat(target);
|
|
251
|
+
if (!before.isDirectory() || before.isSymbolicLink() || before.nlink < 1)
|
|
252
|
+
throw new BoundedFileError(`workflow storage path "${target}" is unsafe: destination is not an empty directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
253
|
+
if (expected !== undefined && !sameVersion(expected, before))
|
|
254
|
+
throw new BoundedFileError(`private directory "${name}" has changed`, 'WORKFLOW_STALE_REVISION');
|
|
255
|
+
try {
|
|
256
|
+
await rmdir(target);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error.code === 'ENOTEMPTY')
|
|
260
|
+
throw new BoundedFileError(`private directory "${name}" is not empty`, 'WORKFLOW_STORAGE_UNSAFE', { cause: error });
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
await this.assertRetainedIdentity(signal);
|
|
264
|
+
}
|
|
265
|
+
async publishDirectory(name, target, targetName, signal) {
|
|
266
|
+
component(name);
|
|
267
|
+
component(targetName);
|
|
268
|
+
this.ensureOpen(signal);
|
|
269
|
+
await this.assertRetainedIdentity(signal);
|
|
270
|
+
await target.assertIdentity(signal);
|
|
271
|
+
if (!(target instanceof LocalPrivateDirectory))
|
|
272
|
+
throw new BoundedFileError('private directory publication providers do not match', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
273
|
+
const sourcePath = join(this.path, name);
|
|
274
|
+
const destinationPath = join(target.path, targetName);
|
|
275
|
+
const source = await lstat(sourcePath);
|
|
276
|
+
if (!source.isDirectory() || source.isSymbolicLink())
|
|
277
|
+
throw new BoundedFileError(`workflow storage path "${sourcePath}" is unsafe: staging entry is not a directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
278
|
+
const destination = await lstat(destinationPath).catch(error => error.code === 'ENOENT' ? undefined : Promise.reject(error));
|
|
279
|
+
if (destination !== undefined)
|
|
280
|
+
throw new BoundedFileError(`workflow storage path "${destinationPath}" already exists`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
281
|
+
signal?.throwIfAborted();
|
|
282
|
+
await rename(sourcePath, destinationPath);
|
|
283
|
+
await this.assertRetainedIdentity(signal);
|
|
284
|
+
await target.assertIdentity(signal);
|
|
285
|
+
}
|
|
286
|
+
async readBytes(name, signal, maxBytes) {
|
|
287
|
+
component(name);
|
|
288
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
289
|
+
throw new RangeError('maxBytes must be a non-negative safe integer');
|
|
290
|
+
this.ensureOpen(signal);
|
|
291
|
+
await this.assertIdentity(signal);
|
|
292
|
+
if (O_NOFOLLOW === undefined || O_NOFOLLOW === 0 || process.platform === 'win32')
|
|
293
|
+
failUnsupported();
|
|
294
|
+
const filePath = join(this.path, name);
|
|
295
|
+
const file = await open(filePath, constants.O_RDONLY | O_NOFOLLOW);
|
|
296
|
+
try {
|
|
297
|
+
signal?.throwIfAborted();
|
|
298
|
+
const info = await file.stat();
|
|
299
|
+
assertFileInfo(info, filePath);
|
|
300
|
+
if (info.size > maxBytes)
|
|
301
|
+
throw new BoundedFileError(`workflow storage file "${filePath}" exceeds ${maxBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
302
|
+
const chunks = [];
|
|
303
|
+
let total = 0;
|
|
304
|
+
while (total <= maxBytes) {
|
|
305
|
+
signal?.throwIfAborted();
|
|
306
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes - total + 1));
|
|
307
|
+
const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
|
|
308
|
+
if (bytesRead === 0)
|
|
309
|
+
break;
|
|
310
|
+
total += bytesRead;
|
|
311
|
+
if (total > maxBytes)
|
|
312
|
+
throw new BoundedFileError(`workflow storage file "${filePath}" exceeds ${maxBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
313
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
314
|
+
}
|
|
315
|
+
const after = await file.stat();
|
|
316
|
+
if (!sameIdentity(info, after, true))
|
|
317
|
+
throw new BoundedFileError(`workflow storage file "${filePath}" changed while reading`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
318
|
+
return new Uint8Array(Buffer.concat(chunks, total));
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await file.close();
|
|
322
|
+
await this.assertIdentity(signal);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async writeText(name, content, expected, signal) {
|
|
326
|
+
component(name);
|
|
327
|
+
this.ensureOpen(signal);
|
|
328
|
+
await this.assertIdentity(signal);
|
|
329
|
+
if (expected.kind !== 'createIfAbsent' && expected.kind !== 'replaceIfVersion')
|
|
330
|
+
throw new BoundedFileError('unguarded private write');
|
|
331
|
+
const target = join(this.path, name);
|
|
332
|
+
let before;
|
|
333
|
+
try {
|
|
334
|
+
before = await lstat(target);
|
|
335
|
+
if (before.isSymbolicLink() || !before.isFile())
|
|
336
|
+
throw new BoundedFileError(`workflow storage path "${target}" is unsafe: destination is not a regular file`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
337
|
+
assertFileInfo(before, target);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (error.code !== 'ENOENT')
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
if (expected.kind === 'createIfAbsent' && before !== undefined)
|
|
344
|
+
throw new BoundedFileError(`private file "${name}" already exists`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
345
|
+
if (expected.kind === 'replaceIfVersion' && !before)
|
|
346
|
+
throw new BoundedFileError(`private file "${name}" does not exist`, 'WORKFLOW_STALE_REVISION');
|
|
347
|
+
if (expected.kind === 'replaceIfVersion' && expected.version !== undefined) {
|
|
348
|
+
const candidate = expected.version;
|
|
349
|
+
if (typeof candidate !== 'object' || candidate === null || !sameVersion(candidate, before)) {
|
|
350
|
+
throw new BoundedFileError(`private file "${name}" has changed`, 'WORKFLOW_STALE_REVISION');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
signal?.throwIfAborted();
|
|
354
|
+
const temp = join(this.path, `.${name}.${randomBytes(16).toString('hex')}.tmp`);
|
|
355
|
+
const bytes = encoder.encode(content);
|
|
356
|
+
let tempHandle;
|
|
357
|
+
try {
|
|
358
|
+
tempHandle = await open(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | O_NOFOLLOW, 0o600);
|
|
359
|
+
let offset = 0;
|
|
360
|
+
while (offset < bytes.byteLength) {
|
|
361
|
+
const written = await tempHandle.write(bytes, offset, bytes.byteLength - offset, offset);
|
|
362
|
+
if (written.bytesWritten <= 0)
|
|
363
|
+
throw new BoundedFileError(`workflow storage file "${temp}" was short-written`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
364
|
+
offset += written.bytesWritten;
|
|
365
|
+
}
|
|
366
|
+
await tempHandle.sync();
|
|
367
|
+
await tempHandle.close();
|
|
368
|
+
tempHandle = undefined;
|
|
369
|
+
await this.assertIdentity(signal);
|
|
370
|
+
const latest = await lstat(target).catch(error => {
|
|
371
|
+
if (error.code === 'ENOENT')
|
|
372
|
+
return undefined;
|
|
373
|
+
throw error;
|
|
374
|
+
});
|
|
375
|
+
if (expected.kind === 'createIfAbsent' && latest !== undefined)
|
|
376
|
+
throw new BoundedFileError(`private file "${name}" already exists`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
377
|
+
if (expected.kind === 'replaceIfVersion' && (!latest || (before && !sameIdentity(before, latest, true))))
|
|
378
|
+
throw new BoundedFileError(`private file "${name}" has changed`, 'WORKFLOW_STALE_REVISION');
|
|
379
|
+
if (expected.kind === 'createIfAbsent') {
|
|
380
|
+
// `link` is the POSIX no-replace publication primitive. It fails with
|
|
381
|
+
// EEXIST instead of overwriting a destination created after our check.
|
|
382
|
+
try {
|
|
383
|
+
await link(temp, target);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (error.code === 'EEXIST') {
|
|
387
|
+
throw new BoundedFileError(`private file "${name}" already exists`, 'WORKFLOW_STORAGE_UNSAFE', { cause: error });
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
await unlink(temp);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
await rename(temp, target);
|
|
395
|
+
}
|
|
396
|
+
const after = await lstat(target);
|
|
397
|
+
assertFileInfo(after, target);
|
|
398
|
+
const parent = await open(this.path, constants.O_RDONLY | O_DIRECTORY);
|
|
399
|
+
try {
|
|
400
|
+
await parent.sync();
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await parent.close();
|
|
404
|
+
}
|
|
405
|
+
await this.assertRetainedIdentity(signal);
|
|
406
|
+
return { operation: expected.kind, version: identityOf(after), before: before ? identityOf(before) : null, after: content };
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
await tempHandle?.close().catch(() => undefined);
|
|
410
|
+
await unlink(temp).catch(() => undefined);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async close() {
|
|
414
|
+
if (this.closePromise !== undefined)
|
|
415
|
+
return this.closePromise;
|
|
416
|
+
this.closed = true;
|
|
417
|
+
this.closePromise = new Promise((resolve, reject) => {
|
|
418
|
+
this.closeWaiters.push({ resolve, reject });
|
|
419
|
+
void this.closeWhenUnreferenced();
|
|
420
|
+
});
|
|
421
|
+
return this.closePromise;
|
|
422
|
+
}
|
|
423
|
+
closeWaiters = [];
|
|
424
|
+
async closeWhenUnreferenced() {
|
|
425
|
+
if (!this.handleClosed && this.children.size === 0) {
|
|
426
|
+
this.handleClosed = true;
|
|
427
|
+
let error;
|
|
428
|
+
try {
|
|
429
|
+
await this.handle.close();
|
|
430
|
+
}
|
|
431
|
+
catch (cause) {
|
|
432
|
+
error = cause;
|
|
433
|
+
}
|
|
434
|
+
this.parent?.children.delete(this);
|
|
435
|
+
// A child keeps its ancestor descriptor alive, but does not implicitly
|
|
436
|
+
// close an ancestor that the caller still owns. If the parent was
|
|
437
|
+
// explicitly closed while this child was retained, complete that
|
|
438
|
+
// deferred close now.
|
|
439
|
+
if (this.parent?.closed)
|
|
440
|
+
await this.parent.closeWhenUnreferenced();
|
|
441
|
+
for (const waiter of this.closeWaiters.splice(0))
|
|
442
|
+
error === undefined ? waiter.resolve() : waiter.reject(error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Adapter for an official descriptor capability. No operation below
|
|
447
|
+
* opens a path with Node; the path is retained only for diagnostics and for
|
|
448
|
+
* the provider's own `openPrivateDirectory` bootstrap seam. */
|
|
449
|
+
class HostPrivateDirectory {
|
|
450
|
+
path;
|
|
451
|
+
provider;
|
|
452
|
+
delegate;
|
|
453
|
+
closed = false;
|
|
454
|
+
constructor(path, provider, delegate) {
|
|
455
|
+
this.path = path;
|
|
456
|
+
this.provider = provider;
|
|
457
|
+
this.delegate = delegate;
|
|
458
|
+
assertHostDirectoryDelegate(delegate);
|
|
459
|
+
}
|
|
460
|
+
ensure(signal) {
|
|
461
|
+
if (this.closed)
|
|
462
|
+
throw new BoundedFileError('private directory is closed', 'WORKFLOW_STORAGE_UNSAFE');
|
|
463
|
+
signal?.throwIfAborted();
|
|
464
|
+
}
|
|
465
|
+
async openDirectory(name, signal, options = {}) {
|
|
466
|
+
component(name);
|
|
467
|
+
this.ensure(signal);
|
|
468
|
+
await this.delegate.assertIdentity(signal);
|
|
469
|
+
try {
|
|
470
|
+
const child = await this.delegate.openDirectory(name, signal, options);
|
|
471
|
+
assertHostDirectoryDelegate(child);
|
|
472
|
+
await this.delegate.assertIdentity(signal);
|
|
473
|
+
return new HostPrivateDirectory(join(this.path, name), this.provider, child);
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
// Only the provider's explicit not-found result may enter the create
|
|
477
|
+
// fallback. Treating an arbitrary delegate failure (including an
|
|
478
|
+
// identity/link failure) as absence would silently reopen a mutable path
|
|
479
|
+
// and defeat the descriptor guarantee.
|
|
480
|
+
const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '';
|
|
481
|
+
const missing = code === 'ENOENT' || code === 'FS_NOT_FOUND';
|
|
482
|
+
if (options.create !== true || !missing)
|
|
483
|
+
throw error;
|
|
484
|
+
await this.delegate.assertIdentity(signal);
|
|
485
|
+
let child;
|
|
486
|
+
if (this.delegate.target !== undefined && typeof this.provider.openPrivateDirectoryChild === 'function') {
|
|
487
|
+
child = await this.provider.openPrivateDirectoryChild(this.delegate.target, name, { create: true }, signal);
|
|
488
|
+
}
|
|
489
|
+
else if (this.provider.allowLegacyPathFallback === true) {
|
|
490
|
+
// This path-shaped adapter is retained only for explicitly marked
|
|
491
|
+
// legacy test fixtures. A production Host provider must expose an opaque
|
|
492
|
+
// child primitive; never infer fixture status from the shape of a
|
|
493
|
+
// target or silently reopen a lexical path after an absent result.
|
|
494
|
+
child = await this.provider.openPrivateDirectory(join(this.path, name), { create: true }, signal);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
throw unsupportedCapability('relative child creation');
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
assertHostDirectoryDelegate(child);
|
|
501
|
+
await this.delegate.assertIdentity(signal);
|
|
502
|
+
return new HostPrivateDirectory(join(this.path, name), this.provider, child);
|
|
503
|
+
}
|
|
504
|
+
catch (creationError) {
|
|
505
|
+
if (typeof child.close === 'function')
|
|
506
|
+
await child.close().catch(() => undefined);
|
|
507
|
+
throw creationError;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async listEntries(signal) {
|
|
512
|
+
this.ensure(signal);
|
|
513
|
+
await this.delegate.assertIdentity(signal);
|
|
514
|
+
const direct = this.delegate.listEntries;
|
|
515
|
+
const list = this.provider.listDir;
|
|
516
|
+
let entries;
|
|
517
|
+
if (direct !== undefined)
|
|
518
|
+
entries = await direct.call(this.delegate, signal);
|
|
519
|
+
else if (list !== undefined && this.delegate.target !== undefined) {
|
|
520
|
+
// The retained opaque target is consumed directly; no lexical path is
|
|
521
|
+
// resolved or reopened here.
|
|
522
|
+
entries = await list(this.delegate.target, signal);
|
|
523
|
+
}
|
|
524
|
+
else
|
|
525
|
+
throw new BoundedFileError('descriptor-rooted directory listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
526
|
+
const result = [];
|
|
527
|
+
for (const entry of entries) {
|
|
528
|
+
signal?.throwIfAborted();
|
|
529
|
+
component(entry.name);
|
|
530
|
+
const type = entry.type === 'file' ? 'file' : entry.type === 'directory' ? 'directory' : entry.type === 'symlink' ? 'symlink' : 'other';
|
|
531
|
+
// Provider versions are opaque. Do not manufacture dev/ino values for
|
|
532
|
+
// Provider entries: a zero identity is indistinguishable from a real identity
|
|
533
|
+
// and would turn an unverified cleanup into a false capability claim.
|
|
534
|
+
// A provider version is opaque metadata, not a POSIX dev/ino pair. Do
|
|
535
|
+
// not manufacture NaN/zero fields and present them as an identity: the
|
|
536
|
+
// caller must obtain a real guarded fileInfo observation (or fail
|
|
537
|
+
// closed) before it can read, account, or remove the entry.
|
|
538
|
+
const hasPosixIdentity = Number.isSafeInteger(Number(entry.dev))
|
|
539
|
+
&& Number.isSafeInteger(Number(entry.ino))
|
|
540
|
+
&& Number.isSafeInteger(Number(entry.size))
|
|
541
|
+
&& Number(entry.size) >= 0;
|
|
542
|
+
result.push({ name: entry.name, type, ...(hasPosixIdentity ? {
|
|
543
|
+
identity: {
|
|
544
|
+
dev: Number(entry.dev), ino: Number(entry.ino), size: Number(entry.size),
|
|
545
|
+
mtimeMs: Number(entry.mtimeMs ?? 0), ctimeMs: Number(entry.ctimeMs ?? 0),
|
|
546
|
+
mode: Number(entry.mode ?? 0), nlink: Number(entry.nlink ?? 1),
|
|
547
|
+
...(entry.version === undefined ? {} : { version: entry.version }),
|
|
548
|
+
},
|
|
549
|
+
} : {}) });
|
|
550
|
+
}
|
|
551
|
+
await this.delegate.assertIdentity(signal);
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
async fileInfo(name, signal) {
|
|
555
|
+
component(name);
|
|
556
|
+
this.ensure(signal);
|
|
557
|
+
const direct = this.delegate.fileInfo;
|
|
558
|
+
await this.delegate.assertIdentity(signal);
|
|
559
|
+
if (direct !== undefined) {
|
|
560
|
+
const result = await direct.call(this.delegate, name, signal);
|
|
561
|
+
await this.delegate.assertIdentity(signal);
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
564
|
+
if (this.delegate.target === undefined || typeof this.provider.fileInfoChild !== 'function')
|
|
565
|
+
throw new BoundedFileError('descriptor-rooted file metadata is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
566
|
+
const result = await this.provider.fileInfoChild(this.delegate.target, name, signal);
|
|
567
|
+
await this.delegate.assertIdentity(signal);
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
async removeFile(name, expected, signal) {
|
|
571
|
+
component(name);
|
|
572
|
+
this.ensure(signal);
|
|
573
|
+
const remover = this.delegate.removeFile;
|
|
574
|
+
if (typeof remover === 'function')
|
|
575
|
+
await remover.call(this.delegate, name, expected, signal);
|
|
576
|
+
else if (typeof this.provider.removeFile === 'function' && this.delegate.target !== undefined) {
|
|
577
|
+
await this.provider.removeFile(this.delegate.target, name, expected, signal);
|
|
578
|
+
}
|
|
579
|
+
else
|
|
580
|
+
throw new BoundedFileError('descriptor-rooted file removal is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
581
|
+
await this.delegate.assertIdentity(signal);
|
|
582
|
+
}
|
|
583
|
+
async removeDirectory(name, expected, signal) {
|
|
584
|
+
component(name);
|
|
585
|
+
this.ensure(signal);
|
|
586
|
+
const remover = this.delegate.removeDirectory;
|
|
587
|
+
if (typeof remover === 'function')
|
|
588
|
+
await remover.call(this.delegate, name, expected, signal);
|
|
589
|
+
else if (typeof this.provider.removeDirectory === 'function' && this.delegate.target !== undefined) {
|
|
590
|
+
await this.provider.removeDirectory(this.delegate.target, name, expected, signal);
|
|
591
|
+
}
|
|
592
|
+
else
|
|
593
|
+
throw new BoundedFileError('descriptor-rooted directory removal is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
594
|
+
await this.delegate.assertIdentity(signal);
|
|
595
|
+
}
|
|
596
|
+
async publishDirectory(name, target, targetName, signal) {
|
|
597
|
+
component(name);
|
|
598
|
+
component(targetName);
|
|
599
|
+
this.ensure(signal);
|
|
600
|
+
await this.delegate.assertIdentity(signal);
|
|
601
|
+
await target.assertIdentity(signal);
|
|
602
|
+
if (!(target instanceof HostPrivateDirectory))
|
|
603
|
+
throw new BoundedFileError('private directory publication providers do not match', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
604
|
+
const direct = this.delegate.publishDirectory;
|
|
605
|
+
if (typeof direct === 'function') {
|
|
606
|
+
await direct.call(this.delegate, name, target.delegate, targetName, signal);
|
|
607
|
+
}
|
|
608
|
+
else if (typeof this.provider.publishDirectory === 'function' && this.delegate.target !== undefined && target.delegate.target !== undefined) {
|
|
609
|
+
await this.provider.publishDirectory(this.delegate.target, name, target.delegate.target, targetName, signal);
|
|
610
|
+
}
|
|
611
|
+
else
|
|
612
|
+
throw new BoundedFileError('descriptor-rooted directory publication is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
613
|
+
await this.delegate.assertIdentity(signal);
|
|
614
|
+
await target.delegate.assertIdentity(signal);
|
|
615
|
+
}
|
|
616
|
+
async readBytes(name, signal, maxBytes) {
|
|
617
|
+
component(name);
|
|
618
|
+
this.ensure(signal);
|
|
619
|
+
return this.delegate.readBytes(name, signal, maxBytes);
|
|
620
|
+
}
|
|
621
|
+
async writeText(name, content, expected, signal) {
|
|
622
|
+
component(name);
|
|
623
|
+
this.ensure(signal);
|
|
624
|
+
const guarded = expected.kind === 'replaceIfVersion' && expected.version !== undefined
|
|
625
|
+
&& typeof expected.version === 'object' && expected.version !== null && 'version' in expected.version
|
|
626
|
+
? { ...expected, version: expected.version.version }
|
|
627
|
+
: expected;
|
|
628
|
+
const outcome = await this.delegate.writeText(name, content, guarded, signal);
|
|
629
|
+
return outcome;
|
|
630
|
+
}
|
|
631
|
+
async assertIdentity(signal) { this.ensure(signal); await this.delegate.assertIdentity(signal); }
|
|
632
|
+
async close() { if (this.closed)
|
|
633
|
+
return; this.closed = true; await this.delegate.close(); }
|
|
634
|
+
}
|
|
635
|
+
function sameVersion(candidate, before) {
|
|
636
|
+
const expected = identityOf(before);
|
|
637
|
+
let compared = false;
|
|
638
|
+
// A local guarded write must carry the stable device/inode pair. Accepting
|
|
639
|
+
// an empty or unrelated object here would turn replaceIfVersion into an
|
|
640
|
+
// unconditional rename. Host-backed directories do their opaque-version
|
|
641
|
+
// comparison in the provider adapter and do not call this helper.
|
|
642
|
+
for (const key of ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode', 'nlink']) {
|
|
643
|
+
if (candidate[key] === undefined)
|
|
644
|
+
continue;
|
|
645
|
+
compared = true;
|
|
646
|
+
const value = Number(candidate[key]);
|
|
647
|
+
if (!Number.isFinite(value) || value !== expected[key])
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
if (candidate.version !== undefined) {
|
|
651
|
+
compared = true;
|
|
652
|
+
const beforeVersion = before.version;
|
|
653
|
+
if (beforeVersion === undefined || !isSameOpaque(candidate.version, beforeVersion))
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
return compared && candidate.dev !== undefined && candidate.ino !== undefined;
|
|
657
|
+
}
|
|
658
|
+
function isSameOpaque(left, right) {
|
|
659
|
+
if (Object.is(left, right))
|
|
660
|
+
return true;
|
|
661
|
+
try {
|
|
662
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async function canonicalPrivatePath(path) {
|
|
669
|
+
const parsed = parse(path);
|
|
670
|
+
const allowedAliases = process.platform === 'darwin' ? new Set(['/var', '/tmp', '/etc']) : new Set();
|
|
671
|
+
let lexical = parsed.root;
|
|
672
|
+
for (const part of path.slice(parsed.root.length).split(/[\\/]+/u).filter(Boolean)) {
|
|
673
|
+
lexical = join(lexical, part);
|
|
674
|
+
try {
|
|
675
|
+
const entry = await lstat(lexical);
|
|
676
|
+
if (entry.isSymbolicLink() && !allowedAliases.has(lexical))
|
|
677
|
+
throw new BoundedFileError(`workflow storage path "${path}" is unsafe: ancestor "${lexical}" is a symbolic link`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (error.code === 'ENOENT')
|
|
681
|
+
break;
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
let current = path;
|
|
686
|
+
const missing = [];
|
|
687
|
+
while (true) {
|
|
688
|
+
try {
|
|
689
|
+
const info = await lstat(current);
|
|
690
|
+
if (info.isSymbolicLink() && missing.length === 0)
|
|
691
|
+
throw new BoundedFileError(`workflow storage path "${path}" is unsafe: symbolic-link directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
692
|
+
if (!info.isDirectory() && !info.isSymbolicLink() && missing.length > 0)
|
|
693
|
+
throw new BoundedFileError(`workflow storage path "${current}" is unsafe: ancestor is not a directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
694
|
+
const resolved = await realpath(current);
|
|
695
|
+
return missing.length === 0 ? resolved : join(resolved, ...missing);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
if (error.code !== 'ENOENT')
|
|
699
|
+
throw error;
|
|
700
|
+
const parent = dirname(current);
|
|
701
|
+
if (parent.length === 0 || parent === current || current === parsed.root)
|
|
702
|
+
throw error;
|
|
703
|
+
const componentName = current.slice(parent.length + 1);
|
|
704
|
+
missing.unshift(componentName);
|
|
705
|
+
current = parent;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Open one owner-only private directory and pin its descriptor identity. */
|
|
710
|
+
export async function openPrivateDirectory(path, create = true) {
|
|
711
|
+
if (O_NOFOLLOW === undefined || O_NOFOLLOW === 0 || process.platform === 'win32')
|
|
712
|
+
failUnsupported();
|
|
713
|
+
if (!isAbsolute(path))
|
|
714
|
+
throw new BoundedFileError(`workflow storage path "${path}" is unsafe: path must be absolute`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
715
|
+
const normalized = await canonicalPrivatePath(path.replace(/[\\/]+$/u, '') || parse(path).root);
|
|
716
|
+
const parsed = parse(normalized);
|
|
717
|
+
const parts = normalized.slice(parsed.root.length).split(/[\\/]+/u).filter(Boolean);
|
|
718
|
+
let currentPath = parsed.root;
|
|
719
|
+
let parent;
|
|
720
|
+
let current;
|
|
721
|
+
const opened = [];
|
|
722
|
+
try {
|
|
723
|
+
// Open every ancestor one component at a time. This retains the full
|
|
724
|
+
// descriptor chain instead of opening a deep path after a string-only
|
|
725
|
+
// lstat walk, which is vulnerable to an ancestor substitution race.
|
|
726
|
+
const rootHandle = await open(parsed.root, constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
|
727
|
+
const rootInfo = await rootHandle.stat();
|
|
728
|
+
assertDirectoryInfo(rootInfo, parsed.root, true);
|
|
729
|
+
current = new LocalPrivateDirectory(parsed.root, rootHandle, { dev: Number(rootInfo.dev), ino: Number(rootInfo.ino) }, undefined, true);
|
|
730
|
+
opened.push(current);
|
|
731
|
+
parent = current;
|
|
732
|
+
for (const [partIndex, part] of parts.entries()) {
|
|
733
|
+
const childPath = join(currentPath, part);
|
|
734
|
+
let childInfo;
|
|
735
|
+
try {
|
|
736
|
+
childInfo = await lstat(childPath);
|
|
737
|
+
if (childInfo.isSymbolicLink() || !childInfo.isDirectory())
|
|
738
|
+
throw new BoundedFileError(`workflow storage path "${childPath}" is unsafe: expected directory`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
739
|
+
if (partIndex === parts.length - 1)
|
|
740
|
+
assertDirectoryInfo(childInfo, childPath);
|
|
741
|
+
else
|
|
742
|
+
assertAncestorDirectoryInfo(childInfo, childPath);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error.code !== 'ENOENT' || !create)
|
|
746
|
+
throw error;
|
|
747
|
+
await mkdir(childPath, { mode: 0o700 });
|
|
748
|
+
await chmod(childPath, 0o700);
|
|
749
|
+
childInfo = await lstat(childPath);
|
|
750
|
+
if (partIndex === parts.length - 1)
|
|
751
|
+
assertDirectoryInfo(childInfo, childPath);
|
|
752
|
+
else
|
|
753
|
+
assertAncestorDirectoryInfo(childInfo, childPath);
|
|
754
|
+
}
|
|
755
|
+
const childHandle = await open(childPath, constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
|
756
|
+
try {
|
|
757
|
+
const retained = await childHandle.stat();
|
|
758
|
+
if (partIndex === parts.length - 1)
|
|
759
|
+
assertDirectoryInfo(retained, childPath);
|
|
760
|
+
else
|
|
761
|
+
assertAncestorDirectoryInfo(retained, childPath);
|
|
762
|
+
const pathInfo = await lstat(childPath);
|
|
763
|
+
if (!sameIdentity(retained, pathInfo))
|
|
764
|
+
throw new BoundedFileError(`workflow storage path "${childPath}" is unsafe: identity changed`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
765
|
+
const previousParent = parent;
|
|
766
|
+
current = new LocalPrivateDirectory(childPath, childHandle, { dev: Number(retained.dev), ino: Number(retained.ino) }, previousParent, false, partIndex === parts.length - 1);
|
|
767
|
+
opened.push(current);
|
|
768
|
+
if (previousParent !== undefined)
|
|
769
|
+
void previousParent.close();
|
|
770
|
+
parent = current;
|
|
771
|
+
currentPath = childPath;
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
await childHandle.close().catch(() => undefined);
|
|
775
|
+
throw error;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return current;
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
// Close leaf-to-root so each retained ancestor can release immediately.
|
|
782
|
+
for (const directory of opened.reverse())
|
|
783
|
+
await directory.close().catch(() => undefined);
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
export async function openVerifiedRunDirectory(layout, runDirectory) {
|
|
788
|
+
component(runDirectory);
|
|
789
|
+
const directory = await layout.runs.openDirectory(runDirectory);
|
|
790
|
+
return { id: runDirectory, directory, scriptPath: join(layout.runs.path, runDirectory, 'script.js'), assertIdentity: () => directory.assertIdentity() };
|
|
791
|
+
}
|
|
792
|
+
export async function initializePrivateLayout(anchor, lease, hostFileSystem) {
|
|
793
|
+
await anchor.assertCurrent();
|
|
794
|
+
await lease.assertCurrent();
|
|
795
|
+
if (hostFileSystem !== undefined) {
|
|
796
|
+
if (typeof hostFileSystem.openPrivateDirectory !== 'function')
|
|
797
|
+
failUnsupported();
|
|
798
|
+
const rootDelegate = await hostFileSystem.openPrivateDirectory(anchor.root, { create: false });
|
|
799
|
+
try {
|
|
800
|
+
assertHostDirectoryDelegate(rootDelegate);
|
|
801
|
+
assertHostStorageCapabilities(hostFileSystem, rootDelegate);
|
|
802
|
+
await rootDelegate.assertIdentity();
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
if (typeof rootDelegate.close === 'function')
|
|
806
|
+
await rootDelegate.close().catch(() => undefined);
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
const root = new HostPrivateDirectory(anchor.root, hostFileSystem, rootDelegate);
|
|
810
|
+
const opened = [];
|
|
811
|
+
try {
|
|
812
|
+
// Create/open categories through the retained root capability. Do not
|
|
813
|
+
// reopen an absolute child path after the anchor has been pinned: that
|
|
814
|
+
// would reintroduce an ancestor substitution window in Host adapters.
|
|
815
|
+
const sessions = await root.openDirectory('sessions', undefined, { create: true });
|
|
816
|
+
opened.push(sessions);
|
|
817
|
+
const runs = await root.openDirectory('runs', undefined, { create: true });
|
|
818
|
+
opened.push(runs);
|
|
819
|
+
const staging = await root.openDirectory('staging', undefined, { create: true });
|
|
820
|
+
opened.push(staging);
|
|
821
|
+
const quarantine = await root.openDirectory('quarantine', undefined, { create: true });
|
|
822
|
+
opened.push(quarantine);
|
|
823
|
+
return { anchor, lease, root, rootDirectory: root, sessions, runs, staging, quarantine };
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
for (const directory of opened.reverse())
|
|
827
|
+
await directory.close().catch(() => undefined);
|
|
828
|
+
await root.close().catch(() => undefined);
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const root = await openPrivateDirectory(anchor.root, false);
|
|
833
|
+
const opened = [];
|
|
834
|
+
try {
|
|
835
|
+
const sessions = await root.openDirectory('sessions', undefined, { create: true });
|
|
836
|
+
opened.push(sessions);
|
|
837
|
+
const runs = await root.openDirectory('runs', undefined, { create: true });
|
|
838
|
+
opened.push(runs);
|
|
839
|
+
const staging = await root.openDirectory('staging', undefined, { create: true });
|
|
840
|
+
opened.push(staging);
|
|
841
|
+
const quarantine = await root.openDirectory('quarantine', undefined, { create: true });
|
|
842
|
+
opened.push(quarantine);
|
|
843
|
+
// Keep the root capability alive for the lifetime of every child. The
|
|
844
|
+
// child capabilities also retain this parent, but making it explicit in
|
|
845
|
+
// the layout gives teardown and Host implementations one authoritative
|
|
846
|
+
// owner and prevents accidental use-after-close during cleanup.
|
|
847
|
+
return { anchor, lease, root, rootDirectory: root, sessions, runs, staging, quarantine };
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
// A partially-created layout must not leave descriptors behind. Keep the
|
|
851
|
+
// original bootstrap error authoritative while making best-effort cleanup.
|
|
852
|
+
for (const directory of opened.reverse())
|
|
853
|
+
await directory.close().catch(() => undefined);
|
|
854
|
+
await root.close().catch(() => undefined);
|
|
855
|
+
throw error;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
/** Alias using the name from the storage design. */
|
|
859
|
+
export const initializeLeasedWorkflowStorage = initializePrivateLayout;
|
|
860
|
+
//# sourceMappingURL=private-root.js.map
|