@better-fs-tools/pi-durable 0.0.0-stage → 0.3.0
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/README.md +97 -2
- package/dist/extension.d.ts +65 -0
- package/dist/extension.js +146 -0
- package/dist/filesystem.d.ts +38 -0
- package/dist/filesystem.js +209 -0
- package/dist/host.d.ts +35 -0
- package/dist/host.js +31 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/policy.d.ts +44 -0
- package/dist/policy.js +201 -0
- package/dist/read-store.d.ts +26 -0
- package/dist/read-store.js +47 -0
- package/dist/runner.d.ts +26 -0
- package/dist/runner.js +126 -0
- package/dist/write.d.ts +13 -0
- package/dist/write.js +150 -0
- package/package.json +46 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createDurableFsTools } from "./extension.ts";
|
|
2
|
+
export type { DurableFsToolsApplyPatchOptions, DurableFsToolsBashOptions, DurableFsToolsEditOptions, DurableFsToolsOptions, DurableFsToolsReadOptions, DurableFsToolsWriteOptions, } from "./extension.ts";
|
|
3
|
+
export { executionEnvFileSystem } from "./filesystem.ts";
|
|
4
|
+
export type { ExecutionEnvFileSystem, ExecutionEnvFileSystemOptions } from "./filesystem.ts";
|
|
5
|
+
export { durableEnvFileSystem, durableEnvRunner } from "./host.ts";
|
|
6
|
+
export type { DurableCallFactory, DurableEnvFileSystemOptions, DurableEnvRunnerOptions, DurableFsHost, } from "./host.ts";
|
|
7
|
+
export type { EnvSymlinkPolicy } from "./policy.ts";
|
|
8
|
+
export { documentReadStore, ReadStateDoc } from "./read-store.ts";
|
|
9
|
+
export type { ReadStateDocument, ReadStoreApi } from "./read-store.ts";
|
|
10
|
+
export { executionEnvRunner } from "./runner.ts";
|
|
11
|
+
export type { ExecutionEnvRunnerOptions } from "./runner.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createDurableFsTools } from "./extension.js";
|
|
2
|
+
export { executionEnvFileSystem } from "./filesystem.js";
|
|
3
|
+
export { durableEnvFileSystem, durableEnvRunner } from "./host.js";
|
|
4
|
+
export { documentReadStore, ReadStateDoc } from "./read-store.js";
|
|
5
|
+
export { executionEnvRunner } from "./runner.js";
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord/context";
|
|
2
|
+
import type { FileError, FileSystem as EnvFileSystem } from "@earendil-works/pi-durable/env";
|
|
3
|
+
import type { FileSystemError, FileSystemRootOptions, NotAFileError } from "@better-fs-tools/fs";
|
|
4
|
+
export type EnvSymlinkPolicy = "follow-within-roots" | "reject";
|
|
5
|
+
export interface Roots {
|
|
6
|
+
readonly cwd: string;
|
|
7
|
+
readonly allowedRoots: readonly string[];
|
|
8
|
+
readonly denyRoots: readonly string[];
|
|
9
|
+
readonly symlinks: EnvSymlinkPolicy;
|
|
10
|
+
}
|
|
11
|
+
/** A path after the roots, the deny roots, and the symlink policy accepted it. */
|
|
12
|
+
export interface Located {
|
|
13
|
+
/** The real path: symbolic links resolved. For a missing file, its real parent and the name. */
|
|
14
|
+
readonly target: string;
|
|
15
|
+
readonly displayPath: string;
|
|
16
|
+
readonly exists: boolean;
|
|
17
|
+
/** Real paths of missing parent directories, outermost first. Empty when the target exists. */
|
|
18
|
+
readonly missingDirectories: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
/** The context of one operation: the call's context, also stopped by the operation's signal. */
|
|
21
|
+
export declare function contextFor(base: Context, signal: AbortSignal | undefined): Context;
|
|
22
|
+
export declare function resolveRoots(options: FileSystemRootOptions<EnvSymlinkPolicy, "none">, envCwd: string): Roots;
|
|
23
|
+
/** The real paths of the configured roots, found once for each filesystem. */
|
|
24
|
+
export interface RealRoots {
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
readonly allowedRoots: readonly string[];
|
|
27
|
+
readonly denyRoots: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function realRoots(env: EnvFileSystem, roots: Roots, ctx: Context): Promise<RealRoots>;
|
|
30
|
+
/**
|
|
31
|
+
* Checks the requested path against the configured roots, then resolves its
|
|
32
|
+
* symbolic links and checks the real path against the real roots.
|
|
33
|
+
*/
|
|
34
|
+
export declare function locate(env: EnvFileSystem, roots: Roots, real: RealRoots, requested: unknown, ctx: Context): Promise<Located>;
|
|
35
|
+
export declare function displayPath(cwd: string, target: string): string;
|
|
36
|
+
export declare function notAFile(kind: NotAFileError["kind"], located: Located): NotAFileError;
|
|
37
|
+
/** Maps an execution environment FileError to a filesystem error. */
|
|
38
|
+
export declare function fromFileError(error: FileError, phase: string): FileSystemError;
|
|
39
|
+
export declare class AdapterRefusal extends Error {
|
|
40
|
+
readonly error: FileSystemError;
|
|
41
|
+
constructor(error: FileSystemError);
|
|
42
|
+
}
|
|
43
|
+
export declare function refuse(error: FileSystemError): AdapterRefusal;
|
|
44
|
+
export declare function toFileSystemError(error: unknown): FileSystemError;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { withAbortSignal } from "@earendil-works/chord/context";
|
|
2
|
+
import { containsPosix, posixPaths, resolvePosix } from "@better-fs-tools/fs";
|
|
3
|
+
/** The context of one operation: the call's context, also stopped by the operation's signal. */
|
|
4
|
+
export function contextFor(base, signal) {
|
|
5
|
+
return signal === undefined ? base : withAbortSignal(signal, base);
|
|
6
|
+
}
|
|
7
|
+
export function resolveRoots(options, envCwd) {
|
|
8
|
+
const base = absolutePath(options.cwd ?? envCwd, "cwd");
|
|
9
|
+
const allowed = options.allowedRoots;
|
|
10
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
11
|
+
throw new TypeError("allowedRoots must be a non-empty array");
|
|
12
|
+
}
|
|
13
|
+
if (options.denyRoots !== undefined && !Array.isArray(options.denyRoots)) {
|
|
14
|
+
throw new TypeError("denyRoots must be an array");
|
|
15
|
+
}
|
|
16
|
+
const symlinks = options.symlinks ?? "follow-within-roots";
|
|
17
|
+
if (symlinks !== "follow-within-roots" && symlinks !== "reject") {
|
|
18
|
+
throw new TypeError('symlinks must be "follow-within-roots" or "reject"');
|
|
19
|
+
}
|
|
20
|
+
if (options.identity !== undefined && options.identity !== "none") {
|
|
21
|
+
throw new TypeError('identity must be "none": an execution environment has no file identity');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
cwd: base,
|
|
25
|
+
allowedRoots: Object.freeze(allowed.map((root) => configuredRoot(base, root))),
|
|
26
|
+
denyRoots: Object.freeze((options.denyRoots ?? []).map((root) => configuredRoot(base, root))),
|
|
27
|
+
symlinks,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function absolutePath(value, label) {
|
|
31
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.includes("\0")) {
|
|
32
|
+
throw new TypeError(`${label} must be an absolute POSIX path without NUL`);
|
|
33
|
+
}
|
|
34
|
+
return resolvePosix("/", value);
|
|
35
|
+
}
|
|
36
|
+
function configuredRoot(base, value) {
|
|
37
|
+
if (typeof value !== "string" || value === "" || value.includes("\0")) {
|
|
38
|
+
throw new TypeError("filesystem roots must be non-empty POSIX paths without NUL");
|
|
39
|
+
}
|
|
40
|
+
return resolvePosix(base, value);
|
|
41
|
+
}
|
|
42
|
+
export async function realRoots(env, roots, ctx) {
|
|
43
|
+
const real = async (path) => {
|
|
44
|
+
const result = await env.canonicalPath(path, ctx);
|
|
45
|
+
if (result.ok)
|
|
46
|
+
return resolvePosix("/", result.value);
|
|
47
|
+
// A root that does not exist yet keeps its configured path.
|
|
48
|
+
if (result.error.code === "not_found" || result.error.code === "not_supported")
|
|
49
|
+
return path;
|
|
50
|
+
throw refuse(fromFileError(result.error, "canonicalPath"));
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
cwd: await real(roots.cwd),
|
|
54
|
+
allowedRoots: await Promise.all(roots.allowedRoots.map(real)),
|
|
55
|
+
denyRoots: await Promise.all(roots.denyRoots.map(real)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Checks the requested path against the configured roots, then resolves its
|
|
60
|
+
* symbolic links and checks the real path against the real roots.
|
|
61
|
+
*/
|
|
62
|
+
export async function locate(env, roots, real, requested, ctx) {
|
|
63
|
+
if (typeof requested !== "string" || requested === "") {
|
|
64
|
+
throw refuse({ reason: "io", detail: "the requested path must be a non-empty string" });
|
|
65
|
+
}
|
|
66
|
+
if (requested.includes("\0")) {
|
|
67
|
+
throw refuse({ reason: "dangerous-path", detail: "the path contains NUL" });
|
|
68
|
+
}
|
|
69
|
+
const lexical = resolvePosix(roots.cwd, requested);
|
|
70
|
+
checkRoots(roots.denyRoots, roots.allowedRoots, lexical);
|
|
71
|
+
const resolved = await resolveReal(env, lexical, ctx);
|
|
72
|
+
const root = innermost(roots.allowedRoots, lexical);
|
|
73
|
+
const realRoot = real.allowedRoots[roots.allowedRoots.indexOf(root)];
|
|
74
|
+
if (roots.symlinks === "reject") {
|
|
75
|
+
const expected = posixJoin(realRoot, lexical.slice(root === "/" ? 1 : root.length + 1));
|
|
76
|
+
if (resolved.target !== expected) {
|
|
77
|
+
throw refuse({
|
|
78
|
+
reason: "denied",
|
|
79
|
+
detail: "the path contains a symbolic link and policy rejects symlinks",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
checkRoots(real.denyRoots, real.allowedRoots, resolved.target);
|
|
84
|
+
return {
|
|
85
|
+
target: resolved.target,
|
|
86
|
+
displayPath: displayPath(real.cwd, resolved.target),
|
|
87
|
+
exists: resolved.exists,
|
|
88
|
+
missingDirectories: resolved.missingDirectories,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function checkRoots(denyRoots, allowedRoots, path) {
|
|
92
|
+
const denied = denyRoots.find((root) => containsPosix(root, path));
|
|
93
|
+
if (denied !== undefined)
|
|
94
|
+
throw refuse({ reason: "dangerous-path", detail: denied });
|
|
95
|
+
if (innermost(allowedRoots, path) === null)
|
|
96
|
+
throw refuse({ reason: "outside-allowed-roots" });
|
|
97
|
+
}
|
|
98
|
+
function innermost(roots, path) {
|
|
99
|
+
let found = null;
|
|
100
|
+
for (const root of roots) {
|
|
101
|
+
if (containsPosix(root, path) && (found === null || root.length > found.length))
|
|
102
|
+
found = root;
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
function posixJoin(dir, rest) {
|
|
107
|
+
return rest === "" ? dir : posixPaths.join(dir, rest);
|
|
108
|
+
}
|
|
109
|
+
async function resolveReal(env, lexical, ctx) {
|
|
110
|
+
const whole = await env.canonicalPath(lexical, ctx);
|
|
111
|
+
if (whole.ok)
|
|
112
|
+
return { target: resolvePosix("/", whole.value), exists: true, missingDirectories: [] };
|
|
113
|
+
if (whole.error.code !== "not_found")
|
|
114
|
+
throw refuse(fromFileError(whole.error, "canonicalPath"));
|
|
115
|
+
// Walk up to the nearest existing ancestor and join the missing names to its real path.
|
|
116
|
+
const missing = [];
|
|
117
|
+
let current = lexical;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const parent = posixPaths.dirname(current);
|
|
120
|
+
missing.unshift(posixPaths.basename(current));
|
|
121
|
+
if (parent === current)
|
|
122
|
+
throw refuse({ reason: "not-found", detail: "no ancestor exists" });
|
|
123
|
+
const found = await env.canonicalPath(parent, ctx);
|
|
124
|
+
if (!found.ok) {
|
|
125
|
+
if (found.error.code !== "not_found")
|
|
126
|
+
throw refuse(fromFileError(found.error, "canonicalPath"));
|
|
127
|
+
current = parent;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const realParent = resolvePosix("/", found.value);
|
|
131
|
+
// A dangling symbolic link has no real path, and a write would follow it.
|
|
132
|
+
const first = posixJoin(realParent, missing[0]);
|
|
133
|
+
const entry = await env.fileInfo(first, ctx);
|
|
134
|
+
if (entry.ok) {
|
|
135
|
+
throw refuse({ reason: "denied", detail: "a path component is a dangling symbolic link" });
|
|
136
|
+
}
|
|
137
|
+
if (entry.error.code !== "not_found")
|
|
138
|
+
throw refuse(fromFileError(entry.error, "fileInfo"));
|
|
139
|
+
const paths = [];
|
|
140
|
+
let path = realParent;
|
|
141
|
+
for (const name of missing) {
|
|
142
|
+
path = posixJoin(path, name);
|
|
143
|
+
paths.push(path);
|
|
144
|
+
}
|
|
145
|
+
return { target: paths.at(-1), exists: false, missingDirectories: paths.slice(0, -1) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function displayPath(cwd, target) {
|
|
149
|
+
if (!containsPosix(cwd, target))
|
|
150
|
+
return target;
|
|
151
|
+
if (target === cwd)
|
|
152
|
+
return posixPaths.basename(target) || "/";
|
|
153
|
+
return target.slice(cwd === "/" ? 1 : cwd.length + 1);
|
|
154
|
+
}
|
|
155
|
+
export function notAFile(kind, located) {
|
|
156
|
+
return {
|
|
157
|
+
reason: "not-a-file",
|
|
158
|
+
kind,
|
|
159
|
+
target: { resolvedPath: located.target, displayPath: located.displayPath },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Maps an execution environment FileError to a filesystem error. */
|
|
163
|
+
export function fromFileError(error, phase) {
|
|
164
|
+
const cause = { code: error.code, phase };
|
|
165
|
+
switch (error.code) {
|
|
166
|
+
case "aborted":
|
|
167
|
+
return { reason: "aborted", cause };
|
|
168
|
+
case "not_found":
|
|
169
|
+
case "not_directory":
|
|
170
|
+
return { reason: "not-found", cause };
|
|
171
|
+
case "permission_denied":
|
|
172
|
+
return { reason: "permission-denied", cause };
|
|
173
|
+
case "is_directory":
|
|
174
|
+
return { reason: "not-a-file", kind: "directory", target: null, cause };
|
|
175
|
+
case "not_supported":
|
|
176
|
+
return { reason: "unsupported", cause };
|
|
177
|
+
case "invalid":
|
|
178
|
+
case "unknown":
|
|
179
|
+
return { reason: "io", detail: phase, cause };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export class AdapterRefusal extends Error {
|
|
183
|
+
error;
|
|
184
|
+
constructor(error) {
|
|
185
|
+
super(error.reason);
|
|
186
|
+
this.error = error;
|
|
187
|
+
this.name = "AdapterRefusal";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export function refuse(error) {
|
|
191
|
+
return new AdapterRefusal(error);
|
|
192
|
+
}
|
|
193
|
+
export function toFileSystemError(error) {
|
|
194
|
+
if (error instanceof AdapterRefusal)
|
|
195
|
+
return error.error;
|
|
196
|
+
return {
|
|
197
|
+
reason: "io",
|
|
198
|
+
detail: "the execution environment adapter failed",
|
|
199
|
+
cause: { code: error instanceof Error ? error.name.slice(0, 40) : "UNKNOWN" },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord/context";
|
|
2
|
+
import type { JsonObject, ToolExecutionApi } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { ReadStateStore } from "@better-fs-tools/read";
|
|
4
|
+
import type { DurableCallFactory } from "./host.ts";
|
|
5
|
+
/** One read record. `record` is null after a delete. A type alias, so it fits JsonObject. */
|
|
6
|
+
export type ReadStateDocument = {
|
|
7
|
+
record: JsonObject | null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The read records of a conversation: one document for each path, keyed by
|
|
11
|
+
* the environment id and the real path. A fork starts with the records that
|
|
12
|
+
* its parent had at the fork entry, so a model in the fork can change only
|
|
13
|
+
* what that history read.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ReadStateDoc: import("@earendil-works/pi-durable").RewindableConversationDocFamilyToken<ReadStateDocument, null>;
|
|
16
|
+
/** The parts of the tool API that the store uses. */
|
|
17
|
+
export type ReadStoreApi = Pick<ToolExecutionApi, "commit" | "snapshot" | "conversationId">;
|
|
18
|
+
/**
|
|
19
|
+
* A per-call read store in the conversation's ReadStateDoc documents. Pass it
|
|
20
|
+
* as `state` to createDurableFsTools(). It stays after a restart, so an edit
|
|
21
|
+
* after a crash needs no new read. The environment id keeps the records of
|
|
22
|
+
* two environments apart.
|
|
23
|
+
*/
|
|
24
|
+
export declare function documentReadStore(): DurableCallFactory<ReadStateStore>;
|
|
25
|
+
/** The read store of one call, over the tool API of the call. */
|
|
26
|
+
export declare function readStoreOf(api: ReadStoreApi, envId: string, context: Context): ReadStateStore;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineDocFamily } from "@earendil-works/pi-durable";
|
|
2
|
+
/**
|
|
3
|
+
* The read records of a conversation: one document for each path, keyed by
|
|
4
|
+
* the environment id and the real path. A fork starts with the records that
|
|
5
|
+
* its parent had at the fork entry, so a model in the fork can change only
|
|
6
|
+
* what that history read.
|
|
7
|
+
*/
|
|
8
|
+
export const ReadStateDoc = defineDocFamily({
|
|
9
|
+
kind: "better-fs-tools.read-state",
|
|
10
|
+
version: 1,
|
|
11
|
+
family: true,
|
|
12
|
+
scope: "conversation",
|
|
13
|
+
history: "rewindable",
|
|
14
|
+
fork: "asOf",
|
|
15
|
+
initial: () => ({ record: null }),
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* A per-call read store in the conversation's ReadStateDoc documents. Pass it
|
|
19
|
+
* as `state` to createDurableFsTools(). It stays after a restart, so an edit
|
|
20
|
+
* after a crash needs no new read. The environment id keeps the records of
|
|
21
|
+
* two environments apart.
|
|
22
|
+
*/
|
|
23
|
+
export function documentReadStore() {
|
|
24
|
+
return (call) => readStoreOf(call.host.api, call.host.env.id, call.host.context);
|
|
25
|
+
}
|
|
26
|
+
/** The read store of one call, over the tool API of the call. */
|
|
27
|
+
export function readStoreOf(api, envId, context) {
|
|
28
|
+
const conversationId = api.conversationId;
|
|
29
|
+
const keyOf = (key) => {
|
|
30
|
+
if (typeof key !== "string" || key === "") {
|
|
31
|
+
throw new TypeError("store key must be a non-empty string");
|
|
32
|
+
}
|
|
33
|
+
return `${envId}:${key}`;
|
|
34
|
+
};
|
|
35
|
+
const write = (key, record) => api.commit(async (tx) => {
|
|
36
|
+
const doc = await tx.doc(ReadStateDoc, conversationId, keyOf(key), null);
|
|
37
|
+
doc.record = record;
|
|
38
|
+
}, context);
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
async get(key) {
|
|
41
|
+
const doc = await api.snapshot(ReadStateDoc, conversationId, keyOf(key), context);
|
|
42
|
+
return (doc?.record ?? null);
|
|
43
|
+
},
|
|
44
|
+
put: (key, record) => write(key, record),
|
|
45
|
+
delete: (key) => write(key, null),
|
|
46
|
+
});
|
|
47
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord/context";
|
|
2
|
+
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
|
|
3
|
+
import type { CommandRunner } from "@better-fs-tools/shell";
|
|
4
|
+
export interface ExecutionEnvRunnerOptions {
|
|
5
|
+
/** The Chord context of the tool call. Cancelling it stops the command too. */
|
|
6
|
+
readonly context: Context;
|
|
7
|
+
/** Default `pi-durable:<env.id>`. The bash tool description shows it. */
|
|
8
|
+
readonly id?: string;
|
|
9
|
+
/** Default env.cwd. */
|
|
10
|
+
readonly cwd?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Default true: a command keeps the environment's own variables, such as
|
|
13
|
+
* PATH and HOME on a remote host, and the request's variables override them.
|
|
14
|
+
* false: the request's variables are the whole environment of the command.
|
|
15
|
+
*/
|
|
16
|
+
readonly inheritEnv?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Runs each command with the environment's exec(), through its shell: on the
|
|
20
|
+
* local machine for a NodeExecutionEnv, over SSH for pi-env's
|
|
21
|
+
* RemoteExecutionEnv. Output arrives as the environment reports it, stdout and
|
|
22
|
+
* stderr apart. A stop aborts the exec() call, and the environment kills the
|
|
23
|
+
* command's processes. By default the command also keeps the environment's
|
|
24
|
+
* own variables: see `inheritEnv`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function executionEnvRunner(env: ExecutionEnv, options: ExecutionEnvRunnerOptions): CommandRunner;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { withAbortSignal } from "@earendil-works/chord/context";
|
|
2
|
+
/**
|
|
3
|
+
* Runs each command with the environment's exec(), through its shell: on the
|
|
4
|
+
* local machine for a NodeExecutionEnv, over SSH for pi-env's
|
|
5
|
+
* RemoteExecutionEnv. Output arrives as the environment reports it, stdout and
|
|
6
|
+
* stderr apart. A stop aborts the exec() call, and the environment kills the
|
|
7
|
+
* command's processes. By default the command also keeps the environment's
|
|
8
|
+
* own variables: see `inheritEnv`.
|
|
9
|
+
*/
|
|
10
|
+
export function executionEnvRunner(env, options) {
|
|
11
|
+
if (env === null || typeof env !== "object" || typeof env.exec !== "function") {
|
|
12
|
+
throw new TypeError("executionEnvRunner needs a pi-durable ExecutionEnv");
|
|
13
|
+
}
|
|
14
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
15
|
+
throw new TypeError("executionEnvRunner options must be an object");
|
|
16
|
+
}
|
|
17
|
+
const { context, id = `pi-durable:${env.id}`, cwd = env.cwd, inheritEnv = true } = options;
|
|
18
|
+
if (context === null || typeof context !== "object" || typeof context.value !== "function") {
|
|
19
|
+
throw new TypeError("executionEnvRunner needs the Chord context of the call");
|
|
20
|
+
}
|
|
21
|
+
if (typeof id !== "string" || id === "")
|
|
22
|
+
throw new TypeError("id must be a non-empty string");
|
|
23
|
+
if (typeof inheritEnv !== "boolean")
|
|
24
|
+
throw new TypeError("inheritEnv must be a boolean");
|
|
25
|
+
if (typeof cwd !== "string" || !cwd.startsWith("/")) {
|
|
26
|
+
throw new TypeError("cwd must be an absolute POSIX path");
|
|
27
|
+
}
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
id,
|
|
30
|
+
cwd,
|
|
31
|
+
run: (request) => run(env, withAbortSignal(request.signal, context), request, inheritEnv),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function run(env, ctx, request, inheritEnv) {
|
|
35
|
+
const output = new ChunkQueue();
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
const exit = (async () => {
|
|
38
|
+
try {
|
|
39
|
+
const problem = await checkCwd(env, request.cwd, ctx);
|
|
40
|
+
if (problem !== null)
|
|
41
|
+
return { code: null, signal: null, error: problem };
|
|
42
|
+
const result = await env.exec(request.command, {
|
|
43
|
+
cwd: request.cwd,
|
|
44
|
+
env: { ...request.env },
|
|
45
|
+
inheritEnv,
|
|
46
|
+
onOutput: (text, _context, info) => {
|
|
47
|
+
if (text !== "")
|
|
48
|
+
output.push({ stream: info.stream, bytes: encoder.encode(text) });
|
|
49
|
+
},
|
|
50
|
+
}, ctx);
|
|
51
|
+
if (result.ok)
|
|
52
|
+
return { code: result.value.exitCode, signal: null };
|
|
53
|
+
if (result.error.code === "aborted" || request.signal.aborted) {
|
|
54
|
+
return { code: null, signal: "SIGTERM" };
|
|
55
|
+
}
|
|
56
|
+
if (result.error.code === "timeout")
|
|
57
|
+
return { code: null, signal: "SIGKILL" };
|
|
58
|
+
return {
|
|
59
|
+
code: null,
|
|
60
|
+
signal: null,
|
|
61
|
+
error: { reason: "spawn-failed", detail: result.error.code },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (request.signal.aborted)
|
|
66
|
+
return { code: null, signal: "SIGTERM" };
|
|
67
|
+
const detail = error instanceof Error ? error.name : "unknown";
|
|
68
|
+
return { code: null, signal: null, error: { reason: "spawn-failed", detail } };
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
output.end();
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
return { output, exit };
|
|
75
|
+
}
|
|
76
|
+
async function checkCwd(env, cwd, ctx) {
|
|
77
|
+
const info = await env.fileInfo(cwd, ctx);
|
|
78
|
+
if (info.ok) {
|
|
79
|
+
if (info.value.kind === "directory")
|
|
80
|
+
return null;
|
|
81
|
+
if (info.value.kind === "symlink") {
|
|
82
|
+
// fileInfo does not follow a link: ask the link's target.
|
|
83
|
+
const real = await env.canonicalPath(cwd, ctx);
|
|
84
|
+
if (!real.ok)
|
|
85
|
+
return { reason: "cwd-not-found" };
|
|
86
|
+
return checkCwd(env, real.value, ctx);
|
|
87
|
+
}
|
|
88
|
+
return { reason: "cwd-not-a-directory" };
|
|
89
|
+
}
|
|
90
|
+
return { reason: "cwd-not-found" };
|
|
91
|
+
}
|
|
92
|
+
/** Output chunks in arrival order, for one reader. */
|
|
93
|
+
class ChunkQueue {
|
|
94
|
+
chunks = [];
|
|
95
|
+
ended = false;
|
|
96
|
+
wake = null;
|
|
97
|
+
push(chunk) {
|
|
98
|
+
if (this.ended)
|
|
99
|
+
return;
|
|
100
|
+
this.chunks.push(chunk);
|
|
101
|
+
this.notify();
|
|
102
|
+
}
|
|
103
|
+
end() {
|
|
104
|
+
this.ended = true;
|
|
105
|
+
this.notify();
|
|
106
|
+
}
|
|
107
|
+
notify() {
|
|
108
|
+
const wake = this.wake;
|
|
109
|
+
this.wake = null;
|
|
110
|
+
wake?.();
|
|
111
|
+
}
|
|
112
|
+
async *[Symbol.asyncIterator]() {
|
|
113
|
+
for (;;) {
|
|
114
|
+
const next = this.chunks.shift();
|
|
115
|
+
if (next !== undefined) {
|
|
116
|
+
yield next;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (this.ended)
|
|
120
|
+
return;
|
|
121
|
+
await new Promise((resolve) => {
|
|
122
|
+
this.wake = resolve;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/dist/write.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord/context";
|
|
2
|
+
import type { ExecutionEnv, FileInfo as EnvFileInfo } from "@earendil-works/pi-durable/env";
|
|
3
|
+
import type { MutateOptions, MutationOutcome, OpenOptions, StatOutcome, WriteCapabilities, WriteOptions } from "@better-fs-tools/fs";
|
|
4
|
+
import type { Located } from "./policy.ts";
|
|
5
|
+
/** The weak change token of a file: its size and modification time. */
|
|
6
|
+
export declare function envVersion(info: EnvFileInfo): string;
|
|
7
|
+
export interface EnvWrites {
|
|
8
|
+
readonly writeCapabilities: WriteCapabilities;
|
|
9
|
+
stat(path: string, options?: OpenOptions): Promise<StatOutcome>;
|
|
10
|
+
write(path: string, bytes: Uint8Array, options: WriteOptions): Promise<MutationOutcome>;
|
|
11
|
+
remove(path: string, options: MutateOptions): Promise<MutationOutcome>;
|
|
12
|
+
}
|
|
13
|
+
export declare function envWrites(env: ExecutionEnv, contextOf: (signal: AbortSignal | undefined) => Context, resolve: (requested: unknown, ctx: Context) => Promise<Located>): EnvWrites;
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { fromFileError, notAFile, refuse, toFileSystemError } from "./policy.js";
|
|
2
|
+
const WRITE_CAPABILITIES = Object.freeze({ compareAndSwap: false });
|
|
3
|
+
/** The weak change token of a file: its size and modification time. */
|
|
4
|
+
export function envVersion(info) {
|
|
5
|
+
return `env:${info.size}:${info.mtimeMs}`;
|
|
6
|
+
}
|
|
7
|
+
export function envWrites(env, contextOf, resolve) {
|
|
8
|
+
/** The file at a located path, refusing anything that is not a regular file. */
|
|
9
|
+
const current = async (located, ctx) => {
|
|
10
|
+
if (!located.exists)
|
|
11
|
+
return { exists: false };
|
|
12
|
+
const result = await env.fileInfo(located.target, ctx);
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
if (result.error.code === "not_found")
|
|
15
|
+
return { exists: false };
|
|
16
|
+
throw refuse(fromFileError(result.error, "fileInfo"));
|
|
17
|
+
}
|
|
18
|
+
if (result.value.kind !== "file") {
|
|
19
|
+
throw refuse(notAFile(result.value.kind === "directory" ? "directory" : "other", located));
|
|
20
|
+
}
|
|
21
|
+
return { exists: true, info: result.value };
|
|
22
|
+
};
|
|
23
|
+
const fileStat = (located, file) => file.exists
|
|
24
|
+
? {
|
|
25
|
+
exists: true,
|
|
26
|
+
resolvedPath: located.target,
|
|
27
|
+
displayPath: located.displayPath,
|
|
28
|
+
size: file.info.size,
|
|
29
|
+
mtimeMs: file.info.mtimeMs,
|
|
30
|
+
identity: null,
|
|
31
|
+
version: envVersion(file.info),
|
|
32
|
+
mode: null,
|
|
33
|
+
hardLinks: null,
|
|
34
|
+
}
|
|
35
|
+
: {
|
|
36
|
+
exists: false,
|
|
37
|
+
resolvedPath: located.target,
|
|
38
|
+
displayPath: located.displayPath,
|
|
39
|
+
missingDirectories: located.missingDirectories,
|
|
40
|
+
};
|
|
41
|
+
const mutated = (located, info, created) => ({
|
|
42
|
+
resolvedPath: located.target,
|
|
43
|
+
displayPath: located.displayPath,
|
|
44
|
+
version: info === null ? null : envVersion(info),
|
|
45
|
+
identity: null,
|
|
46
|
+
size: info?.size ?? null,
|
|
47
|
+
createdDirectories: created,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
writeCapabilities: WRITE_CAPABILITIES,
|
|
51
|
+
async stat(requested, options = {}) {
|
|
52
|
+
if (options.signal?.aborted)
|
|
53
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
54
|
+
const ctx = contextOf(options.signal);
|
|
55
|
+
try {
|
|
56
|
+
const located = await resolve(requested, ctx);
|
|
57
|
+
return { ok: true, stat: fileStat(located, await current(located, ctx)) };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return { ok: false, error: toFileSystemError(error) };
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async write(requested, bytes, options) {
|
|
64
|
+
if (options.signal?.aborted)
|
|
65
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
66
|
+
const ctx = contextOf(options.signal);
|
|
67
|
+
try {
|
|
68
|
+
const located = await resolve(requested, ctx);
|
|
69
|
+
const file = await current(located, ctx);
|
|
70
|
+
refuseConflict(file, options.precondition);
|
|
71
|
+
const missing = file.exists ? [] : located.missingDirectories;
|
|
72
|
+
if (missing.length > 0 && !options.createParents) {
|
|
73
|
+
throw refuse({ reason: "not-found", detail: "the parent directory does not exist" });
|
|
74
|
+
}
|
|
75
|
+
const created = [];
|
|
76
|
+
for (const directory of missing) {
|
|
77
|
+
const made = await env.createDir(directory, { recursive: false }, ctx);
|
|
78
|
+
if (!made.ok)
|
|
79
|
+
throw refuse(fromFileError(made.error, "createDir"));
|
|
80
|
+
created.push(directory);
|
|
81
|
+
}
|
|
82
|
+
if (ctx.abortSignal?.aborted)
|
|
83
|
+
throw refuse({ reason: "aborted" });
|
|
84
|
+
const written = await env.writeFile(located.target, bytes, ctx);
|
|
85
|
+
if (!written.ok)
|
|
86
|
+
throw refuse(fromFileError(written.error, "writeFile"));
|
|
87
|
+
const after = await env.fileInfo(located.target, ctx);
|
|
88
|
+
if (!after.ok || after.value.kind !== "file") {
|
|
89
|
+
throw refuse({ reason: "io", detail: "the file was not there after the write" });
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, file: mutated(located, after.value, created) };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return { ok: false, error: toMutationError(error) };
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
async remove(requested, options) {
|
|
98
|
+
if (options.signal?.aborted)
|
|
99
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
100
|
+
const ctx = contextOf(options.signal);
|
|
101
|
+
try {
|
|
102
|
+
const located = await resolve(requested, ctx);
|
|
103
|
+
const file = await current(located, ctx);
|
|
104
|
+
refuseConflict(file, options.precondition);
|
|
105
|
+
if (!file.exists)
|
|
106
|
+
throw refuse({ reason: "not-found" });
|
|
107
|
+
if (ctx.abortSignal?.aborted)
|
|
108
|
+
throw refuse({ reason: "aborted" });
|
|
109
|
+
const removed = await env.remove(located.target, { recursive: false }, ctx);
|
|
110
|
+
if (!removed.ok)
|
|
111
|
+
throw refuse(fromFileError(removed.error, "remove"));
|
|
112
|
+
return { ok: true, file: mutated(located, null, []) };
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
return { ok: false, error: toMutationError(error) };
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function refuseConflict(file, precondition) {
|
|
121
|
+
switch (precondition.kind) {
|
|
122
|
+
case "absent":
|
|
123
|
+
if (file.exists)
|
|
124
|
+
throw new WriteRefusal({ reason: "exists" });
|
|
125
|
+
return;
|
|
126
|
+
case "version":
|
|
127
|
+
if (!file.exists || envVersion(file.info) !== precondition.version) {
|
|
128
|
+
throw new WriteRefusal({ reason: "changed" });
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
case "any":
|
|
132
|
+
return;
|
|
133
|
+
default:
|
|
134
|
+
throw refuse({ reason: "io", detail: "unknown precondition" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
class WriteRefusal extends Error {
|
|
138
|
+
error;
|
|
139
|
+
constructor(error) {
|
|
140
|
+
super(error.reason);
|
|
141
|
+
this.error = error;
|
|
142
|
+
this.name = "WriteRefusal";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// The environment reports no disk-full or read-only code, so those failures are "io".
|
|
146
|
+
function toMutationError(error) {
|
|
147
|
+
if (error instanceof WriteRefusal)
|
|
148
|
+
return error.error;
|
|
149
|
+
return toFileSystemError(error);
|
|
150
|
+
}
|