@relayflows/sdk 2.0.28 → 2.0.30
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/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +10 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-step-index.d.ts +6 -0
- package/dist/authored-step-index.d.ts.map +1 -1
- package/dist/authored-step-index.js +42 -14
- package/dist/authored-step-index.js.map +1 -1
- package/dist/bundle-extensions.d.ts.map +1 -1
- package/dist/bundle-extensions.js +4 -7
- package/dist/bundle-extensions.js.map +1 -1
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +41 -7
- package/dist/bundle.js.map +1 -1
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +18 -6
- package/dist/canonical.js.map +1 -1
- package/dist/cli/status.d.ts +12 -0
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +39 -1
- package/dist/cli/status.js.map +1 -1
- package/dist/flow-extension-compat.d.ts.map +1 -1
- package/dist/flow-extension-compat.js +26 -8
- package/dist/flow-extension-compat.js.map +1 -1
- package/dist/flow-extension-loader.d.ts +7 -0
- package/dist/flow-extension-loader.d.ts.map +1 -1
- package/dist/flow-extension-loader.js +48 -22
- package/dist/flow-extension-loader.js.map +1 -1
- package/dist/fs-descriptor.d.ts +17 -0
- package/dist/fs-descriptor.d.ts.map +1 -0
- package/dist/fs-descriptor.js +96 -0
- package/dist/fs-descriptor.js.map +1 -0
- package/dist/hosted-base-snapshot.d.ts +25 -0
- package/dist/hosted-base-snapshot.d.ts.map +1 -0
- package/dist/hosted-base-snapshot.js +278 -0
- package/dist/hosted-base-snapshot.js.map +1 -0
- package/dist/hosted-extension-declarations.d.ts +10 -0
- package/dist/hosted-extension-declarations.d.ts.map +1 -0
- package/dist/hosted-extension-declarations.js +272 -0
- package/dist/hosted-extension-declarations.js.map +1 -0
- package/dist/hosted-extension-isolation.d.ts +82 -0
- package/dist/hosted-extension-isolation.d.ts.map +1 -0
- package/dist/hosted-extension-isolation.js +322 -0
- package/dist/hosted-extension-isolation.js.map +1 -0
- package/dist/hosted-extension-manifest.d.ts +9 -0
- package/dist/hosted-extension-manifest.d.ts.map +1 -0
- package/dist/hosted-extension-manifest.js +235 -0
- package/dist/hosted-extension-manifest.js.map +1 -0
- package/dist/hosted-extension-protocol.d.ts +16 -0
- package/dist/hosted-extension-protocol.d.ts.map +1 -0
- package/dist/hosted-extension-protocol.js +224 -0
- package/dist/hosted-extension-protocol.js.map +1 -0
- package/dist/hosted-extension-runtime.d.ts +30 -0
- package/dist/hosted-extension-runtime.d.ts.map +1 -0
- package/dist/hosted-extension-runtime.js +167 -0
- package/dist/hosted-extension-runtime.js.map +1 -0
- package/dist/hosted-extension-sandbox-source.d.ts +9 -0
- package/dist/hosted-extension-sandbox-source.d.ts.map +1 -0
- package/dist/hosted-extension-sandbox-source.js +95 -0
- package/dist/hosted-extension-sandbox-source.js.map +1 -0
- package/dist/hosted-extension-sandbox.d.ts +39 -0
- package/dist/hosted-extension-sandbox.d.ts.map +1 -0
- package/dist/hosted-extension-sandbox.js +418 -0
- package/dist/hosted-extension-sandbox.js.map +1 -0
- package/dist/hosted-project.d.ts +3 -0
- package/dist/hosted-project.d.ts.map +1 -0
- package/dist/hosted-project.js +19 -0
- package/dist/hosted-project.js.map +1 -0
- package/dist/hosted-promise-safety.d.ts +12 -0
- package/dist/hosted-promise-safety.d.ts.map +1 -0
- package/dist/hosted-promise-safety.js +63 -0
- package/dist/hosted-promise-safety.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/intrinsic-array.d.ts +3 -0
- package/dist/intrinsic-array.d.ts.map +1 -0
- package/dist/intrinsic-array.js +14 -0
- package/dist/intrinsic-array.js.map +1 -0
- package/dist/json-value.d.ts +6 -1
- package/dist/json-value.d.ts.map +1 -1
- package/dist/json-value.js +155 -36
- package/dist/json-value.js.map +1 -1
- package/dist/llm-worker.d.ts +9 -0
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +15 -1
- package/dist/llm-worker.js.map +1 -1
- package/dist/plugin-store.d.ts +8 -2
- package/dist/plugin-store.d.ts.map +1 -1
- package/dist/plugin-store.js +233 -56
- package/dist/plugin-store.js.map +1 -1
- package/dist/semver-range.d.ts.map +1 -1
- package/dist/semver-range.js +27 -15
- package/dist/semver-range.js.map +1 -1
- package/package.json +2 -2
- package/src/authored-flow-executor.ts +10 -1
- package/src/authored-step-index.ts +46 -12
- package/src/bundle-extensions.ts +6 -5
- package/src/bundle.ts +52 -7
- package/src/canonical.ts +20 -6
- package/src/cli/status.ts +50 -1
- package/src/flow-extension-compat.ts +32 -7
- package/src/flow-extension-loader.ts +56 -21
- package/src/fs-descriptor.ts +127 -0
- package/src/hosted-base-snapshot.ts +351 -0
- package/src/hosted-extension-declarations.ts +387 -0
- package/src/hosted-extension-isolation.ts +457 -0
- package/src/hosted-extension-manifest.ts +249 -0
- package/src/hosted-extension-protocol.ts +262 -0
- package/src/hosted-extension-runtime.ts +266 -0
- package/src/hosted-extension-sandbox-source.ts +94 -0
- package/src/hosted-extension-sandbox.ts +489 -0
- package/src/hosted-project.ts +18 -0
- package/src/hosted-promise-safety.ts +68 -0
- package/src/index.ts +18 -0
- package/src/intrinsic-array.ts +14 -0
- package/src/json-value.ts +184 -32
- package/src/llm-worker.ts +15 -1
- package/src/plugin-store.ts +323 -56
- package/src/semver-range.ts +39 -14
package/src/bundle-extensions.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { safePath, sha256 } from './bundle.js';
|
|
|
5
5
|
import { findPluginProject } from './plugin-loader.js';
|
|
6
6
|
import { parsePluginLock, readPluginLock, reconcileDeclaredExtensions, type PluginLock } from './plugin-lock.js';
|
|
7
7
|
import { PluginError } from './plugin-manifest.js';
|
|
8
|
-
import { pluginStoreDirectory, readStoredPluginFiles
|
|
8
|
+
import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js';
|
|
9
9
|
|
|
10
10
|
const EMPTY_LOCK: PluginLock = Object.freeze({ version: 2, plugins: Object.freeze([]) });
|
|
11
11
|
|
|
@@ -50,10 +50,11 @@ export async function verifyBundlePluginLock(bundle: string): Promise<void> {
|
|
|
50
50
|
throw new Error(`lockfile.json: plugin name ${entry.name} is not a safe path component`);
|
|
51
51
|
}
|
|
52
52
|
const directory = join(bundle, 'plugins', entry.name);
|
|
53
|
-
await
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
const stored = await readStoredPluginFiles(directory, entry.digest);
|
|
54
|
+
const pluginManifest = stored.find(file => file.path === 'flows-plugin.json')?.data;
|
|
55
|
+
if (pluginManifest === undefined) {
|
|
56
|
+
throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`);
|
|
57
|
+
}
|
|
57
58
|
if (sha256(pluginManifest) !== entry.manifestSha256) {
|
|
58
59
|
throw new Error(`lockfile.json: plugin ${entry.name} flows-plugin.json does not match the lockfile manifest hash`);
|
|
59
60
|
}
|
package/src/bundle.ts
CHANGED
|
@@ -2,6 +2,27 @@ import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verif
|
|
|
2
2
|
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { canonicalize } from './canonical.js';
|
|
5
|
+
import { appendIntrinsicArray } from './intrinsic-array.js';
|
|
6
|
+
|
|
7
|
+
type Hash = ReturnType<typeof createHash>;
|
|
8
|
+
const CREATE_HASH = createHash;
|
|
9
|
+
const HASH_UPDATE = Function.prototype.call.bind(CREATE_HASH('sha256').update) as (
|
|
10
|
+
hash: Hash,
|
|
11
|
+
data: Uint8Array | string,
|
|
12
|
+
) => Hash;
|
|
13
|
+
const HASH_DIGEST = Function.prototype.call.bind(CREATE_HASH('sha256').digest) as (
|
|
14
|
+
hash: Hash,
|
|
15
|
+
encoding: 'hex',
|
|
16
|
+
) => string;
|
|
17
|
+
const STRING_INCLUDES = Function.prototype.call.bind(String.prototype.includes) as (
|
|
18
|
+
value: string, search: string,
|
|
19
|
+
) => boolean;
|
|
20
|
+
const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as (
|
|
21
|
+
value: string, separator: string,
|
|
22
|
+
) => string[];
|
|
23
|
+
const TYPED_ARRAY_LENGTH = Function.prototype.call.bind(
|
|
24
|
+
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype) as object, 'length')!.get!,
|
|
25
|
+
) as (value: Uint8Array) => number;
|
|
5
26
|
|
|
6
27
|
export interface BundleEntry { path: string; sha256: string; bytes: number }
|
|
7
28
|
export interface BundleFile { path: string; data: Uint8Array | string; executable?: boolean }
|
|
@@ -15,14 +36,21 @@ export interface BundleOptions {
|
|
|
15
36
|
}
|
|
16
37
|
|
|
17
38
|
export function sha256(data: Uint8Array | string): string {
|
|
18
|
-
|
|
39
|
+
const hash = CREATE_HASH('sha256');
|
|
40
|
+
HASH_UPDATE(hash, data);
|
|
41
|
+
return HASH_DIGEST(hash, 'hex');
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
/** A bundle-relative path: no empty, `.`, or `..` component, no backslash, NUL, or drive colon. */
|
|
22
45
|
export function safePath(path: string): boolean {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
if (path.length === 0 || STRING_INCLUDES(path, '\\') || STRING_INCLUDES(path, '\0')
|
|
47
|
+
|| STRING_INCLUDES(path, ':')) return false;
|
|
48
|
+
const parts = STRING_SPLIT(path, '/');
|
|
49
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
50
|
+
const part = parts[index]!;
|
|
51
|
+
if (part === '' || part === '.' || part === '..') return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
/**
|
|
@@ -31,9 +59,26 @@ export function safePath(path: string): boolean {
|
|
|
31
59
|
* thing everywhere: the digest of `[{bytes,path,sha256}]`, sorted by path.
|
|
32
60
|
*/
|
|
33
61
|
export function payloadManifest(files: readonly { path: string; data: Uint8Array }[]): string {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
const sorted: { path: string; data: Uint8Array }[] = [];
|
|
63
|
+
for (let index = 0; index < files.length; index += 1) appendIntrinsicArray(sorted, files[index]!);
|
|
64
|
+
// Do not consult mutable Array prototype methods here. Authored base code
|
|
65
|
+
// executes in this process before hosted artifact staging, so a live
|
|
66
|
+
// sort/map lookup would let it substitute bytes or manifest records.
|
|
67
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
68
|
+
const current = sorted[index]!;
|
|
69
|
+
let position = index;
|
|
70
|
+
while (position > 0 && sorted[position - 1]!.path > current.path) {
|
|
71
|
+
sorted[position] = sorted[position - 1]!;
|
|
72
|
+
position -= 1;
|
|
73
|
+
}
|
|
74
|
+
sorted[position] = current;
|
|
75
|
+
}
|
|
76
|
+
const entries: { path: string; sha256: string; bytes: number }[] = [];
|
|
77
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
78
|
+
const file = sorted[index]!;
|
|
79
|
+
appendIntrinsicArray(entries, { path: file.path, sha256: sha256(file.data), bytes: TYPED_ARRAY_LENGTH(file.data) });
|
|
80
|
+
}
|
|
81
|
+
return canonicalize(entries);
|
|
37
82
|
}
|
|
38
83
|
|
|
39
84
|
/** Manifest and identity are envelopes, excluded to avoid circular hashing. */
|
package/src/canonical.ts
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { snapshotJsonValue, type JsonValue } from './json-value.js';
|
|
12
|
+
import { appendIntrinsicArray } from './intrinsic-array.js';
|
|
13
|
+
|
|
14
|
+
const ARRAY_IS_ARRAY = Array.isArray;
|
|
15
|
+
const ARRAY_JOIN = Function.prototype.call.bind(Array.prototype.join) as (
|
|
16
|
+
array: readonly string[], separator?: string,
|
|
17
|
+
) => string;
|
|
18
|
+
const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as <T>(array: T[]) => T[];
|
|
19
|
+
const JSON_STRINGIFY = JSON.stringify;
|
|
20
|
+
const OBJECT_KEYS = Object.keys;
|
|
12
21
|
|
|
13
22
|
/**
|
|
14
23
|
* Serialize a value as canonical JSON: object keys sorted recursively,
|
|
@@ -42,16 +51,21 @@ export function specHash(spec: unknown): string {
|
|
|
42
51
|
*/
|
|
43
52
|
function serialize(value: JsonValue): string {
|
|
44
53
|
if (value === null || typeof value !== 'object') {
|
|
45
|
-
return
|
|
54
|
+
return JSON_STRINGIFY(value);
|
|
46
55
|
}
|
|
47
|
-
if (
|
|
48
|
-
|
|
56
|
+
if (ARRAY_IS_ARRAY(value)) {
|
|
57
|
+
const items: string[] = [];
|
|
58
|
+
for (let index = 0; index < value.length; index += 1) appendIntrinsicArray(items, serialize(value[index]!));
|
|
59
|
+
return '[' + ARRAY_JOIN(items, ',') + ']';
|
|
49
60
|
}
|
|
50
61
|
const parts: string[] = [];
|
|
51
|
-
|
|
62
|
+
const keys = OBJECT_KEYS(value);
|
|
63
|
+
ARRAY_SORT(keys);
|
|
64
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
65
|
+
const key = keys[index]!;
|
|
52
66
|
const child = value[key];
|
|
53
67
|
if (child === undefined) continue;
|
|
54
|
-
parts
|
|
68
|
+
appendIntrinsicArray(parts, JSON_STRINGIFY(key) + ':' + serialize(child));
|
|
55
69
|
}
|
|
56
|
-
return '{' + parts
|
|
70
|
+
return '{' + ARRAY_JOIN(parts, ',') + '}';
|
|
57
71
|
}
|
package/src/cli/status.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// sandbox, its listener) is Cloud's to answer, and this verb does not guess.
|
|
10
10
|
|
|
11
11
|
import type { CliIo } from '../cli.js';
|
|
12
|
+
import { AUTHORED_STEP_STREAM, foldAuthoredStepRecords, type AuthoredStepRecord } from '../authored-step-index.js';
|
|
12
13
|
import { canonicalize } from '../canonical.js';
|
|
13
14
|
import { DEFAULT_DATA_DIR } from '../daemon-connection.js';
|
|
14
15
|
import { JournalReadError, walkJournal, type JournalEvent } from '../journal-client.js';
|
|
@@ -179,7 +180,14 @@ export async function runStatus(args: StatusArgs, io: CliIo, options: StatusOpti
|
|
|
179
180
|
|
|
180
181
|
const presented = present(view, thisStep, tails, env);
|
|
181
182
|
if (args.json) {
|
|
182
|
-
|
|
183
|
+
const authored = authoredSteps(taken.events, env);
|
|
184
|
+
io.stdout(canonicalize({
|
|
185
|
+
v: 1, ...presented,
|
|
186
|
+
// Additive: only an authored root journals this stream, so every other
|
|
187
|
+
// run's JSON is byte-for-byte what it was.
|
|
188
|
+
...(authored.length === 0 ? {} : { authored_steps: authored }),
|
|
189
|
+
partial: taken.partial,
|
|
190
|
+
}));
|
|
183
191
|
} else {
|
|
184
192
|
for (const line of renderText(presented, taken.partial, args.tail !== undefined)) io.stdout(line);
|
|
185
193
|
}
|
|
@@ -187,6 +195,47 @@ export async function runStatus(args: StatusArgs, io: CliIo, options: StatusOpti
|
|
|
187
195
|
return taken.partial.length === 0 ? 0 : 1;
|
|
188
196
|
}
|
|
189
197
|
|
|
198
|
+
/** One authored step as the root's `authored-steps` index names it. */
|
|
199
|
+
export interface PresentedAuthoredStep {
|
|
200
|
+
step: string;
|
|
201
|
+
run_id: string;
|
|
202
|
+
state: AuthoredStepRecord['state'];
|
|
203
|
+
completion_reason?: string;
|
|
204
|
+
kernel_step?: string;
|
|
205
|
+
label?: string;
|
|
206
|
+
after?: string[];
|
|
207
|
+
after_truncated?: true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The authored root's step index, folded from the journal already in hand.
|
|
212
|
+
*
|
|
213
|
+
* An authored body's steps each run in their own child journal, so a child's
|
|
214
|
+
* view cannot say what the step is called or what it waited for; the root's
|
|
215
|
+
* `authored-steps` stream can, from the moment each child is admitted. It is
|
|
216
|
+
* the same fold `readAuthoredStepIndex` applies through the daemon, so the two
|
|
217
|
+
* readers cannot disagree. Identifiers are printed as-is, like every other id
|
|
218
|
+
* here; the label is author-chosen free text and is redacted like one.
|
|
219
|
+
*/
|
|
220
|
+
function authoredSteps(events: readonly JournalEvent[], env: NodeJS.ProcessEnv): PresentedAuthoredStep[] {
|
|
221
|
+
const messages: unknown[] = [];
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
if (event.entry_type !== 'stream.appended') continue;
|
|
224
|
+
const payload = event.payload as { stream?: unknown; message?: unknown } | null;
|
|
225
|
+
if (payload?.stream === AUTHORED_STEP_STREAM) messages.push(payload.message);
|
|
226
|
+
}
|
|
227
|
+
return [...foldAuthoredStepRecords(messages).values()].map((record) => ({
|
|
228
|
+
step: record.step,
|
|
229
|
+
run_id: record.runId,
|
|
230
|
+
state: record.state,
|
|
231
|
+
...(record.completionReason === undefined ? {} : { completion_reason: record.completionReason }),
|
|
232
|
+
...(record.kernelStep === undefined ? {} : { kernel_step: record.kernelStep }),
|
|
233
|
+
...(record.label === undefined ? {} : { label: redact(record.label, env) }),
|
|
234
|
+
...(record.after === undefined ? {} : { after: [...record.after] }),
|
|
235
|
+
...(record.afterTruncated === true ? { after_truncated: true as const } : {}),
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
190
239
|
type PresentedStep = StepView & { tails: StepTails };
|
|
191
240
|
type Presented = Omit<RunView, 'steps'> & { this_step: string | null; steps: PresentedStep[] };
|
|
192
241
|
|
|
@@ -1,20 +1,38 @@
|
|
|
1
|
-
import
|
|
1
|
+
import packageManifest from '../package.json' with { type: 'json' };
|
|
2
2
|
import type { FlowExtensionManifest } from './flow-extension-manifest.js';
|
|
3
3
|
import { PluginError } from './plugin-manifest.js';
|
|
4
4
|
import { satisfiesRange } from './semver-range.js';
|
|
5
5
|
|
|
6
|
+
const OBJECT_FREEZE = Object.freeze;
|
|
7
|
+
// This static import is resolved before module evaluation and embedded by the
|
|
8
|
+
// standalone Bun build. It neither consults mutable fs exports after authored
|
|
9
|
+
// code nor depends on import.meta.url naming a real package directory.
|
|
10
|
+
const RUNTIME_VERSIONS = OBJECT_FREEZE({
|
|
11
|
+
sdk: packageManifest.version,
|
|
12
|
+
surface: packageManifest.dependencies['@relayflows/surface']!,
|
|
13
|
+
});
|
|
14
|
+
|
|
6
15
|
export interface RuntimeVersions { readonly sdk: string; readonly surface: string }
|
|
7
16
|
|
|
8
17
|
/** The versions a plugin's `compat` is checked against: this SDK and the surface it pins. */
|
|
9
18
|
export function runtimeVersions(): RuntimeVersions {
|
|
10
|
-
|
|
11
|
-
return { sdk: pkg.version, surface: pkg.dependencies['@relayflows/surface']! };
|
|
19
|
+
return RUNTIME_VERSIONS;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
/** `compat.surface` / `compat.sdk` against the runtime: a miss is a refusal, never a warning. */
|
|
15
23
|
export function assertCompatible(manifest: FlowExtensionManifest, versions: RuntimeVersions): void {
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
assertRuntimeRange(manifest, 'surface', manifest.compat.surface, versions.surface);
|
|
25
|
+
assertRuntimeRange(manifest, 'sdk', manifest.compat.sdk, versions.sdk);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertRuntimeRange(
|
|
29
|
+
manifest: FlowExtensionManifest,
|
|
30
|
+
what: 'surface' | 'sdk',
|
|
31
|
+
range: string,
|
|
32
|
+
actual: string,
|
|
33
|
+
): void {
|
|
34
|
+
if (!satisfiesRange(actual, range)) {
|
|
35
|
+
throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`);
|
|
18
36
|
}
|
|
19
37
|
}
|
|
20
38
|
|
|
@@ -23,9 +41,16 @@ export function assertCompatible(manifest: FlowExtensionManifest, versions: Runt
|
|
|
23
41
|
* optional: a base without it matches only `"*"`.
|
|
24
42
|
*/
|
|
25
43
|
export function assertBaseCompatible(manifest: FlowExtensionManifest, base: { readonly name: string; readonly version?: string }): void {
|
|
26
|
-
|
|
44
|
+
let entry: FlowExtensionManifest['compat']['base'][number] | undefined;
|
|
45
|
+
for (let index = 0; index < manifest.compat.base.length; index += 1) {
|
|
46
|
+
if (manifest.compat.base[index]!.name === base.name) entry = manifest.compat.base[index];
|
|
47
|
+
}
|
|
27
48
|
if (entry === undefined) {
|
|
28
|
-
|
|
49
|
+
let names = '';
|
|
50
|
+
for (let index = 0; index < manifest.compat.base.length; index += 1) {
|
|
51
|
+
names += `${index === 0 ? '' : ', '}${manifest.compat.base[index]!.name}`;
|
|
52
|
+
}
|
|
53
|
+
throw new PluginError('plugin_incompatible', `${manifest.name} extends ${names}, not "${base.name}".`);
|
|
29
54
|
}
|
|
30
55
|
if (base.version === undefined) {
|
|
31
56
|
if (entry.version !== '*') throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}, but the base flow declares no version; only "*" can be satisfied.`);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
1
|
import { dirname, join } from 'node:path';
|
|
3
2
|
import type { Ctx } from '@relayflows/surface';
|
|
4
3
|
import type { AuthoredFlowDefinition, FlowHandle } from './authored-flow.js';
|
|
@@ -6,9 +5,22 @@ import { sha256 } from './bundle.js';
|
|
|
6
5
|
import { assertBaseCompatible, assertCompatible, runtimeVersions, type RuntimeVersions } from './flow-extension-compat.js';
|
|
7
6
|
import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js';
|
|
8
7
|
import { findPluginProject } from './plugin-loader.js';
|
|
8
|
+
import { appendIntrinsicArray } from './intrinsic-array.js';
|
|
9
9
|
import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js';
|
|
10
10
|
import { PluginError } from './plugin-manifest.js';
|
|
11
|
-
import { pluginStoreDirectory,
|
|
11
|
+
import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js';
|
|
12
|
+
|
|
13
|
+
const JSON_PARSE = JSON.parse;
|
|
14
|
+
const ARRAY_IS_ARRAY = Array.isArray;
|
|
15
|
+
const JSON_STRINGIFY = JSON.stringify;
|
|
16
|
+
const OBJECT_FREEZE = Object.freeze;
|
|
17
|
+
const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (
|
|
18
|
+
regexp: RegExp, value: string,
|
|
19
|
+
) => boolean;
|
|
20
|
+
const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as (
|
|
21
|
+
value: string,
|
|
22
|
+
separator: string | RegExp,
|
|
23
|
+
) => string[];
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
26
|
* Compose schema-2 flow extensions onto a base authored flow.
|
|
@@ -94,7 +106,7 @@ function subscriptionOf(handler: TriggerHandler): { provider: string; event: str
|
|
|
94
106
|
if (trigger.kind !== 'webhook' || trigger.filter === undefined) return undefined;
|
|
95
107
|
const { provider, type, payload } = trigger.filter as { provider?: unknown; type?: unknown; payload?: unknown };
|
|
96
108
|
if (typeof provider !== 'string' || provider !== trigger.name || typeof type !== 'string') return undefined;
|
|
97
|
-
const action = typeof payload === 'object' && payload !== null && !
|
|
109
|
+
const action = typeof payload === 'object' && payload !== null && !ARRAY_IS_ARRAY(payload) ? (payload as { action?: unknown }).action : undefined;
|
|
98
110
|
if (action !== undefined && typeof action !== 'string') return undefined;
|
|
99
111
|
return action === undefined ? { provider, event: type } : { provider, event: type, action };
|
|
100
112
|
}
|
|
@@ -115,32 +127,41 @@ export type HostedExtensionDispatch = {
|
|
|
115
127
|
readonly deliveryId: string;
|
|
116
128
|
};
|
|
117
129
|
|
|
118
|
-
type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string };
|
|
130
|
+
export type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string };
|
|
119
131
|
const DISPATCH_PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
120
132
|
const DISPATCH_EVENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
121
133
|
const DISPATCH_DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
122
134
|
|
|
123
135
|
function hostedEventIdentity(dispatch: unknown): HostedEventIdentity {
|
|
124
|
-
if (typeof dispatch !== 'object' || dispatch === null ||
|
|
136
|
+
if (typeof dispatch !== 'object' || dispatch === null || ARRAY_IS_ARRAY(dispatch)) {
|
|
125
137
|
throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.');
|
|
126
138
|
}
|
|
127
139
|
const { provenance, provider, eventType, deliveryId } = dispatch as Partial<HostedExtensionDispatch>;
|
|
128
140
|
if ((dispatch as Partial<HostedExtensionDispatch>)[HOSTED_EXTENSION_DISPATCH_AUTHORITY] !== true
|
|
129
141
|
|| provenance !== 'integration-watch'
|
|
130
|
-
|| typeof provider !== 'string' || !DISPATCH_PROVIDER
|
|
131
|
-
|| typeof deliveryId !== 'string' || !DISPATCH_DELIVERY
|
|
142
|
+
|| typeof provider !== 'string' || !REGEXP_TEST(DISPATCH_PROVIDER, provider)
|
|
143
|
+
|| typeof deliveryId !== 'string' || !REGEXP_TEST(DISPATCH_DELIVERY, deliveryId)
|
|
132
144
|
|| typeof eventType !== 'string') {
|
|
133
145
|
throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.');
|
|
134
146
|
}
|
|
135
|
-
const parts = eventType
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
const parts = STRING_SPLIT(eventType, '.');
|
|
148
|
+
let valid = parts.length === 1 || parts.length === 2;
|
|
149
|
+
for (let index = 0; valid && index < parts.length; index += 1) {
|
|
150
|
+
valid = REGEXP_TEST(DISPATCH_EVENT, parts[index]!);
|
|
151
|
+
}
|
|
152
|
+
if (!valid) {
|
|
153
|
+
throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON_STRINGIFY(eventType)} is malformed.`);
|
|
138
154
|
}
|
|
139
155
|
return parts.length === 1
|
|
140
156
|
? { provider, event: parts[0]! }
|
|
141
157
|
: { provider, event: parts[0]!, action: parts[1]! };
|
|
142
158
|
}
|
|
143
159
|
|
|
160
|
+
/** Validate and project branded host authority without making it serializable. */
|
|
161
|
+
export function hostedExtensionDispatchIdentity(dispatch: unknown): HostedEventIdentity {
|
|
162
|
+
return OBJECT_FREEZE(hostedEventIdentity(dispatch));
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
/**
|
|
145
166
|
* Brand metadata only after the host has authenticated the integration
|
|
146
167
|
* delivery. The symbol is deliberately not serializable, so copying a direct
|
|
@@ -149,7 +170,7 @@ function hostedEventIdentity(dispatch: unknown): HostedEventIdentity {
|
|
|
149
170
|
export function hostedExtensionDispatchFromVerifiedDelivery(
|
|
150
171
|
delivery: Omit<HostedExtensionDispatch, typeof HOSTED_EXTENSION_DISPATCH_AUTHORITY | 'provenance'>,
|
|
151
172
|
): HostedExtensionDispatch {
|
|
152
|
-
const dispatch =
|
|
173
|
+
const dispatch = OBJECT_FREEZE({
|
|
153
174
|
[HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true as const,
|
|
154
175
|
provenance: 'integration-watch' as const,
|
|
155
176
|
...delivery,
|
|
@@ -171,16 +192,29 @@ export function extensionHandlerForHostedDispatch(
|
|
|
171
192
|
): { readonly extension: Pick<LoadedFlowExtension, 'name' | 'handlers'>; readonly handler: TriggerHandler } | undefined {
|
|
172
193
|
if (dispatch === undefined) return undefined;
|
|
173
194
|
const identity = hostedEventIdentity(dispatch);
|
|
174
|
-
const matches
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
const matches: Array<{
|
|
196
|
+
extension: Pick<LoadedFlowExtension, 'name' | 'handlers'>;
|
|
197
|
+
handler: TriggerHandler;
|
|
198
|
+
}> = [];
|
|
199
|
+
for (let extensionIndex = 0; extensionIndex < extensions.length; extensionIndex += 1) {
|
|
200
|
+
const extension = extensions[extensionIndex]!;
|
|
201
|
+
for (let handlerIndex = 0; handlerIndex < extension.handlers.length; handlerIndex += 1) {
|
|
202
|
+
const handler = extension.handlers[handlerIndex]!;
|
|
203
|
+
const subscription = subscriptionOf(handler);
|
|
204
|
+
if (subscription === undefined || subscription.provider !== identity.provider
|
|
205
|
+
|| subscription.event !== identity.event) continue;
|
|
206
|
+
if (subscription.action !== undefined && subscription.action !== identity.action) continue;
|
|
207
|
+
appendIntrinsicArray(matches, { extension, handler });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
180
210
|
if (matches.length > 1) {
|
|
211
|
+
let matchingNames = '';
|
|
212
|
+
for (let index = 0; index < matches.length; index += 1) {
|
|
213
|
+
matchingNames += `${index === 0 ? '' : ', '}${matches[index]!.extension.name}`;
|
|
214
|
+
}
|
|
181
215
|
throw new PluginError(
|
|
182
216
|
'plugin_event_ambiguous',
|
|
183
|
-
`Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${
|
|
217
|
+
`Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matchingNames}).`,
|
|
184
218
|
);
|
|
185
219
|
}
|
|
186
220
|
return matches[0];
|
|
@@ -206,11 +240,12 @@ async function loadOne<Authority>(
|
|
|
206
240
|
options: LoadFlowExtensionsOptions<Authority>,
|
|
207
241
|
): Promise<LoadedFlowExtension> {
|
|
208
242
|
const directory = pluginStoreDirectory(root, lock.name, lock.digest);
|
|
209
|
-
await
|
|
210
|
-
const manifestBytes =
|
|
243
|
+
const stored = await readStoredPluginFiles(directory, lock.digest);
|
|
244
|
+
const manifestBytes = stored.find(file => file.path === 'flows-plugin.json')?.data;
|
|
245
|
+
if (manifestBytes === undefined) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json is missing.`);
|
|
211
246
|
if (sha256(manifestBytes) !== lock.manifestSha256) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json differs from the lockfile's manifest hash.`);
|
|
212
247
|
let input: unknown;
|
|
213
|
-
try { input =
|
|
248
|
+
try { input = JSON_PARSE(manifestBytes.toString('utf8')); }
|
|
214
249
|
catch { throw new PluginError('plugin_manifest_invalid', `${ref}: flows-plugin.json is not valid JSON.`); }
|
|
215
250
|
const manifest = validateFlowExtensionManifest(input);
|
|
216
251
|
if (manifest.name !== lock.name || manifest.version !== lock.version) throw new PluginError('plugin_source_drift', `${ref}: manifest names ${manifest.name}@${manifest.version}, lockfile has ${lock.name}@${lock.version}.`);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Dir,
|
|
3
|
+
Dirent,
|
|
4
|
+
Stats,
|
|
5
|
+
close,
|
|
6
|
+
fstat,
|
|
7
|
+
lstat,
|
|
8
|
+
open,
|
|
9
|
+
opendir,
|
|
10
|
+
read,
|
|
11
|
+
readdir,
|
|
12
|
+
type BigIntStats,
|
|
13
|
+
type PathLike,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { hostedPromiseValue } from './hosted-promise-safety.js';
|
|
16
|
+
|
|
17
|
+
const CLOSE = close;
|
|
18
|
+
const FSTAT = fstat;
|
|
19
|
+
const LSTAT = lstat;
|
|
20
|
+
const OPEN = open;
|
|
21
|
+
const OPENDIR = opendir;
|
|
22
|
+
const READ = read;
|
|
23
|
+
const READDIR = readdir;
|
|
24
|
+
const PROMISE = Promise;
|
|
25
|
+
const DIRENT_IS_DIRECTORY = Function.prototype.call.bind(Dirent.prototype.isDirectory) as (
|
|
26
|
+
entry: Dirent,
|
|
27
|
+
) => boolean;
|
|
28
|
+
const DIRENT_IS_FILE = Function.prototype.call.bind(Dirent.prototype.isFile) as (
|
|
29
|
+
entry: Dirent,
|
|
30
|
+
) => boolean;
|
|
31
|
+
const DIR_CLOSE = Function.prototype.call.bind(Dir.prototype.close) as (
|
|
32
|
+
directory: Dir,
|
|
33
|
+
callback: (error?: NodeJS.ErrnoException | null) => void,
|
|
34
|
+
) => void;
|
|
35
|
+
const DIR_READ = Function.prototype.call.bind(Dir.prototype.read) as (
|
|
36
|
+
directory: Dir,
|
|
37
|
+
callback: (error: NodeJS.ErrnoException | null, entry: Dirent | null) => void,
|
|
38
|
+
) => void;
|
|
39
|
+
const STATS_IS_DIRECTORY = Function.prototype.call.bind(Stats.prototype.isDirectory) as (
|
|
40
|
+
stats: Stats | BigIntStats,
|
|
41
|
+
) => boolean;
|
|
42
|
+
const STATS_IS_FILE = Function.prototype.call.bind(Stats.prototype.isFile) as (
|
|
43
|
+
stats: Stats | BigIntStats,
|
|
44
|
+
) => boolean;
|
|
45
|
+
|
|
46
|
+
export const descriptorIsDirectory = STATS_IS_DIRECTORY;
|
|
47
|
+
export const descriptorIsFile = STATS_IS_FILE;
|
|
48
|
+
export const directoryEntryIsDirectory = DIRENT_IS_DIRECTORY;
|
|
49
|
+
export const directoryEntryIsFile = DIRENT_IS_FILE;
|
|
50
|
+
|
|
51
|
+
export function closeDescriptor(descriptor: number): Promise<void> {
|
|
52
|
+
return new PROMISE<void>((resolvePromise, rejectPromise) => {
|
|
53
|
+
CLOSE(descriptor, error => error === null ? resolvePromise() : rejectPromise(error));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function openDescriptor(path: PathLike, flags: number): Promise<number> {
|
|
58
|
+
return new PROMISE<number>((resolvePromise, rejectPromise) => {
|
|
59
|
+
OPEN(path, flags, (error, descriptor) => error === null ? resolvePromise(descriptor) : rejectPromise(error));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function statDescriptor(descriptor: number, options: { bigint: true }): Promise<BigIntStats> {
|
|
64
|
+
return new PROMISE<BigIntStats>((resolvePromise, rejectPromise) => {
|
|
65
|
+
FSTAT(descriptor, options, (error, stats) => {
|
|
66
|
+
if (error !== null) rejectPromise(error);
|
|
67
|
+
else resolvePromise(hostedPromiseValue(stats));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function lstatPath(path: PathLike): Promise<Stats> {
|
|
73
|
+
return new PROMISE<Stats>((resolvePromise, rejectPromise) => {
|
|
74
|
+
LSTAT(path, (error, stats) => {
|
|
75
|
+
if (error !== null) rejectPromise(error);
|
|
76
|
+
else resolvePromise(hostedPromiseValue(stats));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function openDirectory(path: PathLike): Promise<Dir> {
|
|
82
|
+
return new PROMISE<Dir>((resolvePromise, rejectPromise) => {
|
|
83
|
+
OPENDIR(path, (error, directory) => {
|
|
84
|
+
if (error !== null) rejectPromise(error);
|
|
85
|
+
else resolvePromise(hostedPromiseValue(directory));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readDirectory(path: PathLike): Promise<Dirent[]> {
|
|
91
|
+
return new PROMISE<Dirent[]>((resolvePromise, rejectPromise) => {
|
|
92
|
+
READDIR(path, { withFileTypes: true }, (error, entries) => {
|
|
93
|
+
if (error !== null) rejectPromise(error);
|
|
94
|
+
else resolvePromise(hostedPromiseValue(entries));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function readDirectoryEntry(directory: Dir): Promise<Dirent | null> {
|
|
100
|
+
return new PROMISE<Dirent | null>((resolvePromise, rejectPromise) => {
|
|
101
|
+
DIR_READ(directory, (error, entry) => {
|
|
102
|
+
if (error !== null) rejectPromise(error);
|
|
103
|
+
else resolvePromise(entry === null ? null : hostedPromiseValue(entry));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function closeDirectory(directory: Dir): Promise<void> {
|
|
109
|
+
return new PROMISE<void>((resolvePromise, rejectPromise) => {
|
|
110
|
+
DIR_CLOSE(directory, error => error == null ? resolvePromise() : rejectPromise(error));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readDescriptor(
|
|
115
|
+
descriptor: number,
|
|
116
|
+
buffer: Buffer,
|
|
117
|
+
offset: number,
|
|
118
|
+
length: number,
|
|
119
|
+
position: number,
|
|
120
|
+
): Promise<number> {
|
|
121
|
+
return await new PROMISE<number>((resolvePromise, rejectPromise) => {
|
|
122
|
+
READ(descriptor, buffer, offset, length, position, (error, bytesRead) => {
|
|
123
|
+
if (error !== null) rejectPromise(error);
|
|
124
|
+
else resolvePromise(bytesRead);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|