@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.
Files changed (120) hide show
  1. package/dist/authored-flow-executor.d.ts.map +1 -1
  2. package/dist/authored-flow-executor.js +10 -1
  3. package/dist/authored-flow-executor.js.map +1 -1
  4. package/dist/authored-step-index.d.ts +6 -0
  5. package/dist/authored-step-index.d.ts.map +1 -1
  6. package/dist/authored-step-index.js +42 -14
  7. package/dist/authored-step-index.js.map +1 -1
  8. package/dist/bundle-extensions.d.ts.map +1 -1
  9. package/dist/bundle-extensions.js +4 -7
  10. package/dist/bundle-extensions.js.map +1 -1
  11. package/dist/bundle.d.ts.map +1 -1
  12. package/dist/bundle.js +41 -7
  13. package/dist/bundle.js.map +1 -1
  14. package/dist/canonical.d.ts.map +1 -1
  15. package/dist/canonical.js +18 -6
  16. package/dist/canonical.js.map +1 -1
  17. package/dist/cli/status.d.ts +12 -0
  18. package/dist/cli/status.d.ts.map +1 -1
  19. package/dist/cli/status.js +39 -1
  20. package/dist/cli/status.js.map +1 -1
  21. package/dist/flow-extension-compat.d.ts.map +1 -1
  22. package/dist/flow-extension-compat.js +26 -8
  23. package/dist/flow-extension-compat.js.map +1 -1
  24. package/dist/flow-extension-loader.d.ts +7 -0
  25. package/dist/flow-extension-loader.d.ts.map +1 -1
  26. package/dist/flow-extension-loader.js +48 -22
  27. package/dist/flow-extension-loader.js.map +1 -1
  28. package/dist/fs-descriptor.d.ts +17 -0
  29. package/dist/fs-descriptor.d.ts.map +1 -0
  30. package/dist/fs-descriptor.js +96 -0
  31. package/dist/fs-descriptor.js.map +1 -0
  32. package/dist/hosted-base-snapshot.d.ts +25 -0
  33. package/dist/hosted-base-snapshot.d.ts.map +1 -0
  34. package/dist/hosted-base-snapshot.js +278 -0
  35. package/dist/hosted-base-snapshot.js.map +1 -0
  36. package/dist/hosted-extension-declarations.d.ts +10 -0
  37. package/dist/hosted-extension-declarations.d.ts.map +1 -0
  38. package/dist/hosted-extension-declarations.js +272 -0
  39. package/dist/hosted-extension-declarations.js.map +1 -0
  40. package/dist/hosted-extension-isolation.d.ts +82 -0
  41. package/dist/hosted-extension-isolation.d.ts.map +1 -0
  42. package/dist/hosted-extension-isolation.js +322 -0
  43. package/dist/hosted-extension-isolation.js.map +1 -0
  44. package/dist/hosted-extension-manifest.d.ts +9 -0
  45. package/dist/hosted-extension-manifest.d.ts.map +1 -0
  46. package/dist/hosted-extension-manifest.js +235 -0
  47. package/dist/hosted-extension-manifest.js.map +1 -0
  48. package/dist/hosted-extension-protocol.d.ts +16 -0
  49. package/dist/hosted-extension-protocol.d.ts.map +1 -0
  50. package/dist/hosted-extension-protocol.js +224 -0
  51. package/dist/hosted-extension-protocol.js.map +1 -0
  52. package/dist/hosted-extension-runtime.d.ts +30 -0
  53. package/dist/hosted-extension-runtime.d.ts.map +1 -0
  54. package/dist/hosted-extension-runtime.js +167 -0
  55. package/dist/hosted-extension-runtime.js.map +1 -0
  56. package/dist/hosted-extension-sandbox-source.d.ts +9 -0
  57. package/dist/hosted-extension-sandbox-source.d.ts.map +1 -0
  58. package/dist/hosted-extension-sandbox-source.js +95 -0
  59. package/dist/hosted-extension-sandbox-source.js.map +1 -0
  60. package/dist/hosted-extension-sandbox.d.ts +39 -0
  61. package/dist/hosted-extension-sandbox.d.ts.map +1 -0
  62. package/dist/hosted-extension-sandbox.js +418 -0
  63. package/dist/hosted-extension-sandbox.js.map +1 -0
  64. package/dist/hosted-project.d.ts +3 -0
  65. package/dist/hosted-project.d.ts.map +1 -0
  66. package/dist/hosted-project.js +19 -0
  67. package/dist/hosted-project.js.map +1 -0
  68. package/dist/hosted-promise-safety.d.ts +12 -0
  69. package/dist/hosted-promise-safety.d.ts.map +1 -0
  70. package/dist/hosted-promise-safety.js +63 -0
  71. package/dist/hosted-promise-safety.js.map +1 -0
  72. package/dist/index.d.ts +2 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +2 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/intrinsic-array.d.ts +3 -0
  77. package/dist/intrinsic-array.d.ts.map +1 -0
  78. package/dist/intrinsic-array.js +14 -0
  79. package/dist/intrinsic-array.js.map +1 -0
  80. package/dist/json-value.d.ts +6 -1
  81. package/dist/json-value.d.ts.map +1 -1
  82. package/dist/json-value.js +155 -36
  83. package/dist/json-value.js.map +1 -1
  84. package/dist/llm-worker.d.ts +9 -0
  85. package/dist/llm-worker.d.ts.map +1 -1
  86. package/dist/llm-worker.js +15 -1
  87. package/dist/llm-worker.js.map +1 -1
  88. package/dist/plugin-store.d.ts +8 -2
  89. package/dist/plugin-store.d.ts.map +1 -1
  90. package/dist/plugin-store.js +233 -56
  91. package/dist/plugin-store.js.map +1 -1
  92. package/dist/semver-range.d.ts.map +1 -1
  93. package/dist/semver-range.js +27 -15
  94. package/dist/semver-range.js.map +1 -1
  95. package/package.json +2 -2
  96. package/src/authored-flow-executor.ts +10 -1
  97. package/src/authored-step-index.ts +46 -12
  98. package/src/bundle-extensions.ts +6 -5
  99. package/src/bundle.ts +52 -7
  100. package/src/canonical.ts +20 -6
  101. package/src/cli/status.ts +50 -1
  102. package/src/flow-extension-compat.ts +32 -7
  103. package/src/flow-extension-loader.ts +56 -21
  104. package/src/fs-descriptor.ts +127 -0
  105. package/src/hosted-base-snapshot.ts +351 -0
  106. package/src/hosted-extension-declarations.ts +387 -0
  107. package/src/hosted-extension-isolation.ts +457 -0
  108. package/src/hosted-extension-manifest.ts +249 -0
  109. package/src/hosted-extension-protocol.ts +262 -0
  110. package/src/hosted-extension-runtime.ts +266 -0
  111. package/src/hosted-extension-sandbox-source.ts +94 -0
  112. package/src/hosted-extension-sandbox.ts +489 -0
  113. package/src/hosted-project.ts +18 -0
  114. package/src/hosted-promise-safety.ts +68 -0
  115. package/src/index.ts +18 -0
  116. package/src/intrinsic-array.ts +14 -0
  117. package/src/json-value.ts +184 -32
  118. package/src/llm-worker.ts +15 -1
  119. package/src/plugin-store.ts +323 -56
  120. package/src/semver-range.ts +39 -14
@@ -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, verifyStoredPlugin } from './plugin-store.js';
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 verifyStoredPlugin(directory, entry.digest);
54
- let pluginManifest: Buffer;
55
- try { pluginManifest = await readFile(join(directory, 'flows-plugin.json')); }
56
- catch { throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`); }
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
- return createHash('sha256').update(data).digest('hex');
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
- return path.length > 0 && !path.includes('\\') && !path.includes('\0')
24
- && path.split('/').every(part => part !== '' && part !== '.' && part !== '..')
25
- && !path.includes(':');
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
- return canonicalize([...files]
35
- .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
36
- .map(file => ({ path: file.path, sha256: sha256(file.data), bytes: file.data.length })));
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 JSON.stringify(value);
54
+ return JSON_STRINGIFY(value);
46
55
  }
47
- if (Array.isArray(value)) {
48
- return '[' + value.map(serialize).join(',') + ']';
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
- for (const key of Object.keys(value).sort()) {
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.push(JSON.stringify(key) + ':' + serialize(child));
68
+ appendIntrinsicArray(parts, JSON_STRINGIFY(key) + ':' + serialize(child));
55
69
  }
56
- return '{' + parts.join(',') + '}';
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
- io.stdout(canonicalize({ v: 1, ...presented, partial: taken.partial }));
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 { readFileSync } from 'node:fs';
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
- const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record<string, string> };
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
- for (const [what, range, actual] of [['surface', manifest.compat.surface, versions.surface], ['sdk', manifest.compat.sdk, versions.sdk]] as const) {
17
- if (!satisfiesRange(actual, range)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`);
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
- const entry = manifest.compat.base.find(b => b.name === base.name);
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
- throw new PluginError('plugin_incompatible', `${manifest.name} extends ${manifest.compat.base.map(b => b.name).join(', ')}, not "${base.name}".`);
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, verifyStoredPlugin } from './plugin-store.js';
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 && !Array.isArray(payload) ? (payload as { action?: unknown }).action : undefined;
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 || Array.isArray(dispatch)) {
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.test(provider)
131
- || typeof deliveryId !== 'string' || !DISPATCH_DELIVERY.test(deliveryId)
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.split('.');
136
- if ((parts.length !== 1 && parts.length !== 2) || parts.some(part => !DISPATCH_EVENT.test(part))) {
137
- throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON.stringify(eventType)} is malformed.`);
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 = Object.freeze({
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 = extensions.flatMap(extension => extension.handlers.flatMap(handler => {
175
- const subscription = subscriptionOf(handler);
176
- if (subscription === undefined || subscription.provider !== identity.provider || subscription.event !== identity.event) return [];
177
- if (subscription.action !== undefined && subscription.action !== identity.action) return [];
178
- return [{ extension, handler }];
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 (${matches.map(match => match.extension.name).join(', ')}).`,
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 verifyStoredPlugin(directory, lock.digest);
210
- const manifestBytes = readFileSync(join(directory, 'flows-plugin.json'));
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 = JSON.parse(manifestBytes.toString('utf8')); }
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
+ }