@volter/world-runtime 3.0.166 → 3.0.168
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/known-external-services.json +0 -1
- package/dist/src/actions-runner.js +53 -14
- package/dist/src/attach.d.ts +9 -0
- package/dist/src/attach.js +9 -2
- package/dist/src/cli-commands.js +19 -3
- package/dist/src/console-apart.js +3 -2
- package/dist/src/credential-door.d.ts +8 -0
- package/dist/src/credential-door.js +54 -0
- package/dist/src/fixture-env.js +2 -2
- package/dist/src/forwardable-headers.d.ts +1 -0
- package/dist/src/forwardable-headers.js +19 -0
- package/dist/src/go-mysql-binding.d.ts +2 -0
- package/dist/src/go-mysql-binding.js +7 -1
- package/dist/src/guest-runner.js +8 -4
- package/dist/src/host-ready.d.ts +7 -3
- package/dist/src/host-ready.js +16 -3
- package/dist/src/index.d.ts +6 -1
- package/dist/src/index.js +7 -1
- package/dist/src/infra.js +10 -5
- package/dist/src/init.d.ts +5 -0
- package/dist/src/init.js +157 -23
- package/dist/src/managed-api-needs.d.ts +101 -0
- package/dist/src/managed-api-needs.js +312 -0
- package/dist/src/managed-api-source.d.ts +24 -0
- package/dist/src/managed-api-source.js +59 -0
- package/dist/src/mongodb-backing.js +33 -58
- package/dist/src/mongodb-command.d.ts +21 -0
- package/dist/src/mongodb-command.js +317 -0
- package/dist/src/pglite-host.mjs +2 -2
- package/dist/src/placeholder.d.ts +24 -1
- package/dist/src/placeholder.js +105 -13
- package/dist/src/process-signal.d.mts +2 -0
- package/dist/src/process-signal.mjs +44 -2
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +8 -1
- package/dist/src/redirect-proxy.js +4 -19
- package/dist/src/reflect.js +2 -2
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +311 -59
- package/dist/src/schema.d.ts +10 -0
- package/dist/src/schema.js +76 -3
- package/dist/src/serve.js +24 -4
- package/dist/src/served-world.d.ts +2 -1
- package/dist/src/served-world.js +6 -4
- package/dist/src/service-command.d.ts +7 -3
- package/dist/src/service-command.js +53 -22
- package/dist/src/service-environment.d.ts +4 -0
- package/dist/src/service-environment.js +21 -0
- package/dist/src/service-recorder.js +9 -13
- package/known-external-services.json +0 -1
- package/package.json +5 -5
- package/src/actions-runner.ts +38 -10
- package/src/attach.ts +15 -2
- package/src/cli-commands.ts +18 -3
- package/src/console-apart.ts +3 -2
- package/src/credential-door.ts +54 -0
- package/src/fixture-env.ts +2 -2
- package/src/forwardable-headers.ts +15 -0
- package/src/go-mysql-binding.ts +8 -1
- package/src/guest-runner.ts +8 -4
- package/src/host-ready.ts +12 -3
- package/src/index.ts +8 -1
- package/src/infra.ts +8 -3
- package/src/init.ts +145 -23
- package/src/managed-api-needs.ts +285 -0
- package/src/managed-api-source.ts +67 -0
- package/src/mongodb-backing.ts +32 -48
- package/src/mongodb-command.ts +238 -0
- package/src/pglite-host.mjs +2 -2
- package/src/placeholder.ts +90 -13
- package/src/process-signal.d.mts +2 -0
- package/src/process-signal.mjs +44 -2
- package/src/project-inspect.ts +9 -2
- package/src/redirect-proxy.ts +4 -15
- package/src/reflect.ts +2 -2
- package/src/runtime.ts +272 -45
- package/src/schema.ts +73 -3
- package/src/serve.ts +22 -4
- package/src/served-world.ts +7 -5
- package/src/service-command.ts +41 -19
- package/src/service-environment.ts +21 -0
- package/src/service-recorder.ts +9 -9
package/dist/src/schema.d.ts
CHANGED
|
@@ -212,6 +212,7 @@ export type WorldServiceConfig = {
|
|
|
212
212
|
path: string;
|
|
213
213
|
body: Record<string, unknown>;
|
|
214
214
|
fill: Record<string, string>;
|
|
215
|
+
required?: string[];
|
|
215
216
|
};
|
|
216
217
|
/**
|
|
217
218
|
* The twin PACKAGE this service boots from (`@volter/twin-<vendor>`), resolved from the world
|
|
@@ -528,6 +529,13 @@ export type WorldInstance = {
|
|
|
528
529
|
};
|
|
529
530
|
services: Record<string, WorldServiceInstance>;
|
|
530
531
|
env: Record<string, string>;
|
|
532
|
+
/** Required owners fixed at this boot; the digest binds their actual scalar responses in env without duplicating values.
|
|
533
|
+
* Omitted digest means issuance has not finished. Absent metadata is readable for cleanup, but not a live application declaration. */
|
|
534
|
+
requiredCredentials?: {
|
|
535
|
+
version: 1;
|
|
536
|
+
owners: Record<string, string>;
|
|
537
|
+
valuesSha256?: string;
|
|
538
|
+
};
|
|
531
539
|
/** Always written at boot; optional only so cleanup can read incomplete legacy records. */
|
|
532
540
|
networkCeiling?: WorldNetwork | 'unrestricted';
|
|
533
541
|
bootServices?: string[];
|
|
@@ -567,6 +575,8 @@ export type WorldInstance = {
|
|
|
567
575
|
packFacts?: string[];
|
|
568
576
|
};
|
|
569
577
|
export declare function isEnvironmentName(value: unknown): value is string;
|
|
578
|
+
/** Required names belong to one twin's exact pending reference, with no competing output or local override. */
|
|
579
|
+
export declare function assertRequiredCredentialBindings(config: WorldConfig, path: string): void;
|
|
570
580
|
export declare const DEFAULT_WORLD_SELECTION: WorldSelection;
|
|
571
581
|
export declare const LEGACY_WORLD_SELECTION: WorldSelection;
|
|
572
582
|
export declare function assertWorldSelection(value: unknown, path: string): asserts value is WorldSelection;
|
package/dist/src/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { errorMessage } from "./error-message.js";
|
|
2
|
+
import { isScalarCredentialField } from "./credential-door.js";
|
|
2
3
|
import { normalizeWorldNetwork } from '@volter/world-core/network-policy';
|
|
3
4
|
export const WORLD_SCHEMA_VERSION = 2;
|
|
4
5
|
export const WORLD_USAGES = ['application', 'dependencies', 'build', 'deployment'];
|
|
@@ -179,8 +180,8 @@ function assertLegacyWorldConfig(value, path) {
|
|
|
179
180
|
const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
|
|
180
181
|
if (exports.some(name => name !== undefined && !isEnvironmentName(name)))
|
|
181
182
|
throw new Error(`Service ${service.id} bindings must name environment variables in ${path}`);
|
|
182
|
-
if (service.issues !== undefined
|
|
183
|
-
|
|
183
|
+
if (service.issues !== undefined)
|
|
184
|
+
readCredentialDoor(service.issues, service.id, path);
|
|
184
185
|
if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY'))
|
|
185
186
|
throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
|
|
186
187
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id))
|
|
@@ -338,6 +339,7 @@ function assertLegacyWorldConfig(value, path) {
|
|
|
338
339
|
}
|
|
339
340
|
}
|
|
340
341
|
}
|
|
342
|
+
assertRequiredCredentialBindings(config, path);
|
|
341
343
|
return config;
|
|
342
344
|
}
|
|
343
345
|
function record(value) {
|
|
@@ -348,6 +350,72 @@ function assertKeys(value, allowed, what, path, allowComments = false) {
|
|
|
348
350
|
if (unexpected.length)
|
|
349
351
|
throw new Error(`${what} has unknown field(s) ${unexpected.join(', ')} in ${path}`);
|
|
350
352
|
}
|
|
353
|
+
/** Credential metadata is read completely before normalization or serialization can discard a field. */
|
|
354
|
+
function readCredentialDoor(value, service, path) {
|
|
355
|
+
const what = `Service ${service} issues`;
|
|
356
|
+
if (!record(value))
|
|
357
|
+
throw new Error(`${what} must be an object in ${path}`);
|
|
358
|
+
assertKeys(value, ['path', 'body', 'fill', 'required'], what, path);
|
|
359
|
+
if (!Object.hasOwn(value, 'path') || typeof value.path !== 'string' || value.path === ''
|
|
360
|
+
|| !Object.hasOwn(value, 'body') || !record(value.body)
|
|
361
|
+
|| !Object.hasOwn(value, 'fill') || !record(value.fill)) {
|
|
362
|
+
throw new Error(`${what} must define non-empty path, object body and string→string fill in ${path}`);
|
|
363
|
+
}
|
|
364
|
+
const fill = [];
|
|
365
|
+
for (const [name, field] of Object.entries(value.fill)) {
|
|
366
|
+
if (!isEnvironmentName(name) || typeof field !== 'string')
|
|
367
|
+
throw new Error(`${what} fill must map environment names to strings in ${path}`);
|
|
368
|
+
fill.push([name, field]);
|
|
369
|
+
}
|
|
370
|
+
const required = [];
|
|
371
|
+
if (Object.hasOwn(value, 'required')) {
|
|
372
|
+
if (!Array.isArray(value.required))
|
|
373
|
+
throw new Error(`${what} required must be an array in ${path}`);
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
for (const name of value.required) {
|
|
376
|
+
if (!isEnvironmentName(name) || seen.has(name))
|
|
377
|
+
throw new Error(`${what} required must contain unique environment names in ${path}`);
|
|
378
|
+
const field = value.fill[name];
|
|
379
|
+
if (!Object.hasOwn(value.fill, name) || typeof field !== 'string' || !isScalarCredentialField(field)) {
|
|
380
|
+
throw new Error(`${what} required ${name} must name its direct scalar fill in ${path}`);
|
|
381
|
+
}
|
|
382
|
+
seen.add(name);
|
|
383
|
+
required.push(name);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { path: value.path, body: value.body, fill: Object.fromEntries(fill),
|
|
387
|
+
...(Object.hasOwn(value, 'required') ? { required } : {}) };
|
|
388
|
+
}
|
|
389
|
+
/** Required names belong to one twin's exact pending reference, with no competing output or local override. */
|
|
390
|
+
export function assertRequiredCredentialBindings(config, path) {
|
|
391
|
+
const owners = new Map();
|
|
392
|
+
for (const service of config.services) {
|
|
393
|
+
if (service.issues === undefined)
|
|
394
|
+
continue;
|
|
395
|
+
const door = readCredentialDoor(service.issues, service.id, path);
|
|
396
|
+
if (door.required !== undefined && (service.type ?? 'twin') !== 'twin') {
|
|
397
|
+
throw new Error(`Service ${service.id} required credential bindings belong to a twin in ${path}`);
|
|
398
|
+
}
|
|
399
|
+
for (const name of door.required ?? []) {
|
|
400
|
+
if (owners.has(name))
|
|
401
|
+
throw new Error(`Required credential ${name} has competing issuers in ${path}`);
|
|
402
|
+
if (!Object.hasOwn(config.env ?? {}, name) || config.env?.[name] !== `${ISSUE_ENV_PREFIX}${service.id}`) {
|
|
403
|
+
throw new Error(`Required credential ${name} must be exactly ${ISSUE_ENV_PREFIX}${service.id} in ${path}`);
|
|
404
|
+
}
|
|
405
|
+
owners.set(name, service.id);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const service of config.services) {
|
|
409
|
+
const endpoints = new Set([service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}),
|
|
410
|
+
...Object.keys(service.cliRedirect ?? {}), ...(service.external?.discover ?? []).map(entry => entry.as)]);
|
|
411
|
+
for (const [name, owner] of owners) {
|
|
412
|
+
if (endpoints.has(name) || Object.hasOwn(service.env ?? {}, name)
|
|
413
|
+
|| (service.id !== owner && Object.hasOwn(service.issues?.fill ?? {}, name))) {
|
|
414
|
+
throw new Error(`Required credential ${name} owned by ${owner} conflicts with service ${service.id} in ${path}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
351
419
|
export const DEFAULT_WORLD_SELECTION = {
|
|
352
420
|
include: [{ usage: 'application' }],
|
|
353
421
|
exclude: [],
|
|
@@ -389,7 +457,7 @@ function serviceV2ToLegacy(value, path) {
|
|
|
389
457
|
assertKeys(source, ['package', 'version'], 'service.source', path);
|
|
390
458
|
assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane', 'branchState'], 'service.execution', path);
|
|
391
459
|
assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
|
|
392
|
-
assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
|
|
460
|
+
assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover', 'issues'], 'service.bindings', path);
|
|
393
461
|
const process = execution.process === undefined ? {} : execution.process;
|
|
394
462
|
const environment = execution.environment === undefined ? {} : execution.environment;
|
|
395
463
|
if (!record(process) || !record(environment))
|
|
@@ -458,6 +526,7 @@ function serviceV2ToLegacy(value, path) {
|
|
|
458
526
|
...(bindings.injectEnv === undefined ? {} : { injectEnv: bindings.injectEnv }),
|
|
459
527
|
...(bindings.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: bindings.injectEnvTemplates }),
|
|
460
528
|
...(bindings.cliRedirect === undefined ? {} : { cliRedirect: bindings.cliRedirect }),
|
|
529
|
+
...(!Object.hasOwn(bindings, 'issues') ? {} : { issues: readCredentialDoor(bindings.issues, value.id, path) }),
|
|
461
530
|
...(value.root === undefined ? {} : { root: value.root }),
|
|
462
531
|
...(external === undefined ? {} : { external }),
|
|
463
532
|
};
|
|
@@ -611,6 +680,8 @@ export function assertWorldConfig(value, path) {
|
|
|
611
680
|
return normalizeV2(value, path);
|
|
612
681
|
}
|
|
613
682
|
const legacy = assertLegacyWorldConfig(value, path);
|
|
683
|
+
if (legacy.services.some(service => service.issues?.required !== undefined))
|
|
684
|
+
throw new Error(`Required credential bindings require a format-2 World manifest in ${path}`);
|
|
614
685
|
return { ...legacy, manifestVersion: 1 };
|
|
615
686
|
}
|
|
616
687
|
export function worldSelectionIncludes(selection, vendor, usage) {
|
|
@@ -645,6 +716,7 @@ function serviceToV2(service) {
|
|
|
645
716
|
...(service.ready === undefined ? {} : { ready: service.ready }),
|
|
646
717
|
};
|
|
647
718
|
const bindings = {
|
|
719
|
+
...(service.issues === undefined ? {} : { issues: readCredentialDoor(service.issues, service.id, 'World serialization') }),
|
|
648
720
|
...(service.injectEnv === undefined ? {} : { injectEnv: service.injectEnv }),
|
|
649
721
|
...(service.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: service.injectEnvTemplates }),
|
|
650
722
|
...(service.cliRedirect === undefined ? {} : { cliRedirect: service.cliRedirect }),
|
|
@@ -667,6 +739,7 @@ function serviceToV2(service) {
|
|
|
667
739
|
}
|
|
668
740
|
/** Serialize normalized intent as the committed format-2 manifest. */
|
|
669
741
|
export function worldConfigDocument(config, selection = config.selection ?? DEFAULT_WORLD_SELECTION) {
|
|
742
|
+
assertLegacyWorldConfig({ ...config, manifestVersion: 2 }, 'World serialization');
|
|
670
743
|
return {
|
|
671
744
|
schemaVersion: WORLD_SCHEMA_VERSION,
|
|
672
745
|
metadata: { id: config.id, ...(config.description === undefined ? {} : { description: config.description }) },
|
package/dist/src/serve.js
CHANGED
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
import { timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { createServer as createHttpServer } from 'node:http';
|
|
10
10
|
import { deriveWorldManifest } from "./attach.js";
|
|
11
|
-
import { statusWorld } from "./runtime.js";
|
|
11
|
+
import { refuseRequiredCredentialTransport, statusWorld } from "./runtime.js";
|
|
12
|
+
import { errorMessage } from "./error-message.js";
|
|
12
13
|
import { worldAttachmentNetworkEnv, worldStrictEgress } from '@volter/world-core/network-policy';
|
|
13
14
|
export const MANIFEST_PATH = '/.well-known/volter-world';
|
|
14
15
|
/** The manifest a REMOTE attacher sees: every vendor behind the one advertised
|
|
15
16
|
* door. The serving side keeps routing against its own loopback env. */
|
|
16
17
|
export function advertiseWorldManifest(name, root, advertisedOrigin) {
|
|
17
|
-
const
|
|
18
|
+
const status = statusWorld(name, root);
|
|
19
|
+
refuseRequiredCredentialTransport(status, 'remote WorldManifest publication');
|
|
20
|
+
const local = deriveWorldManifest(name, status.env);
|
|
18
21
|
const vendors = {};
|
|
19
22
|
// /__vendor/<id> keeps the fetch flow routable through the one door (the
|
|
20
23
|
// injector's fetch patch rewrites URLs wholesale and loses the vendor Host);
|
|
@@ -40,8 +43,17 @@ export async function startManifestServer(options) {
|
|
|
40
43
|
response.end('volter-world: GET ' + MANIFEST_PATH + '\n');
|
|
41
44
|
return;
|
|
42
45
|
}
|
|
46
|
+
let body;
|
|
47
|
+
try {
|
|
48
|
+
body = `${JSON.stringify(options.manifest(), null, 2)}\n`;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
response.writeHead(503, { 'content-type': 'text/plain' });
|
|
52
|
+
response.end(`World manifest unavailable: ${errorMessage(error)}\n`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
43
55
|
response.writeHead(200, { 'content-type': 'application/json' });
|
|
44
|
-
response.end(
|
|
56
|
+
response.end(body);
|
|
45
57
|
});
|
|
46
58
|
await new Promise((resolveListen, reject) => {
|
|
47
59
|
server.once('error', reject);
|
|
@@ -64,14 +76,22 @@ export async function fetchRemoteManifest(ref, options = {}) {
|
|
|
64
76
|
if (!response.ok)
|
|
65
77
|
throw new Error(`remote world manifest: ${url} → ${response.status}`);
|
|
66
78
|
const manifest = (await response.json());
|
|
67
|
-
if (
|
|
79
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)
|
|
80
|
+
|| typeof manifest.name !== 'string' || typeof manifest.vendors !== 'object' || manifest.vendors === null) {
|
|
68
81
|
throw new Error(`remote world manifest: ${url} returned no manifest`);
|
|
69
82
|
}
|
|
83
|
+
refuseRequiredRemoteManifest(manifest);
|
|
70
84
|
return manifest;
|
|
71
85
|
}
|
|
86
|
+
/** Required credentials have no approved remote transport; their field must never be silently ignored. */
|
|
87
|
+
function refuseRequiredRemoteManifest(manifest) {
|
|
88
|
+
if (Object.hasOwn(manifest, 'requiredCredentials'))
|
|
89
|
+
throw new Error('Remote WorldManifest required credential transport is unsupported; refusing attachment');
|
|
90
|
+
}
|
|
72
91
|
/** The env a remote env-attachment exports: vendor twin URLs (the advertised
|
|
73
92
|
* door), suggested fake credentials, CA trust, and the injector preload. */
|
|
74
93
|
export function remoteAttachEnv(manifest, options) {
|
|
94
|
+
refuseRequiredRemoteManifest(manifest);
|
|
75
95
|
const env = { ...manifest.env,
|
|
76
96
|
...worldAttachmentNetworkEnv(options.worldRef ?? manifest.name, manifest.network, options.callerEnv ?? process.env, worldStrictEgress(manifest.env)),
|
|
77
97
|
};
|
|
@@ -51,7 +51,8 @@ export type MountedWorld = {
|
|
|
51
51
|
served: string;
|
|
52
52
|
root: string;
|
|
53
53
|
readonly token: string;
|
|
54
|
-
readonly readToken: string;
|
|
54
|
+
readonly readToken: string; /** whether its boot has finished: until then its twins are not known */
|
|
55
|
+
ready: () => boolean;
|
|
55
56
|
twins: () => string[];
|
|
56
57
|
rotate: () => {
|
|
57
58
|
token: string;
|
package/dist/src/served-world.js
CHANGED
|
@@ -50,6 +50,7 @@ import { describeActionFields, resourceBlobFile } from '@volter/world-core/runti
|
|
|
50
50
|
import { worldLedgers } from "./changeset.js";
|
|
51
51
|
import { clockFile, downWorld, instanceDir, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from "./runtime.js";
|
|
52
52
|
import { assertChangesetRules } from "./schema.js";
|
|
53
|
+
import { forwardableHeaders } from "./forwardable-headers.js";
|
|
53
54
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
54
55
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
55
56
|
export const TWINS_KEY_HEADER = 'x-twins-key';
|
|
@@ -237,7 +238,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
237
238
|
return null;
|
|
238
239
|
// the body as bytes, none when empty: an empty body streamed is sent chunked with no last chunk (Bun 1.2), and the
|
|
239
240
|
// twin's server reads the next request on the connection as its continuation (HPE_INVALID_CHUNK_SIZE)
|
|
240
|
-
return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: request.headers, ...(body?.byteLength ? { body } : {}), redirect: 'manual' }); };
|
|
241
|
+
return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: forwardableHeaders(request.headers), ...(body?.byteLength ? { body } : {}), redirect: 'manual' }); };
|
|
241
242
|
},
|
|
242
243
|
};
|
|
243
244
|
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
@@ -248,6 +249,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
248
249
|
name, served, root: worldRoot,
|
|
249
250
|
maxRequestBodySize: Math.max(1024 * 1024 * 1024, ...loadWorldConfig(configRef, worldRoot).config.services.map((service) => packFacts()[service.package?.replace(/^@volter\/twin-/, '') ?? service.id]?.maxRequestBodySize ?? 0)),
|
|
250
251
|
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
252
|
+
ready: () => host.ready(),
|
|
251
253
|
twins: () => world.twins(),
|
|
252
254
|
rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
|
|
253
255
|
handle: (request) => world.handle(request),
|
|
@@ -1359,7 +1361,7 @@ export class WorldDoors {
|
|
|
1359
1361
|
const enforced = browser && grant?.scope === 'read' && await this.enforcesRead(vendor, twin, url.origin);
|
|
1360
1362
|
if (browser && grant?.scope === 'read' && !reads && !enforced)
|
|
1361
1363
|
return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1362
|
-
const headers = browser ?
|
|
1364
|
+
const headers = browser ? forwardableHeaders(request.headers) : new Headers();
|
|
1363
1365
|
for (const key of [TOKEN_HEADER, TWINS_KEY_HEADER, CALLER_HEADER, ORIGINAL_PATH_HEADER, READ_ONLY_HEADER, 'host', TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER])
|
|
1364
1366
|
headers.delete(key);
|
|
1365
1367
|
for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) {
|
|
@@ -1617,7 +1619,7 @@ export class WorldDoors {
|
|
|
1617
1619
|
const enforced = scope === 'read' && (await this.enforcesRead(vendor, twin, url.origin));
|
|
1618
1620
|
if (scope === 'read' && !reads && !enforced)
|
|
1619
1621
|
return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
1620
|
-
const headers =
|
|
1622
|
+
const headers = forwardableHeaders(request.headers);
|
|
1621
1623
|
headers.delete(TOKEN_HEADER);
|
|
1622
1624
|
headers.delete(TWINS_KEY_HEADER);
|
|
1623
1625
|
headers.delete('host');
|
|
@@ -1725,7 +1727,7 @@ export class WorldDoors {
|
|
|
1725
1727
|
async forward(request, url, rest, name, origin) {
|
|
1726
1728
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
1727
1729
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
1728
|
-
const caller =
|
|
1730
|
+
const caller = forwardableHeaders(request.headers);
|
|
1729
1731
|
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
1730
1732
|
caller.delete(h);
|
|
1731
1733
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
1
|
+
/** The program and arguments a service command runs as. Explicit paths, commands with an extension and every
|
|
2
|
+
* non-Windows command keep their meaning; a command that cannot be resolved is spawned as given, and fails through
|
|
3
|
+
* the ordinary recorded spawn error. */
|
|
4
|
+
export declare function serviceInvocation(command: string, args: string[], env: NodeJS.ProcessEnv): {
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Process services run without a shell. On Windows
|
|
2
|
-
// shim
|
|
1
|
+
// Process services run without a shell. On Windows a bare command name is found the way a shell would find it, and an
|
|
2
|
+
// npm command shim (`.cmd`, which Node's shell-free spawn cannot execute) runs as what it launches: its package's
|
|
3
|
+
// script under Node, or the native executable of npm's Bun.
|
|
3
4
|
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
-
import { join, resolve } from 'node:path';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
6
|
const file = (path) => {
|
|
6
7
|
try {
|
|
7
8
|
return statSync(path).isFile();
|
|
@@ -10,29 +11,59 @@ const file = (path) => {
|
|
|
10
11
|
return false;
|
|
11
12
|
}
|
|
12
13
|
};
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
/** npm's Bun package: `bun.cmd` beside `node_modules/bun/bin/bun.exe`. */
|
|
15
|
+
function npmBun(directory) {
|
|
16
|
+
const installed = resolve(directory, 'node_modules', 'bun');
|
|
17
|
+
const executable = join(installed, 'bin', 'bun.exe');
|
|
18
|
+
if (!file(executable))
|
|
19
|
+
return undefined;
|
|
20
|
+
try {
|
|
21
|
+
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8'));
|
|
22
|
+
if (manifest.name === 'bun' && manifest.bin?.bun === 'bin/bun.exe')
|
|
23
|
+
return executable;
|
|
24
|
+
}
|
|
25
|
+
catch { /* An incomplete installation follows the ordinary spawn-error path. */ }
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/** The script an npm cmd-shim launches (`"%dp0%\node_modules\<package>\bin\<tool>.js" %*`), when it names one under
|
|
29
|
+
* its own directory. Only the shim's text is read; nothing in it is executed. */
|
|
30
|
+
function shimScript(shim) {
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = readFileSync(shim, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const match = /"%dp0%\\([^"%]+\.[cm]?js)"\s+%\*/i.exec(text) ?? /"%~dp0\\([^"%]+\.[cm]?js)"\s+%\*/i.exec(text);
|
|
39
|
+
if (!match)
|
|
40
|
+
return undefined;
|
|
41
|
+
const script = resolve(shim, '..', match[1]);
|
|
42
|
+
return file(script) ? script : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** The program and arguments a service command runs as. Explicit paths, commands with an extension and every
|
|
45
|
+
* non-Windows command keep their meaning; a command that cannot be resolved is spawned as given, and fails through
|
|
46
|
+
* the ordinary recorded spawn error. */
|
|
47
|
+
export function serviceInvocation(command, args, env) {
|
|
48
|
+
if (process.platform !== 'win32' || !command || isAbsolute(command) || /[\\/]/.test(command) || /\.[a-z0-9]+$/i.test(command))
|
|
49
|
+
return { command, args };
|
|
18
50
|
const path = Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] ?? '';
|
|
19
|
-
const directories = [process.cwd(), ...path.split(';').map((value) => value.replace(/^"(.*)"$/, '$1'))];
|
|
51
|
+
const directories = [process.cwd(), ...path.split(';').filter(Boolean).map((value) => value.replace(/^"(.*)"$/, '$1'))];
|
|
20
52
|
for (const directory of directories) {
|
|
21
|
-
const native = resolve(directory,
|
|
53
|
+
const native = resolve(directory, `${command}.exe`);
|
|
22
54
|
if (file(native))
|
|
23
|
-
return native;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const installed = resolve(directory, 'node_modules', 'bun');
|
|
27
|
-
const executable = join(installed, 'bin', 'bun.exe');
|
|
28
|
-
if (!file(executable))
|
|
55
|
+
return { command: native, args };
|
|
56
|
+
const shim = resolve(directory, `${command}.cmd`);
|
|
57
|
+
if (!file(shim))
|
|
29
58
|
continue;
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
return
|
|
59
|
+
if (command.toLowerCase() === 'bun') {
|
|
60
|
+
const bun = npmBun(directory);
|
|
61
|
+
if (bun)
|
|
62
|
+
return { command: bun, args };
|
|
34
63
|
}
|
|
35
|
-
|
|
64
|
+
const script = shimScript(shim);
|
|
65
|
+
if (script)
|
|
66
|
+
return { command: process.execPath, args: [script, ...args] };
|
|
36
67
|
}
|
|
37
|
-
return command;
|
|
68
|
+
return { command, args };
|
|
38
69
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The recorder's environment and its child's are computed here so the boot gate sees the exact final names. */
|
|
2
|
+
export declare function recorderEnvironment(env: NodeJS.ProcessEnv, service: string, log: string, logDir: string, events: string): NodeJS.ProcessEnv;
|
|
3
|
+
/** Restore the injector for the real child and omit only the recorder's existing private transport names. */
|
|
4
|
+
export declare function recordedApplicationEnvironment(recorded: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The recorder's environment and its child's are computed here so the boot gate sees the exact final names. */
|
|
2
|
+
export function recorderEnvironment(env, service, log, logDir, events) {
|
|
3
|
+
const out = { ...env, VOLTER_WORLD_SERVICE_ID: service, VOLTER_WORLD_SERVICE_LOG: log,
|
|
4
|
+
VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: events };
|
|
5
|
+
if (out.NODE_OPTIONS !== undefined) {
|
|
6
|
+
out.VOLTER_WORLD_SERVICE_NODE_OPTIONS = out.NODE_OPTIONS;
|
|
7
|
+
delete out.NODE_OPTIONS;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/** Restore the injector for the real child and omit only the recorder's existing private transport names. */
|
|
12
|
+
export function recordedApplicationEnvironment(recorded) {
|
|
13
|
+
const out = { ...recorded };
|
|
14
|
+
if (out.VOLTER_WORLD_SERVICE_NODE_OPTIONS !== undefined) {
|
|
15
|
+
out.NODE_OPTIONS = out.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
|
|
16
|
+
delete out.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
|
|
17
|
+
}
|
|
18
|
+
for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX'])
|
|
19
|
+
delete out[key];
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
@@ -17,22 +17,17 @@
|
|
|
17
17
|
// The World's NODE_OPTIONS (the injector) is for the service, not this parent: it arrives renamed and is restored.
|
|
18
18
|
import { signalChild } from './process-signal.mjs';
|
|
19
19
|
import { errorMessage } from "./error-message.js";
|
|
20
|
-
import {
|
|
20
|
+
import { serviceInvocation } from "./service-command.js";
|
|
21
|
+
import { recordedApplicationEnvironment } from "./service-environment.js";
|
|
21
22
|
import { spawn } from 'node:child_process';
|
|
22
23
|
import { constants } from 'node:os';
|
|
23
24
|
import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync, truncateSync, writeFileSync } from 'node:fs';
|
|
24
25
|
const [command = '', ...args] = process.argv.slice(2);
|
|
25
|
-
const env =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const service = env.VOLTER_WORLD_SERVICE_ID;
|
|
31
|
-
const logPath = env.VOLTER_WORLD_SERVICE_LOG;
|
|
32
|
-
const eventsPath = env.VOLTER_WORLD_EVENTS;
|
|
33
|
-
const cap = Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
|
|
34
|
-
for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX'])
|
|
35
|
-
delete env[key];
|
|
26
|
+
const env = recordedApplicationEnvironment(process.env);
|
|
27
|
+
const service = process.env.VOLTER_WORLD_SERVICE_ID;
|
|
28
|
+
const logPath = process.env.VOLTER_WORLD_SERVICE_LOG;
|
|
29
|
+
const eventsPath = process.env.VOLTER_WORLD_EVENTS;
|
|
30
|
+
const cap = Number(process.env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(process.env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
|
|
36
31
|
/** The log's size and whether its last byte ends a line (a record never lands inside a service's line). */
|
|
37
32
|
const logEnd = () => {
|
|
38
33
|
if (!logPath)
|
|
@@ -62,7 +57,8 @@ const record = (entry, then) => {
|
|
|
62
57
|
process.stdout.write(`${logEnd().atLineStart ? '' : '\n'}${line}`, () => then?.());
|
|
63
58
|
};
|
|
64
59
|
const started = Date.now();
|
|
65
|
-
const
|
|
60
|
+
const invocation = serviceInvocation(command, args, env);
|
|
61
|
+
const child = spawn(invocation.command, invocation.args, { windowsHide: true, stdio: 'inherit', env });
|
|
66
62
|
if (child.pid !== undefined)
|
|
67
63
|
record({ event: 'world.service.started', pid: child.pid, parentPid: process.pid });
|
|
68
64
|
/** The log's last `bytes` (from a line start) as the rotation `<log>.1`: however far a runaway writer overshot the cap,
|
|
@@ -1017,7 +1017,6 @@
|
|
|
1017
1017
|
"organizationlicense": "generic app-internal env stem, names no vendor",
|
|
1018
1018
|
"osso": "defunct SAML SaaS (Osso), no live vendor surface",
|
|
1019
1019
|
"postgrest": "self-instance client; targets the app's own self-hosted deployment, which is local in a keyless world",
|
|
1020
|
-
"postizoauth": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
|
|
1021
1020
|
"seedoauth2": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
|
|
1022
1021
|
"seedplatformoauth": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
|
|
1023
1022
|
"sink": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.168",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.2.20",
|
|
64
64
|
"@types/node": "^24.0.0",
|
|
65
|
+
"mongodb": "~6.20.0",
|
|
65
66
|
"pg": "8.16.3",
|
|
66
67
|
"redis": "5.8.2",
|
|
67
68
|
"typescript": "^5.9.0"
|
|
@@ -70,8 +71,8 @@
|
|
|
70
71
|
"node": ">=22.3"
|
|
71
72
|
},
|
|
72
73
|
"peerDependencies": {
|
|
73
|
-
"@volter/world-core": "3.0.
|
|
74
|
-
"@volter/world-console": "3.0.
|
|
74
|
+
"@volter/world-core": "3.0.157",
|
|
75
|
+
"@volter/world-console": "3.0.153"
|
|
75
76
|
},
|
|
76
77
|
"dependencies": {
|
|
77
78
|
"@electric-sql/pglite": "0.5.8",
|
|
@@ -80,9 +81,8 @@
|
|
|
80
81
|
"@volter/browser-runtime": "0.1.104",
|
|
81
82
|
"@volter/browser-sandboxes": "0.1.84",
|
|
82
83
|
"@volter/world-access": "3.0.1",
|
|
83
|
-
"@volter/world-core": "3.0.
|
|
84
|
+
"@volter/world-core": "3.0.157",
|
|
84
85
|
"modern-screenshot": "4.7.0",
|
|
85
|
-
"mongodb": "~6.20.0",
|
|
86
86
|
"pg-gateway": "0.3.0-beta.4",
|
|
87
87
|
"smol-toml": "^1.8.0",
|
|
88
88
|
"yaml": "2.9.0"
|
package/src/actions-runner.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// What it does not do (named, so a workflow that needs them fails plainly): matrices beyond their first combination,
|
|
9
9
|
// services and containers, composite and JavaScript actions from the marketplace, reusable workflows, concurrency groups.
|
|
10
10
|
import { errorMessage } from './error-message.ts';
|
|
11
|
+
import { worldRequiredCredentialGate } from './runtime.ts';
|
|
11
12
|
import { spawn } from 'node:child_process';
|
|
12
13
|
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
14
|
import { join } from 'node:path';
|
|
@@ -102,15 +103,36 @@ function readKv(file: string): Record<string, string> {
|
|
|
102
103
|
return out;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function sh(command: string, cwd: string, env: Record<string, string>, shell: string, log: (l: string) => void): Promise<number> {
|
|
106
|
-
|
|
106
|
+
function sh(command: string, cwd: string, env: Record<string, string>, shell: string, log: (l: string) => void, requireEnv: (env: NodeJS.ProcessEnv, boundary: string) => void): Promise<number> {
|
|
107
|
+
requireEnv(env, 'Actions command');
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
107
109
|
const file = join(cwd, `.volter-step-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`);
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
let failure: unknown = undefined;
|
|
111
|
+
const finish = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
112
|
+
const primary = failure ?? (signal ? Object.assign(new Error(`Actions command terminated by ${signal}`), { signal })
|
|
113
|
+
: code === null ? new Error('Actions command closed without an exit status') : undefined);
|
|
114
|
+
try { rmSync(file, { force: true }); }
|
|
115
|
+
catch (cleanup) {
|
|
116
|
+
reject(primary === undefined ? cleanup : new AggregateError([primary, cleanup], 'Actions command and script cleanup failed', { cause: primary }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (primary !== undefined) reject(primary);
|
|
120
|
+
else resolve(code!);
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(file, command);
|
|
124
|
+
const argv = shell === 'pwsh' ? ['pwsh', ['-File', file]] as const : shell === 'sh' ? ['sh', ['-e', file]] as const : ['bash', ['--noprofile', '--norc', '-eo', 'pipefail', file]] as const;
|
|
125
|
+
const child = spawn(argv[0], [...argv[1]], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
126
|
+
// Spawn errors settle at close, after the owned child's streams close; retain the original native cause.
|
|
127
|
+
child.on('error', (error: Error) => { failure ??= error; });
|
|
128
|
+
const output = (d: Buffer): void => {
|
|
129
|
+
try { for (const l of d.toString().split('\n')) if (l) log(` ${l}`); }
|
|
130
|
+
catch (error) { failure ??= error; }
|
|
131
|
+
};
|
|
132
|
+
child.stdout.on('data', output);
|
|
133
|
+
child.stderr.on('data', output);
|
|
134
|
+
child.on('close', finish);
|
|
135
|
+
} catch (error) { failure = error; finish(null, null); }
|
|
114
136
|
});
|
|
115
137
|
}
|
|
116
138
|
|
|
@@ -138,6 +160,11 @@ function builtin(step: Step, env: Record<string, string>, workspace: string): {
|
|
|
138
160
|
/** One run: started, each job's steps run in order of `needs`, completed with its conclusion. */
|
|
139
161
|
export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row): Promise<'success' | 'failure'> {
|
|
140
162
|
const log = opts.log ?? (() => {});
|
|
163
|
+
// The runner's original World context, before job/step overrides, owns its required values.
|
|
164
|
+
const instancePath = process.env.VOLTER_WORLD_INSTANCE ?? opts.env.VOLTER_WORLD_INSTANCE;
|
|
165
|
+
if (!instancePath && (process.env.VOLTER_WORLD || opts.env.VOLTER_WORLD)) throw new Error('Actions runner has no local credential declaration');
|
|
166
|
+
const requireEnv = instancePath ? worldRequiredCredentialGate(JSON.parse(readFileSync(instancePath, 'utf8')))
|
|
167
|
+
: (_env: NodeJS.ProcessEnv, _boundary: string): void => {};
|
|
141
168
|
const id = Number(run.id);
|
|
142
169
|
const started = await api(opts, 'POST', `/_twin/repos/${repo}/actions/runs/${id}/start`);
|
|
143
170
|
if (started.status !== 200) { log(`run ${repo}#${id}: not started (${started.status})`); return 'failure'; }
|
|
@@ -158,7 +185,7 @@ export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row
|
|
|
158
185
|
const root = mkdtempSync(join(opts.workRoot, `run-${id}-`));
|
|
159
186
|
const workspace = join(root, 'workspace');
|
|
160
187
|
const clone = `${opts.github.replace(/\/$/, '').replace(/^(https?:\/\/)/, `$1x-access-token:${jobToken}@`)}/${repo}.git`;
|
|
161
|
-
const git = async (args: string[], cwd: string): Promise<number> => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env as Record<string, string>, ...opts.env }, 'bash', log);
|
|
188
|
+
const git = async (args: string[], cwd: string): Promise<number> => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env as Record<string, string>, ...opts.env }, 'bash', log, requireEnv);
|
|
162
189
|
let conclusion: 'success' | 'failure' = 'success';
|
|
163
190
|
try {
|
|
164
191
|
mkdirSync(root, { recursive: true });
|
|
@@ -204,6 +231,7 @@ export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row
|
|
|
204
231
|
for (const [k, v] of Object.entries(stepScope.env as Row)) env[k] = String(v);
|
|
205
232
|
if (!condition(step.if, stepScope)) { log(` step ${i + 1} ${step.name ?? step.uses ?? 'run'}: skipped`); continue; }
|
|
206
233
|
log(` step ${i + 1}: ${step.name ?? step.uses ?? String(step.run).split('\n')[0]}`);
|
|
234
|
+
requireEnv(env, 'Actions step environment');
|
|
207
235
|
let ok: boolean;
|
|
208
236
|
if (step.uses) {
|
|
209
237
|
// its inputs are expressions too (`registry-url: ${{ vars.NPM_REGISTRY }}`)
|
|
@@ -213,7 +241,7 @@ export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row
|
|
|
213
241
|
ok = r.ok;
|
|
214
242
|
} else {
|
|
215
243
|
const cwd = join(workspace, step['working-directory'] ?? job.defaults?.run?.['working-directory'] ?? '');
|
|
216
|
-
ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log)) === 0;
|
|
244
|
+
ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log, requireEnv)) === 0;
|
|
217
245
|
}
|
|
218
246
|
if (step.id) (scope.steps as Row)[step.id] = { outputs: readKv(files.output), outcome: ok ? 'success' : 'failure', conclusion: ok || step['continue-on-error'] ? 'success' : 'failure' };
|
|
219
247
|
if (!ok && !step['continue-on-error']) failed = true;
|