@lapxo/bound 0.1.2 → 0.1.4
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/README.md +5 -5
- package/TARGET.bound +27 -20
- package/dist/cli/accept.js +112 -71
- package/dist/cli/owner.js +3 -0
- package/dist/cli/verb.js +54 -12
- package/dist/fold/configures.js +3 -1
- package/dist/fold/digests.js +7 -1
- package/dist/fold/kept.js +5 -1
- package/dist/fold/observed.d.ts +1 -0
- package/dist/fold/observed.js +30 -11
- package/dist/fold/readers.d.ts +2 -0
- package/dist/fold/readers.js +3 -3
- package/dist/fold/region-declarations.d.ts +2 -0
- package/dist/fold/region-declarations.js +13 -0
- package/dist/fold/resolved.js +9 -1
- package/dist/fold/second.js +3 -0
- package/dist/host/adapters/transport/http.d.ts +5 -0
- package/dist/host/adapters/transport/http.js +28 -0
- package/dist/host/capsule-process.d.ts +2 -1
- package/dist/host/capsule-process.js +4 -3
- package/dist/host/capsule.d.ts +1 -0
- package/dist/host/capsule.js +11 -7
- package/dist/host/cell-receipts.js +1 -1
- package/dist/host/content-needed.d.ts +5 -0
- package/dist/host/content-needed.js +8 -0
- package/dist/host/content-store.d.ts +8 -0
- package/dist/host/content-store.js +31 -0
- package/dist/host/lifetime.d.ts +1 -0
- package/dist/host/lifetime.js +40 -0
- package/dist/host/objects.js +1 -1
- package/dist/host/ports/transport.d.ts +1 -0
- package/dist/host/ports/transport.js +48 -0
- package/dist/host/process-lifetime.d.ts +11 -0
- package/dist/host/process-lifetime.js +12 -0
- package/dist/host/read-only.d.ts +3 -0
- package/dist/host/read-only.js +9 -0
- package/dist/host/release.js +2 -1
- package/dist/host/selected-capsules.js +38 -12
- package/dist/host/selected-topos.d.ts +5 -5
- package/dist/host/selected-topos.js +9 -36
- package/dist/host/source-content.d.ts +5 -3
- package/dist/host/source-content.js +36 -30
- package/dist/land/leaving.js +4 -0
- package/dist/land/ledger.js +3 -0
- package/dist/observe/identity.d.ts +2 -0
- package/dist/observe/identity.js +3 -3
- package/dist/observe/regions.js +2 -2
- package/dist/observe/run.d.ts +2 -1
- package/dist/observe/run.js +10 -4
- package/dist/render/receipts.js +2 -1
- package/package.json +2 -2
- package/receipts.bound +73 -72
package/dist/fold/readers.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Capsule } from '../host/capsule.ts';
|
|
1
2
|
import type { CoordinateRole } from './wire.ts';
|
|
2
3
|
import { capsulesFor } from './reach.ts';
|
|
3
4
|
import type { Kind } from '../observe/run.ts';
|
|
@@ -8,6 +9,7 @@ export interface ReaderClaim {
|
|
|
8
9
|
readonly module: string;
|
|
9
10
|
readonly kind: Kind;
|
|
10
11
|
readonly capsule?: {
|
|
12
|
+
readonly selected?: Capsule;
|
|
11
13
|
readonly digest: string;
|
|
12
14
|
readonly entry: string;
|
|
13
15
|
readonly region: string;
|
package/dist/fold/readers.js
CHANGED
|
@@ -62,13 +62,13 @@ export function capsuleReadersOf(root, store, standing, lines, resolve = capsule
|
|
|
62
62
|
const pkg = fieldOf(by?.offer ?? '', 'scope').split('/')[1] ?? '';
|
|
63
63
|
if (by === undefined || reaching.has(`${pkg} ${region} ${where || '/'}`))
|
|
64
64
|
continue;
|
|
65
|
-
const key = `${by.capsule.digest} ${region}`;
|
|
66
|
-
const group = groups.get(key) ?? groups.set(key, { module: `${pkg}/${region}`, digest: by.capsule.digest, entry: fieldOf(by.offer, 'shape'), region, reads: by.capsule.declaration.regions[region] ?? [], globs: new Map() }).get(key);
|
|
65
|
+
const key = `${by.capsule.selection ?? by.capsule.digest} ${region}`;
|
|
66
|
+
const group = groups.get(key) ?? groups.set(key, { module: `${pkg}/${region}`, digest: by.capsule.digest, entry: fieldOf(by.offer, 'shape'), selected: by.capsule, region, reads: by.capsule.declaration.regions[region] ?? [], globs: new Map() }).get(key);
|
|
67
67
|
group.globs.set(where, fieldOf(line, 'value').split('|').filter(Boolean));
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
return [...groups.values()].map((group) => ({ id: group.module, shape: ['run'], where: [...group.globs.keys()], module: group.module, kind: 'process',
|
|
71
|
-
capsule: { digest: group.digest, entry: group.entry, region: group.region, reads: group.reads, globs: group.globs } }));
|
|
71
|
+
capsule: { selected: group.selected, digest: group.digest, entry: group.entry, region: group.region, reads: group.reads, globs: group.globs } }));
|
|
72
72
|
}
|
|
73
73
|
export function handedAt(root, reader, roleOf, look, place) {
|
|
74
74
|
const { reads, globs } = reader.capsule;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { fieldOf, isConfig } from "./claims.js";
|
|
2
|
+
import { wordsOf } from "./wire.js";
|
|
3
|
+
/** Region input selectors consumed by the host, rather than measured ceilings. */
|
|
4
|
+
export function isRegionDeclaration(line, wire) {
|
|
5
|
+
if (!isConfig(line) || !fieldOf(line, 'scope').startsWith('region/') || fieldOf(line, 'value') === 'withdraw')
|
|
6
|
+
return false;
|
|
7
|
+
const named = ['region-coordinate', 'region-leaf'].flatMap(name => {
|
|
8
|
+
const words = wordsOf(wire, name);
|
|
9
|
+
return words.length === 1 ? words : [];
|
|
10
|
+
});
|
|
11
|
+
const measures = ['lines', 'receipts', 'reads', ...named];
|
|
12
|
+
return measures.includes(fieldOf(line, 'measure'));
|
|
13
|
+
}
|
package/dist/fold/resolved.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOnly } from "../host/read-only.js";
|
|
1
2
|
import { createHash, dirname, existsSync, join, linkSync, mkdirSync, readdirSync, renameSync, writeFileSync } from "../host/io.js";
|
|
2
3
|
import { canonical, parse } from '@lapxo/topos/wire';
|
|
3
4
|
import { RECEIPTS, fieldOf, isWire } from "./claims.js";
|
|
@@ -11,6 +12,10 @@ import { sourced } from "../land/vouched.js";
|
|
|
11
12
|
import { entriesIn, observeFile, observeText } from "../observe/files.js";
|
|
12
13
|
export const bytesOnly = (line) => {
|
|
13
14
|
const got = parse(line);
|
|
15
|
+
// Authenticated evidence is delivered verbatim. A shortened observation is
|
|
16
|
+
// a different signed message, even when it describes the same input.
|
|
17
|
+
if (got.kind === 'fact' && got.value.fields.sig)
|
|
18
|
+
return line;
|
|
14
19
|
return got.kind === 'fact' && fieldOf(line, 'measure') === 'observed' ? canonical({ ...got.value.fields, value: fieldOf(line, 'at').replace(/^.*:/, '').slice(0, 16) }) : line;
|
|
15
20
|
};
|
|
16
21
|
const held = new WeakMap();
|
|
@@ -45,7 +50,8 @@ export function receiptsOf(fold) {
|
|
|
45
50
|
return canonical({ scope: `${sourced(fold.standing)}/${place}${shape}`, role: 'writes', form: 'alphabet', measure: 'digest', value: digest, by: writerFor(fold.standing, 'fold') ?? '', at: `place:${digest}` });
|
|
46
51
|
});
|
|
47
52
|
const place = under.replace(/\/$/, '');
|
|
48
|
-
const lines = [...named, ...[...cones].sort(), ...[...ran].sort(), ...paid]
|
|
53
|
+
const lines = [...named, ...[...cones].sort(), ...[...ran].sort(), ...paid]
|
|
54
|
+
.map((line) => (place && !fieldOf(line, 'sig') ? zoomed(line, place) : line));
|
|
49
55
|
if (!lines.length) {
|
|
50
56
|
const shipped = (observeText(join(fold.root, under, RECEIPTS)) ?? '').split('\n').filter(isWire);
|
|
51
57
|
const carried = carriedFrom(fold.store, shipped);
|
|
@@ -64,6 +70,8 @@ export function keepCarried(store, lines, from) {
|
|
|
64
70
|
verifiedCarried(at, digest);
|
|
65
71
|
}
|
|
66
72
|
else {
|
|
73
|
+
if (readOnly())
|
|
74
|
+
return `sha256:${digest}`;
|
|
67
75
|
mkdirSync(dirname(at), { recursive: true });
|
|
68
76
|
const src = from === undefined ? '' : storeAt(from, 'cas', 'carried', digest);
|
|
69
77
|
if (src && existsSync(src)) {
|
package/dist/fold/second.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOnly } from "../host/read-only.js";
|
|
1
2
|
import { snapshotVerdict } from "./evidence.js";
|
|
2
3
|
import { dirname, resolve, spawnSync } from "../host/io.js";
|
|
3
4
|
import { ledgerLines } from "../land/ledger.js";
|
|
@@ -9,6 +10,8 @@ import { said } from "./wire.js";
|
|
|
9
10
|
import { sha } from "../host/hash.js";
|
|
10
11
|
const asked = new Map();
|
|
11
12
|
function askedNow(store, rests) {
|
|
13
|
+
if (readOnly())
|
|
14
|
+
return undefined;
|
|
12
15
|
const root = dirname(store);
|
|
13
16
|
const key = `${root} ${rests}`;
|
|
14
17
|
if (asked.has(key))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Reference HTTP adapter. Location and budgets are already selected by admitted host policy. */
|
|
2
|
+
export async function fetchBytes(location, limits) {
|
|
3
|
+
const signal = AbortSignal.timeout(limits.timeoutMs), response = await fetch(location, { signal });
|
|
4
|
+
if (!response.ok)
|
|
5
|
+
throw Error(`HTTP ${response.status}`);
|
|
6
|
+
const reader = response.body?.getReader();
|
|
7
|
+
if (!reader)
|
|
8
|
+
return new Uint8Array();
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let size = 0;
|
|
11
|
+
try {
|
|
12
|
+
for (;;) {
|
|
13
|
+
const part = await reader.read();
|
|
14
|
+
if (part.done)
|
|
15
|
+
break;
|
|
16
|
+
size += part.value.length;
|
|
17
|
+
if (size > limits.responseBytes) {
|
|
18
|
+
await reader.cancel();
|
|
19
|
+
throw Error('response exceeds declared byte bound');
|
|
20
|
+
}
|
|
21
|
+
chunks.push(part.value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
reader.releaseLock();
|
|
26
|
+
}
|
|
27
|
+
return Buffer.concat(chunks);
|
|
28
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReaderLifetime } from '@lapxo/topos/wire';
|
|
1
2
|
import type { SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
2
3
|
import type { Request, Response } from '@lapxo/topos/contract';
|
|
3
4
|
/** Operational process failure, distinct from a valid contract refusal or abstention. */
|
|
@@ -17,4 +18,4 @@ export declare class CapsuleProcessError extends Error {
|
|
|
17
18
|
}
|
|
18
19
|
export declare function validResponse(value: unknown, request: Request): value is Response;
|
|
19
20
|
/** Last-line JSON is the wire framing. No partial batch, failed exit or synthetic missing response is accepted. */
|
|
20
|
-
export declare function executeCapsule(command: string, args: readonly string[], requests: readonly Request[], limits?: Pick<SpawnSyncOptionsWithStringEncoding, 'timeout' | 'maxBuffer'
|
|
21
|
+
export declare function executeCapsule(command: string, args: readonly string[], requests: readonly Request[], limits?: Pick<SpawnSyncOptionsWithStringEncoding, 'timeout' | 'maxBuffer'>, lifetime?: ReaderLifetime): readonly Response[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boundedProcess } from "./process-lifetime.js";
|
|
1
2
|
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { PROTOCOL } from '@lapxo/topos/wire';
|
|
3
4
|
/** Operational process failure, distinct from a valid contract refusal or abstention. */
|
|
@@ -31,9 +32,9 @@ export function validResponse(value, request) {
|
|
|
31
32
|
return true;
|
|
32
33
|
}
|
|
33
34
|
/** Last-line JSON is the wire framing. No partial batch, failed exit or synthetic missing response is accepted. */
|
|
34
|
-
export function executeCapsule(command, args, requests, limits = {}) {
|
|
35
|
-
const run = spawnSync(command, args, { input: JSON.stringify(requests), encoding: 'utf8', maxBuffer: 1 << 26, ...limits });
|
|
36
|
-
if (run.error)
|
|
35
|
+
export function executeCapsule(command, args, requests, limits = {}, lifetime) {
|
|
36
|
+
const run = lifetime === undefined ? spawnSync(command, args, { input: JSON.stringify(requests), encoding: 'utf8', maxBuffer: 1 << 26, ...limits }) : boundedProcess(command, args, JSON.stringify(requests), lifetime);
|
|
37
|
+
if ('error' in run && run.error)
|
|
37
38
|
throw new CapsuleProcessError(`failed: ${run.error.message}`, run);
|
|
38
39
|
if (run.signal !== null || run.status !== 0)
|
|
39
40
|
throw new CapsuleProcessError(`exited ${run.status ?? `with signal ${run.signal}`}`, run);
|
package/dist/host/capsule.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Declaration } from '@lapxo/topos/capsule';
|
|
2
2
|
import type { Request, Response } from '@lapxo/topos/contract';
|
|
3
3
|
export interface Capsule {
|
|
4
|
+
readonly selection?: string;
|
|
4
5
|
readonly digest: string;
|
|
5
6
|
readonly declaration: Declaration;
|
|
6
7
|
readonly lines: readonly string[];
|
package/dist/host/capsule.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readerLifetime, parse } from '@lapxo/topos/wire';
|
|
2
|
+
import { contentStore, verifiedContent, contentPolicy } from "./content-store.js";
|
|
3
|
+
import { readOnly } from "./read-only.js";
|
|
1
4
|
import { sha } from "./hash.js";
|
|
2
5
|
import { wireLinesOf as linesOf } from "../fold/claims.js";
|
|
3
6
|
import { createHash } from 'node:crypto';
|
|
@@ -8,7 +11,7 @@ import { CAPSULE } from '@lapxo/topos/wire';
|
|
|
8
11
|
import { declarationOf } from '@lapxo/topos/capsule';
|
|
9
12
|
import { unpack } from "./archive.js";
|
|
10
13
|
import { executeCapsule, validResponse } from "./capsule-process.js";
|
|
11
|
-
import {
|
|
14
|
+
import { holdStore, replaceWhole, storeAt } from "../land/ledger.js";
|
|
12
15
|
import { coordinatesUnder, observeFile, observeText } from "../observe/files.js";
|
|
13
16
|
import { hostOf } from "../observe/run.js";
|
|
14
17
|
import { ownLock, ownRoot, ownStore, processOf } from "../observe/runner.js";
|
|
@@ -42,13 +45,12 @@ function unpacked(digest, bytes) {
|
|
|
42
45
|
const capsules = new Map();
|
|
43
46
|
/** A blob is loaded once in an act. A later ask for the same digest reads that capsule, and does not hash or unpack it again. */
|
|
44
47
|
export function capsuleAt(digest, entry, store) {
|
|
45
|
-
const
|
|
48
|
+
const cacheStore = store ?? contentStore();
|
|
49
|
+
const { bytes } = verifiedContent(digest, cacheStore);
|
|
50
|
+
const identity = `${cacheStore}\0${digest}\0${entry}`;
|
|
46
51
|
const held = capsules.get(identity);
|
|
47
52
|
if (held !== undefined)
|
|
48
53
|
return held;
|
|
49
|
-
const bytes = observeFile(blobAt(store ?? ownStore(), digest));
|
|
50
|
-
if (bytes === undefined || `sha256:${createHash('sha256').update(bytes).digest('hex')}` !== digest)
|
|
51
|
-
return undefined;
|
|
52
54
|
const known = declared.get(digest) ?? ((lines) => ({ lines, declaration: declarationOf(lines) }))(linesOf(observeText(join(unpacked(digest, bytes), CAPSULE))));
|
|
53
55
|
declared.set(digest, known);
|
|
54
56
|
const names = Object.keys(known.declaration.regions);
|
|
@@ -61,7 +63,7 @@ export function capsuleAt(digest, entry, store) {
|
|
|
61
63
|
const capsule = { digest, ...known, ask: (requests) => {
|
|
62
64
|
if (!statSync(module, { throwIfNoEntry: false })?.isFile())
|
|
63
65
|
throw new Error(`REFUSE·capsule ${digest} declared entry ${entry} unavailable`);
|
|
64
|
-
return answered(digest, bytes, requests, names, root,
|
|
66
|
+
return answered(digest, bytes, requests, names, root, cacheStore, module);
|
|
65
67
|
} };
|
|
66
68
|
capsules.set(identity, capsule);
|
|
67
69
|
return capsule;
|
|
@@ -108,10 +110,12 @@ function answered(digest, bytes, requests, names, tree, cacheStore = ownStore(),
|
|
|
108
110
|
}
|
|
109
111
|
});
|
|
110
112
|
const missing = requests.filter((_, i) => held[i] === undefined);
|
|
113
|
+
if (missing.length && readOnly())
|
|
114
|
+
throw Error(`REFUSE·preview capsule ${digest} has no verified answer for the proposed inputs; fold the declared inputs first`);
|
|
111
115
|
if (missing.length)
|
|
112
116
|
process.stderr.write(`CAPSULE ${digest.slice(7, 19)} · ${requests.length} asked · ${missing.length} run · ${missing.map((one) => `${one.rootScope}${one.region}`).join(' ')}\n`);
|
|
113
117
|
// Execute and validate the entire batch before committing any answer to the cache.
|
|
114
|
-
const ran = !missing.length ? [] : executeCapsule(process.execPath, [...childFlags(), hostOf(), ...child], missing);
|
|
118
|
+
const ran = !missing.length ? [] : executeCapsule(process.execPath, [...childFlags(), hostOf(), ...child], missing, {}, readerLifetime(contentPolicy().flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; }), digest));
|
|
115
119
|
return requests.map((request, i) => {
|
|
116
120
|
if (held[i] !== undefined)
|
|
117
121
|
return held[i];
|
|
@@ -43,7 +43,7 @@ export async function cellsWithReceipts(root, history, emit) {
|
|
|
43
43
|
for (const record of parsed.value.objects.filter(r => r.record === 'cell')) {
|
|
44
44
|
const pin = record.fields.topos;
|
|
45
45
|
if (!pins.has(pin)) {
|
|
46
|
-
const top = toposAt(pin);
|
|
46
|
+
const top = toposAt(pin, store);
|
|
47
47
|
pins.set(pin, [pin, ...top.dependencies]);
|
|
48
48
|
}
|
|
49
49
|
const scope = `receipts/cells/${record.fields.scope}`;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The synchronous host names unavailable content; the enclosing fold resolves exactly that request. */
|
|
2
|
+
export class ContentNeeded extends Error {
|
|
3
|
+
constructor(digest, why = `REFUSE·pin ${digest} content unavailable · not laid`) {
|
|
4
|
+
super(why);
|
|
5
|
+
this.digest = digest;
|
|
6
|
+
this.name = 'ContentNeeded';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const withContentStore: <T>(store: string, run: () => T, policy?: () => readonly string[]) => T;
|
|
2
|
+
export declare const contentPolicy: () => readonly string[];
|
|
3
|
+
export declare const contentStore: () => string;
|
|
4
|
+
/** Only immutable verified bytes may be shared with the instrument. Local corruption is never hidden by fallback. */
|
|
5
|
+
export declare function verifiedContent(digest: string, store?: string, instrument?: string): {
|
|
6
|
+
readonly bytes: Uint8Array;
|
|
7
|
+
readonly path: string;
|
|
8
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { blobAt } from "../land/ledger.js";
|
|
4
|
+
import { observeFile } from "../observe/files.js";
|
|
5
|
+
import { ownLock, ownStore } from "../observe/runner.js";
|
|
6
|
+
import { parse, wireAt } from '@lapxo/topos/wire';
|
|
7
|
+
import { ContentNeeded } from "./content-needed.js";
|
|
8
|
+
// Each invocation carries its place explicitly; asynchronous requests cannot share a mutable current root.
|
|
9
|
+
const requested = new AsyncLocalStorage();
|
|
10
|
+
export const withContentStore = (store, run, policy = () => []) => requested.run({ store, policy }, run);
|
|
11
|
+
export const contentPolicy = () => requested.getStore()?.policy() ?? [];
|
|
12
|
+
export const contentStore = () => requested.getStore()?.store ?? ownStore();
|
|
13
|
+
/** Only immutable verified bytes may be shared with the instrument. Local corruption is never hidden by fallback. */
|
|
14
|
+
export function verifiedContent(digest, store = contentStore(), instrument = ownStore()) {
|
|
15
|
+
const fields = ownLock().flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; });
|
|
16
|
+
const wire = wireAt(fields, Number.MAX_SAFE_INTEGER);
|
|
17
|
+
if (!wire)
|
|
18
|
+
throw Error('REFUSE·pin instrument has no admitted wire');
|
|
19
|
+
const split = digest.indexOf(':'), algorithm = digest.slice(0, split), hex = digest.slice(split + 1);
|
|
20
|
+
if (split < 1 || !wire.digests.has(algorithm) || !/^[a-f0-9]+$/.test(hex))
|
|
21
|
+
throw Error(`REFUSE·pin ${digest} digest is not admitted`);
|
|
22
|
+
for (const at of new Set([store, instrument])) {
|
|
23
|
+
const path = blobAt(at, digest), bytes = observeFile(path);
|
|
24
|
+
if (bytes === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
if (createHash(algorithm).update(bytes).digest('hex') !== hex)
|
|
27
|
+
throw Error(`REFUSE·pin ${digest} content hash mismatch · store ${at} · not laid`);
|
|
28
|
+
return { bytes, path };
|
|
29
|
+
}
|
|
30
|
+
throw new ContentNeeded(digest);
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
// This is a host mechanism, not a wire reader or a provider. The parent supplies its already selected policy.
|
|
4
|
+
const request = JSON.parse(readFileSync(0, 'utf8'));
|
|
5
|
+
if (!Number.isSafeInteger(request.limits.timeoutMs) || request.limits.timeoutMs <= 0 || !Number.isSafeInteger(request.limits.responseBytes) || request.limits.responseBytes <= 0)
|
|
6
|
+
throw Error('REFUSE·reader invalid process limits');
|
|
7
|
+
const grouped = process.platform !== 'win32';
|
|
8
|
+
const child = spawn(request.command, [...request.args], { cwd: request.cwd, env: request.env, detached: grouped, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
9
|
+
const stdout = [], stderr = [];
|
|
10
|
+
let size = 0, failure, finished = false;
|
|
11
|
+
const cancel = () => { if (child.pid === undefined)
|
|
12
|
+
return; try {
|
|
13
|
+
if (grouped)
|
|
14
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
15
|
+
else
|
|
16
|
+
child.kill('SIGKILL');
|
|
17
|
+
}
|
|
18
|
+
catch { } };
|
|
19
|
+
const refuse = (why) => { failure ?? (failure = why); cancel(); };
|
|
20
|
+
const receive = (chunks, bytes) => { size += bytes.length; if (size > request.limits.responseBytes) {
|
|
21
|
+
refuse('response exceeds declared byte bound');
|
|
22
|
+
return;
|
|
23
|
+
} chunks.push(bytes); };
|
|
24
|
+
child.stdout.on('data', (bytes) => receive(stdout, bytes));
|
|
25
|
+
child.stderr.on('data', (bytes) => receive(stderr, bytes));
|
|
26
|
+
child.stdin.on('error', error => { if (!finished)
|
|
27
|
+
refuse(`input failed: ${error.message}`); });
|
|
28
|
+
const timer = setTimeout(() => refuse('declared timeout exhausted'), request.limits.timeoutMs);
|
|
29
|
+
const finish = (status, signal) => {
|
|
30
|
+
if (finished)
|
|
31
|
+
return;
|
|
32
|
+
finished = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
cancel();
|
|
35
|
+
const result = { stdout: failure ? '' : Buffer.concat(stdout).toString('utf8'), stdoutBytes: failure ? '' : Buffer.concat(stdout).toString('base64'), stderr: failure ? '' : Buffer.concat(stderr).toString('utf8'), status, signal, ...(failure ? { failure } : {}) };
|
|
36
|
+
process.stdout.write(JSON.stringify(result));
|
|
37
|
+
};
|
|
38
|
+
child.on('error', error => { failure = error.message; finish(null, null); });
|
|
39
|
+
child.on('close', finish);
|
|
40
|
+
child.stdin.end(request.input);
|
package/dist/host/objects.js
CHANGED
|
@@ -10,7 +10,7 @@ export async function admittedObjects(root, lines, emit = () => { }) {
|
|
|
10
10
|
if (history.kind !== 'fact')
|
|
11
11
|
throw Error(`REFUSE·wire ${history.why}`);
|
|
12
12
|
const authority = authorityFor(lines, rootSigner(root), signaturesOf(storeOf(root)).admitted);
|
|
13
|
-
const context = await objectProvider(history.value.objects, history.value.configuration, { admit: fields => authority.of(canonical(fields)).kind === 'admitted', emit, topos: toposAt, module: contextModuleAt });
|
|
13
|
+
const context = await objectProvider(history.value.objects, history.value.configuration, { admit: fields => authority.of(canonical(fields)).kind === 'admitted', emit, topos: digest => toposAt(digest, storeOf(root)), module: digest => contextModuleAt(digest, storeOf(root)) });
|
|
14
14
|
const checked = validateObjectContext(history.value.objects, context);
|
|
15
15
|
if (checked.kind !== 'fact')
|
|
16
16
|
throw Error(`REFUSE·context ${checked.why}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function transportBytes(location: string, digest: string, lines: readonly string[]): Promise<Uint8Array>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { contentRequest, transportSelection, parse } from '@lapxo/topos/wire';
|
|
3
|
+
import { boundedProcess } from "../process-lifetime.js";
|
|
4
|
+
import { fetchBytes } from "../adapters/transport/http.js";
|
|
5
|
+
export async function transportBytes(location, digest, lines) {
|
|
6
|
+
const scheme = new URL(location).protocol.slice(0, -1);
|
|
7
|
+
const fields = lines.flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; });
|
|
8
|
+
const selected = transportSelection(fields, scheme);
|
|
9
|
+
if (selected === undefined)
|
|
10
|
+
throw Error(`REFUSE·transport ${scheme} no admitted content-request/1 policy`);
|
|
11
|
+
if (selected.name === 'fetch') {
|
|
12
|
+
if (!['http', 'https'].includes(scheme))
|
|
13
|
+
throw Error(`REFUSE·transport ${scheme} is not supported by this host adapter`);
|
|
14
|
+
return fetchBytes(location, selected);
|
|
15
|
+
}
|
|
16
|
+
const registryPath = process.env['BOUND_TRANSPORTS'];
|
|
17
|
+
if (!registryPath)
|
|
18
|
+
throw Error(`REFUSE·transport ${scheme} no host binding for ${selected.name}`);
|
|
19
|
+
let record;
|
|
20
|
+
try {
|
|
21
|
+
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
22
|
+
if (!registry || typeof registry !== 'object' || !Object.prototype.hasOwnProperty.call(registry, selected.name))
|
|
23
|
+
throw Error();
|
|
24
|
+
const value = registry[selected.name];
|
|
25
|
+
if (!value || typeof value !== 'object')
|
|
26
|
+
throw Error();
|
|
27
|
+
const f = value;
|
|
28
|
+
if (f.kind !== 'command' || typeof f.executable !== 'string' || !f.executable || !Array.isArray(f.args) || !f.args.every(x => typeof x === 'string'))
|
|
29
|
+
throw Error();
|
|
30
|
+
if (f.environment !== undefined && (!Array.isArray(f.environment) || !f.environment.every(x => typeof x === 'string')))
|
|
31
|
+
throw Error();
|
|
32
|
+
const names = (f.environment ?? []);
|
|
33
|
+
const env = {};
|
|
34
|
+
if (process.env.PATH !== undefined)
|
|
35
|
+
env.PATH = process.env.PATH;
|
|
36
|
+
for (const name of names)
|
|
37
|
+
if (process.env[name] !== undefined)
|
|
38
|
+
env[name] = process.env[name];
|
|
39
|
+
record = { executable: f.executable, args: f.args, env };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw Error(`REFUSE·transport ${scheme} host binding unavailable or invalid`);
|
|
43
|
+
}
|
|
44
|
+
const result = boundedProcess(record.executable, record.args, contentRequest(location, digest, selected), selected, undefined, record.env);
|
|
45
|
+
if (result.status !== 0 || result.signal !== null)
|
|
46
|
+
throw Error(`REFUSE·transport ${scheme} failed · status ${result.status ?? result.signal}`);
|
|
47
|
+
return Buffer.from(result.stdoutBytes ?? '', 'base64');
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReaderLifetime } from '@lapxo/topos/wire';
|
|
2
|
+
export interface ProcessResult {
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
readonly stdoutBytes?: string;
|
|
5
|
+
readonly stderr: string;
|
|
6
|
+
readonly status: number | null;
|
|
7
|
+
readonly signal: NodeJS.Signals | null;
|
|
8
|
+
readonly failure?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A synchronous fold waits on a supervising host process; the supervisor owns cancellation of its child group. */
|
|
11
|
+
export declare function boundedProcess(command: string, args: readonly string[], input: string, limits: ReaderLifetime, cwd?: string, env?: Readonly<Record<string, string>>): ProcessResult;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { processOf } from "../observe/runner.js";
|
|
3
|
+
/** A synchronous fold waits on a supervising host process; the supervisor owns cancellation of its child group. */
|
|
4
|
+
export function boundedProcess(command, args, input, limits, cwd, env) {
|
|
5
|
+
const run = spawnSync(process.execPath, [processOf('lifetime', 'host')], { input: JSON.stringify({ command, args, input, limits, cwd, env }), encoding: 'utf8', maxBuffer: limits.responseBytes * 12 + 4096 });
|
|
6
|
+
if (run.error || run.status !== 0)
|
|
7
|
+
throw Error(`REFUSE·reader lifetime supervisor ${run.error?.message ?? run.status}`);
|
|
8
|
+
const result = JSON.parse(run.stdout);
|
|
9
|
+
if (result.failure)
|
|
10
|
+
throw Error(`REFUSE·reader ${result.failure} · status ${result.status ?? result.signal ?? 'cancelled'}`);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
/** Prospective evaluation may read existing evidence and calculate values, but cannot acquire evidence or persist caches. */
|
|
3
|
+
const mode = new AsyncLocalStorage();
|
|
4
|
+
export const readOnly = () => mode.getStore() === true;
|
|
5
|
+
export const withoutEffects = (evaluate) => mode.run(true, evaluate);
|
|
6
|
+
export function requireEffects(operation) {
|
|
7
|
+
if (readOnly())
|
|
8
|
+
throw Error(`REFUSE·preview ${operation} requires an act; fold the declared inputs first`);
|
|
9
|
+
}
|
package/dist/host/release.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ContentNeeded } from "./content-needed.js";
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { dirname, join, sep } from 'node:path';
|
|
@@ -143,7 +144,7 @@ export function layReleases(store, own, lock, contentStore = own) {
|
|
|
143
144
|
if (bytes !== undefined && current !== undefined && !dir && !sameSnapshot(snapshotOf(current), members(bytes).map((one) => [one.name, digestOf(one.bytes)])))
|
|
144
145
|
throw new Error(`REFUSE·release snapshot for ${digest} omits or changes archive members; explicit rebuild required`);
|
|
145
146
|
if (bytes === undefined && !dir && current === undefined)
|
|
146
|
-
throw new
|
|
147
|
+
throw new ContentNeeded(digest, `REFUSE·release ${fieldOf(line, 'scope')} has no available archive or snapshot`);
|
|
147
148
|
const root = fieldOf(line, 'shape');
|
|
148
149
|
const place = join(own, archiveCoordinate(needs));
|
|
149
150
|
const link = lstatSync(place, { throwIfNoEntry: false });
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { capsuleAt } from "./capsule.js";
|
|
2
|
-
import { capsuleOffers } from '@lapxo/topos/standing';
|
|
2
|
+
import { capsuleOffers, providerInputs, providerInputRegions, capsuleDescriptor, assertCapsuleDescriptor } from '@lapxo/topos/standing';
|
|
3
|
+
import { fullDigest } from "./digest.js";
|
|
4
|
+
import { wireAt, parse } from '@lapxo/topos/wire';
|
|
3
5
|
import { selectedTopoi, toposAt } from "./selected-topos.js";
|
|
4
6
|
import { canonical } from '@lapxo/topos/wire';
|
|
5
7
|
import { fieldOf } from "../fold/claims.js";
|
|
@@ -8,16 +10,40 @@ import { archiveCoordinate } from "./archive.js";
|
|
|
8
10
|
/** Only explicit capsule offers request execution. Forms/classes/views do not need an executable alias. */
|
|
9
11
|
export function selectedCapsules(pins, resolve = capsuleAt, select = toposAt, host = ownLock()) {
|
|
10
12
|
return selectedTopoi(pins, select).flatMap(({ digest, topos }) => capsuleOffers(topos).map(offer => {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
const load = () => {
|
|
14
|
+
const projections = host.filter(line => fieldOf(line, 'scope').startsWith('dep/') && fieldOf(line, 'role') === 'writes' && fieldOf(line, 'value') === offer.value);
|
|
15
|
+
if (!projections.length || projections.some(line => !fieldOf(line, 'shape')))
|
|
16
|
+
throw Error(`REFUSE·pin ${digest} capsule ${offer.value} needs one host projection`);
|
|
17
|
+
const entries = [...new Set(projections.map(line => archiveCoordinate(fieldOf(line, 'shape'))))];
|
|
18
|
+
if (entries.length !== 1)
|
|
19
|
+
throw Error(`REFUSE·pin ${digest} capsule ${offer.value} has conflicting host projections`);
|
|
20
|
+
const loaded = resolve(offer.value, entries[0]);
|
|
21
|
+
if (loaded === undefined)
|
|
22
|
+
throw Error(`REFUSE·pin ${digest} · ${offer.scope} · offered capsule ${offer.value} unavailable`);
|
|
23
|
+
return { capsule: loaded, offer: canonical({ ...offer, shape: entries[0] }) };
|
|
24
|
+
};
|
|
25
|
+
const descriptor = capsuleDescriptor(topos, offer.scope ?? '');
|
|
26
|
+
const loaded = descriptor === undefined ? load() : undefined;
|
|
27
|
+
const capsule = loaded?.capsule ?? {
|
|
28
|
+
digest: offer.value, selection: `${digest} ${offer.scope}`, ...descriptor, ask: requests => {
|
|
29
|
+
if (!requests.length)
|
|
30
|
+
return [];
|
|
31
|
+
const actual = load();
|
|
32
|
+
assertCapsuleDescriptor(descriptor.lines, actual.capsule.lines);
|
|
33
|
+
return actual.capsule.ask(requests);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const projected = loaded?.offer ?? canonical(offer);
|
|
37
|
+
if (!providerInputRegions(topos, offer.scope ?? '').length)
|
|
38
|
+
return { capsule, offer: projected };
|
|
39
|
+
const fields = ownLock().flatMap(line => { const p = parse(line); return p.kind === 'fact' ? [p.value.fields] : []; });
|
|
40
|
+
const wire = wireAt(fields, Number.MAX_SAFE_INTEGER), algorithm = wire && [...wire.digests][0];
|
|
41
|
+
if (!algorithm)
|
|
42
|
+
throw Error('REFUSE·input instrument has no admitted digest algorithm');
|
|
43
|
+
const selected = { ...capsule, selection: `${digest} ${offer.scope}`, ask: requests => capsule.ask(requests.map(request => {
|
|
44
|
+
const provider = providerInputs(topos, digest, offer.scope ?? '', offer.value, request.region ?? '', bytes => fullDigest(bytes, algorithm));
|
|
45
|
+
return provider === undefined ? request : { ...request, provider };
|
|
46
|
+
})) };
|
|
47
|
+
return { capsule: selected, offer: projected };
|
|
22
48
|
}));
|
|
23
49
|
}
|
|
@@ -6,11 +6,11 @@ export interface SelectedTopos {
|
|
|
6
6
|
}
|
|
7
7
|
export type ToposResolver = (digest: string) => StandingTopos;
|
|
8
8
|
/** Resolve content using the instrument wire's admitted digest algorithms. No transport interpretation. */
|
|
9
|
-
export declare function toposAt(digest: string): StandingTopos;
|
|
10
|
-
export declare function contentAt(digest: string): Uint8Array;
|
|
11
|
-
/**
|
|
12
|
-
export declare function contextModuleAt(digest: string): string;
|
|
13
|
-
/** Selection verifies
|
|
9
|
+
export declare function toposAt(digest: string, store?: string): StandingTopos;
|
|
10
|
+
export declare function contentAt(digest: string, store?: string): Uint8Array;
|
|
11
|
+
/** The declared module runs from the store that supplied its verified bytes. No copy or live-checkout fallback. */
|
|
12
|
+
export declare function contextModuleAt(digest: string, store?: string): string;
|
|
13
|
+
/** Selection verifies standing identity. Requested capabilities verify their artifact bytes separately. */
|
|
14
14
|
export declare function selectedTopoi(pins: readonly string[], resolve?: ToposResolver): readonly SelectedTopos[];
|
|
15
15
|
/** Read a declared wire list without copying a world's claims or executing its offers.
|
|
16
16
|
* Independent declarations must agree; selection order cannot choose a contract.
|
|
@@ -1,47 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { verifiedContent } from "./content-store.js";
|
|
2
2
|
import { readStanding } from '@lapxo/topos/standing';
|
|
3
3
|
import { parse, wireAt } from '@lapxo/topos/wire';
|
|
4
4
|
import { fieldOf } from "../fold/claims.js";
|
|
5
|
-
import { blobAt } from "../land/ledger.js";
|
|
6
|
-
import { observeFile } from "../observe/files.js";
|
|
7
|
-
import { ownStore, ownLock } from "../observe/runner.js";
|
|
8
|
-
import { replaceWhole } from "../land/ledger.js";
|
|
9
|
-
import { join } from 'node:path';
|
|
10
5
|
/** Resolve content using the instrument wire's admitted digest algorithms. No transport interpretation. */
|
|
11
|
-
export function toposAt(digest) {
|
|
12
|
-
const topos = readStanding(Buffer.from(contentAt(digest)).toString('utf8'));
|
|
13
|
-
for (const dependency of topos.dependencies)
|
|
14
|
-
contentAt(dependency);
|
|
6
|
+
export function toposAt(digest, store) {
|
|
7
|
+
const topos = readStanding(Buffer.from(contentAt(digest, store)).toString('utf8'));
|
|
15
8
|
return topos;
|
|
16
9
|
}
|
|
17
|
-
export function contentAt(digest) {
|
|
18
|
-
|
|
19
|
-
const wire = wireAt(fields, Number.MAX_SAFE_INTEGER);
|
|
20
|
-
if (wire === null)
|
|
21
|
-
throw Error('REFUSE·pin instrument has no admitted wire');
|
|
22
|
-
const algorithms = wire.digests;
|
|
23
|
-
const bytesAt = (name) => {
|
|
24
|
-
const split = name.indexOf(':');
|
|
25
|
-
const algorithm = name.slice(0, split), hex = name.slice(split + 1);
|
|
26
|
-
if (split < 1 || !algorithms.has(algorithm) || !/^[a-f0-9]+$/.test(hex))
|
|
27
|
-
throw Error(`REFUSE·pin ${name} digest is not admitted`);
|
|
28
|
-
const bytes = observeFile(blobAt(ownStore(), name));
|
|
29
|
-
if (bytes === undefined)
|
|
30
|
-
throw Error(`REFUSE·pin ${name} content unavailable · not laid`);
|
|
31
|
-
if (createHash(algorithm).update(bytes).digest('hex') !== hex)
|
|
32
|
-
throw Error(`REFUSE·pin ${name} content hash mismatch · not laid`);
|
|
33
|
-
return bytes;
|
|
34
|
-
};
|
|
35
|
-
return bytesAt(digest);
|
|
10
|
+
export function contentAt(digest, store) {
|
|
11
|
+
return verifiedContent(digest, store).bytes;
|
|
36
12
|
}
|
|
37
|
-
/**
|
|
38
|
-
export function contextModuleAt(digest) {
|
|
39
|
-
|
|
40
|
-
const at = join(ownStore(), 'cas', 'runtime', digest.replace(':', '_') + '.mjs');
|
|
41
|
-
replaceWhole(at, bytes);
|
|
42
|
-
return at;
|
|
13
|
+
/** The declared module runs from the store that supplied its verified bytes. No copy or live-checkout fallback. */
|
|
14
|
+
export function contextModuleAt(digest, store) {
|
|
15
|
+
return verifiedContent(digest, store).path;
|
|
43
16
|
}
|
|
44
|
-
/** Selection verifies
|
|
17
|
+
/** Selection verifies standing identity. Requested capabilities verify their artifact bytes separately. */
|
|
45
18
|
export function selectedTopoi(pins, resolve = toposAt) {
|
|
46
19
|
return [...new Set(pins.map(pin => fieldOf(pin, 'value')))].map(digest => ({ digest, topos: resolve(digest) }));
|
|
47
20
|
}
|