@sentropic/h2a-runtime 0.98.7-rc.2 → 0.98.7-rc.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.
Files changed (41) hide show
  1. package/dist/cli-seat-codecs.d.ts +86 -0
  2. package/dist/cli-seat-codecs.d.ts.map +1 -0
  3. package/dist/cli-seat-codecs.js +255 -0
  4. package/dist/cli-seat-codecs.js.map +1 -0
  5. package/dist/cli-seat-fixtures.d.ts +86 -0
  6. package/dist/cli-seat-fixtures.d.ts.map +1 -0
  7. package/dist/cli-seat-fixtures.js +114 -0
  8. package/dist/cli-seat-fixtures.js.map +1 -0
  9. package/dist/cli-seat-materialization.d.ts +245 -0
  10. package/dist/cli-seat-materialization.d.ts.map +1 -0
  11. package/dist/cli-seat-materialization.js +646 -0
  12. package/dist/cli-seat-materialization.js.map +1 -0
  13. package/dist/gateway-host/live-auth.d.ts +62 -0
  14. package/dist/gateway-host/live-auth.d.ts.map +1 -0
  15. package/dist/gateway-host/live-auth.js +87 -0
  16. package/dist/gateway-host/live-auth.js.map +1 -0
  17. package/dist/gateway-host/live-disclosure.d.ts +124 -0
  18. package/dist/gateway-host/live-disclosure.d.ts.map +1 -0
  19. package/dist/gateway-host/live-disclosure.js +347 -0
  20. package/dist/gateway-host/live-disclosure.js.map +1 -0
  21. package/dist/gateway-host/live-host.d.ts +20 -0
  22. package/dist/gateway-host/live-host.d.ts.map +1 -0
  23. package/dist/gateway-host/live-host.js +14 -0
  24. package/dist/gateway-host/live-host.js.map +1 -0
  25. package/dist/gateway-model-catalog/model-catalog.d.ts +38 -0
  26. package/dist/gateway-model-catalog/model-catalog.d.ts.map +1 -0
  27. package/dist/gateway-model-catalog/model-catalog.js +167 -0
  28. package/dist/gateway-model-catalog/model-catalog.js.map +1 -0
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +29 -28
  32. package/dist/index.js.map +1 -1
  33. package/dist/llm-mesh-accounts.d.ts +57 -1
  34. package/dist/llm-mesh-accounts.d.ts.map +1 -1
  35. package/dist/llm-mesh-accounts.js +74 -3
  36. package/dist/llm-mesh-accounts.js.map +1 -1
  37. package/dist/native-host.d.ts +4 -1
  38. package/dist/native-host.d.ts.map +1 -1
  39. package/dist/native-host.js +11 -7
  40. package/dist/native-host.js.map +1 -1
  41. package/package.json +4 -4
@@ -0,0 +1,646 @@
1
+ // cli-seat-materialization.ts
2
+ //
3
+ // L2-s synthetic seat materialization (no real credential, no live call).
4
+ //
5
+ // The deterministic bootstrap validates and selects only the needed CLI
6
+ // payload, creates a private run profile, then hands a run-scoped capability
7
+ // to the final spawn helper. Credentials resolve after environment capture;
8
+ // the daemon/controller never sees provider values. h2a owns no duplicate
9
+ // store, no account-stack selection and no refresh ownership (§4.3): those
10
+ // are native Sentropic gateway responsibilities; this module holds no
11
+ // credential persistence, no timers and no re-enrollment path. Only private
12
+ // ephemeral family-admission and handoff metadata lives in XDG_RUNTIME_DIR.
13
+ import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs";
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import { isAbsolute, join, relative } from "node:path";
16
+ import { makeSyntheticCanary, makeSyntheticConsumerBundle, SEAT_BUNDLE_MAX_BYTES, SEAT_PACKAGE_PROVIDER_ID, SEAT_SCENARIOS_4 } from "./cli-seat-fixtures.js";
17
+ import { AGY_STRIPPED_ENV_VARS, CREDENTIAL_ENV_VARS, getCliSeatCodec, ensurePrivateSeatDirectory, readPrivateSeatFile, resolveCredentialPrecedence, writePrivateSeatFile, writeSeatMcpConfiguration, writeSyntheticCredentialFiles, } from "./cli-seat-codecs.js";
18
+ export class SeatMaterializationError extends Error {
19
+ code;
20
+ constructor(message, code) {
21
+ super(message);
22
+ this.name = "SeatMaterializationError";
23
+ this.code = code;
24
+ }
25
+ }
26
+ /**
27
+ * Common source-mode contract (§4.2) for all five CLIs. oauth stays available
28
+ * as the product enrollment but is never the seat-circuit origin.
29
+ */
30
+ export function resolveSourceMode(cli, mode, labProfile) {
31
+ const codec = getCliSeatCodec(cli);
32
+ if (mode !== "oauth" && mode !== "cli" && mode !== "token") {
33
+ throw new SeatMaterializationError(`unknown source mode for ${codec.cli}`, "unknown-source-mode");
34
+ }
35
+ const profile = labProfile.trim();
36
+ if (profile === "") {
37
+ throw new SeatMaterializationError("explicit lab profile required", "missing-lab-profile");
38
+ }
39
+ if (profile === "default" || profile === "daily") {
40
+ throw new SeatMaterializationError("daily profiles are never the default", "daily-profile-refused");
41
+ }
42
+ return { cli: codec.cli, mode, circuit: mode === "oauth" ? "product-oauth" : "seat", labProfile: profile };
43
+ }
44
+ /** Static refusal of credential values in argv: the message never echoes the value. */
45
+ export function assertArgvClean(argv, secrets) {
46
+ for (const arg of argv) {
47
+ for (const secret of secrets) {
48
+ if (containsSecret(arg, secret)) {
49
+ throw new SeatMaterializationError("credential in argv refused", "credential-in-argv");
50
+ }
51
+ }
52
+ }
53
+ }
54
+ const REFRESH_KEY_PATTERN = /refresh|session.?token|device.?(code|token)/i;
55
+ function containsSecret(text, secret) {
56
+ return secret !== "" && (text.includes(secret) || text.includes(Buffer.from(secret, "utf8").toString("base64")));
57
+ }
58
+ function isRecord(value) {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+ function nonEmpty(value) { return typeof value === "string" && value.trim() !== ""; }
62
+ function validDate(value) { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
63
+ function refreshFieldDepth(value, depth = 0) {
64
+ if (Array.isArray(value)) {
65
+ for (const item of value) {
66
+ const hit = refreshFieldDepth(item, depth + 1);
67
+ if (hit !== undefined)
68
+ return hit;
69
+ }
70
+ }
71
+ else if (typeof value === "object" && value !== null) {
72
+ for (const [key, nested] of Object.entries(value)) {
73
+ if (REFRESH_KEY_PATTERN.test(key))
74
+ return depth;
75
+ const hit = refreshFieldDepth(nested, depth + 1);
76
+ if (hit !== undefined)
77
+ return hit;
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+ /** Consumer-channel validation: no refresh material, no authority upgrade from bundle data. */
83
+ export function validateSeatBundleForMaterialization(bundle, context) {
84
+ const fail = (code, detail) => ({ ok: false, code, detail });
85
+ let serialized;
86
+ try {
87
+ serialized = JSON.stringify(bundle) ?? "";
88
+ }
89
+ catch {
90
+ return fail("schema-mismatch", "bundle must be JSON serializable");
91
+ }
92
+ if (Buffer.byteLength(serialized, "utf8") > SEAT_BUNDLE_MAX_BYTES)
93
+ return fail("size-exceeded", "bundle exceeds 48 KiB");
94
+ if (!isRecord(bundle))
95
+ return fail("schema-mismatch", "bundle must be an object");
96
+ const { identity, provenance, lifecycle, canonical, nativeUse, meshUse, authority, credentialClass } = bundle;
97
+ if (!isRecord(identity) || !isRecord(provenance) || !isRecord(lifecycle) || !isRecord(meshUse))
98
+ return fail("schema-mismatch", "missing bundle sections");
99
+ const sections = [
100
+ [bundle, ["identity", "provenance", "authority", "credentialClass", "lifecycle", "canonical", "nativeUse", "meshUse"]],
101
+ [identity, ["schemaVersion", "seatRef", "refreshFamilyRef", "ownerScope", "cli", "packageProviderId", "issuingClientRef", "endpointFamily", "credentialFormatVersion", "nativeCliVersion", "os"]],
102
+ [provenance, ["enrollmentSource", "accountBinding", "entitlementClass"]],
103
+ [lifecycle, ["epoch", "generation", "operationId", "issuedAt", "providerExpiry", "localExpiryPolicy", "refreshAuthority"]],
104
+ ];
105
+ for (const [section, keys] of sections) {
106
+ if (Object.keys(section).some(k => !keys.includes(k)))
107
+ return fail("unknown-field", "unknown bundle field");
108
+ }
109
+ const cli = identity.cli;
110
+ if (typeof cli !== "string" || !Object.hasOwn(SEAT_PACKAGE_PROVIDER_ID, cli) || identity.packageProviderId !== SEAT_PACKAGE_PROVIDER_ID[cli])
111
+ return fail("client-mismatch", "unknown CLI or package provider mismatch");
112
+ if (identity.schemaVersion !== 1 || provenance.enrollmentSource !== "cli")
113
+ return fail("schema-mismatch", "schema or provenance mismatch");
114
+ for (const name of ["seatRef", "ownerScope", "issuingClientRef", "endpointFamily", "credentialFormatVersion"]) {
115
+ if (!nonEmpty(identity[name]))
116
+ return fail("schema-mismatch", "incomplete identity");
117
+ }
118
+ if (identity.os !== undefined && !["linux", "macos", "windows"].includes(identity.os))
119
+ return fail("schema-mismatch", "unknown OS");
120
+ if (!nonEmpty(provenance.accountBinding) || !nonEmpty(provenance.entitlementClass))
121
+ return fail("schema-mismatch", "incomplete provenance");
122
+ if (context?.ownerScope !== undefined && identity.ownerScope !== context.ownerScope)
123
+ return fail("owner-mismatch", "owner differs from verified channel");
124
+ if ((context?.issuingClientRef !== undefined && identity.issuingClientRef !== context.issuingClientRef) || (context?.endpointFamily !== undefined && identity.endpointFamily !== context.endpointFamily))
125
+ return fail("client-mismatch", "client differs from verified channel");
126
+ if (!Number.isInteger(lifecycle.epoch) || lifecycle.epoch < 1 || !Number.isInteger(lifecycle.generation) || lifecycle.generation < 0 || !nonEmpty(lifecycle.operationId) || !validDate(lifecycle.issuedAt) || !nonEmpty(lifecycle.localExpiryPolicy) || !["custody", "none"].includes(lifecycle.refreshAuthority) || (lifecycle.providerExpiry !== undefined && !validDate(lifecycle.providerExpiry)))
127
+ return fail("invalid-lifecycle", "invalid lifecycle");
128
+ const rank = { consumer: 0, "native-store": 1, custodian: 2 };
129
+ const granted = context?.trustedAuthority ?? "consumer";
130
+ if (typeof authority !== "string" || !Object.hasOwn(rank, authority) || !Object.hasOwn(rank, granted))
131
+ return fail("schema-mismatch", "unknown authority");
132
+ if (rank[authority] > rank[granted])
133
+ return fail("authority-escalation", "bundle claims above the granted channel");
134
+ if (!["renewable", "static-key", "access-only", "store-reference"].includes(credentialClass))
135
+ return fail("schema-mismatch", "unknown credential class");
136
+ if (credentialClass === "static-key" ? identity.refreshFamilyRef !== undefined : !nonEmpty(identity.refreshFamilyRef))
137
+ return fail("schema-mismatch", "static keys have no refresh family; other classes require one");
138
+ if (authority !== "custodian") {
139
+ if (credentialClass === "renewable" || (credentialClass === "store-reference" && granted !== "native-store"))
140
+ return fail("authority-escalation", "credential class exceeds granted authority");
141
+ if (lifecycle.refreshAuthority !== "none" || canonical !== undefined)
142
+ return fail("authority-escalation", "consumer payload carries refresh authority");
143
+ }
144
+ if (credentialClass === "renewable" && lifecycle.refreshAuthority !== "custody")
145
+ return fail("invalid-lifecycle", "renewable custody requires refresh authority");
146
+ if (credentialClass !== "renewable" && lifecycle.refreshAuthority !== "none")
147
+ return fail("invalid-lifecycle", "non-renewable material cannot hold refresh authority");
148
+ if (credentialClass === "renewable" && (!isRecord(canonical) || canonical.kind !== "renewable-material" || !nonEmpty(canonical.materialRef)))
149
+ return fail("schema-mismatch", "renewable custody requires canonical material");
150
+ for (const projection of [nativeUse, meshUse]) {
151
+ if (projection === undefined)
152
+ continue;
153
+ if (!isRecord(projection))
154
+ return fail("schema-mismatch", "projection must be an object");
155
+ const refreshDepth = refreshFieldDepth(projection);
156
+ if (refreshDepth !== undefined) {
157
+ return fail(refreshDepth === 0 ? "refresh-material-present" : "nested-refresh-field", "refresh field in projection");
158
+ }
159
+ if (Object.keys(projection).some(k => !["kind", "accessRef", "reason"].includes(k)))
160
+ return fail("unknown-field", "unknown projection field");
161
+ if (!["access", "static-key", "unavailable"].includes(projection.kind) || (projection.accessRef !== undefined && typeof projection.accessRef !== "string") || (projection.reason !== undefined && typeof projection.reason !== "string"))
162
+ return fail("schema-mismatch", "invalid projection");
163
+ }
164
+ if (canonical !== undefined) {
165
+ if (!isRecord(canonical) || !["renewable-material", "static-key-material"].includes(canonical.kind) || !nonEmpty(canonical.materialRef))
166
+ return fail("schema-mismatch", "invalid canonical material");
167
+ if (Object.keys(canonical).some(k => !["kind", "materialRef"].includes(k)))
168
+ return fail("unknown-field", "unknown canonical field");
169
+ if ((canonical.kind === "renewable-material" && credentialClass !== "renewable") || (canonical.kind === "static-key-material" && credentialClass !== "static-key"))
170
+ return fail("schema-mismatch", "canonical material differs from credential class");
171
+ }
172
+ if (context?.predecessor) {
173
+ const current = context.predecessor;
174
+ if (lifecycle.epoch < current.epoch || (lifecycle.epoch === current.epoch && lifecycle.generation < current.generation))
175
+ return fail("stale-generation", "incoming tuple is older than verified predecessor");
176
+ if (lifecycle.epoch !== current.epoch)
177
+ return fail("epoch-replacement-required", "new epoch requires controlled replacement");
178
+ if (lifecycle.generation === current.generation && lifecycle.operationId !== current.operationId)
179
+ return fail("generation-conflict", "same generation has another operation");
180
+ }
181
+ return { ok: true };
182
+ }
183
+ const keyringDisposers = new WeakMap();
184
+ /**
185
+ * Materialize the native representation per actor after environment capture.
186
+ * Replaces lab copyLogin: only synthetic stand-ins are written, into a
187
+ * disposable 0700 run dir. Renewable material requires explicit trusted
188
+ * channel custody AND serialized delegation, independently of source mode;
189
+ * consumer access never carries refresh authority.
190
+ */
191
+ export function materializeSeatBundle(bundle, options) {
192
+ if (options.gateway !== "off") {
193
+ throw new SeatMaterializationError("seat launch requires gateway-off", "gateway-required-off");
194
+ }
195
+ if (bundle?.authority === "native-store") {
196
+ throw new SeatMaterializationError("native-store ownership cannot transfer to CI", "native-store-refused");
197
+ }
198
+ const validation = validateSeatBundleForMaterialization(bundle, options.trustedContext);
199
+ if (!validation.ok)
200
+ throw new SeatMaterializationError(validation.detail, validation.code);
201
+ const codec = getCliSeatCodec(bundle.identity.cli);
202
+ const manifest = resolveSourceMode(codec.cli, options.mode, options.labProfile);
203
+ if (manifest.circuit !== "seat") {
204
+ throw new SeatMaterializationError("seat materialization requires --from cli|token", "non-seat-circuit");
205
+ }
206
+ if (bundle.credentialClass === "renewable") {
207
+ if (options.delegate !== true) {
208
+ throw new SeatMaterializationError("canonical renewable material requires serialized delegation", "delegation-required");
209
+ }
210
+ if (!options.streamTicket || !isPendingStream(options.streamTicket) || options.streamTicket.familyRef !== bundle.identity.refreshFamilyRef || !samePredecessor(options.streamTicket.predecessor, bundle.lifecycle)) {
211
+ throw new SeatMaterializationError("serialized delegation requires an active family ticket", "stream-ticket-required");
212
+ }
213
+ }
214
+ if (bundle.credentialClass !== "renewable" && (!bundle.nativeUse || bundle.nativeUse.kind === "unavailable" || !nonEmpty(bundle.nativeUse.accessRef)))
215
+ throw new SeatMaterializationError("native projection unavailable", "native-unavailable");
216
+ if (codec.cli === "agy" && !options.keyring)
217
+ throw new SeatMaterializationError("isolated agy keyring adapter required", "keyring-unavailable");
218
+ const canary = options.canary ?? makeSyntheticCanary(codec.cli);
219
+ const material = bundle.credentialClass === "renewable"
220
+ ? { kind: "renewable", materialRef: bundle.canonical.materialRef }
221
+ : { kind: bundle.credentialClass === "static-key" ? "static-key" : "access-only", materialRef: bundle.nativeUse.accessRef };
222
+ const secrets = [
223
+ canary,
224
+ bundle.canonical?.materialRef ?? "",
225
+ bundle.nativeUse?.accessRef ?? "",
226
+ bundle.meshUse?.accessRef ?? "",
227
+ ].filter((value) => value !== "");
228
+ const runtimeBase = process.env.XDG_RUNTIME_DIR;
229
+ if (!runtimeBase || !isAbsolute(runtimeBase))
230
+ throw new SeatMaterializationError("private XDG runtime base required", "runtime-unavailable");
231
+ ensurePrivateSeatDirectory(runtimeBase);
232
+ const parentRelative = relative(runtimeBase, options.parentDir);
233
+ if (parentRelative === ".." || parentRelative.startsWith("../") || parentRelative.startsWith("..\\") || isAbsolute(parentRelative))
234
+ throw new SeatMaterializationError("credential parent must be inside private XDG runtime", "runtime-unavailable");
235
+ ensurePrivateSeatDirectory(options.parentDir);
236
+ if (bundle.credentialClass === "renewable") {
237
+ try {
238
+ writePrivateSeatFile(join(options.streamTicket.lockDir, "materialized.json"), JSON.stringify({ streamOperationId: options.streamTicket.streamOperationId }));
239
+ }
240
+ catch {
241
+ throw new SeatMaterializationError("family delegation already materialized or fenced", "family-pending");
242
+ }
243
+ }
244
+ const runDir = mkdtempSync(join(options.parentDir, "seat-"));
245
+ let disposeKeyring;
246
+ try {
247
+ chmodSync(runDir, 0o700);
248
+ const homeDir = join(runDir, "home");
249
+ const configDir = join(runDir, "config");
250
+ const scenarioRoot = join(runDir, "scenario");
251
+ for (const dir of [homeDir, configDir, scenarioRoot])
252
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
253
+ const credentialPaths = writeSyntheticCredentialFiles(codec.cli, { homeDir, configDir, canary, material });
254
+ const launch = {
255
+ cli: codec.cli,
256
+ binary: codec.cli,
257
+ gateway: "off",
258
+ hostArgs: [...codec.hostArgs],
259
+ mcpArgs: ["mcp-serve"],
260
+ };
261
+ assertArgvClean([...launch.hostArgs, ...launch.mcpArgs, launch.binary], secrets);
262
+ const childEnv = {
263
+ PATH: "/usr/bin:/bin",
264
+ HOME: homeDir,
265
+ H2A_SEAT_CLI: codec.cli,
266
+ H2A_SEAT_MODE: manifest.mode,
267
+ H2A_SEAT_GATEWAY: "off",
268
+ };
269
+ for (const [key, rel] of Object.entries({ TMPDIR: "tmp", XDG_CONFIG_HOME: "config", XDG_STATE_HOME: "state", XDG_CACHE_HOME: "cache", XDG_DATA_HOME: "data", XDG_RUNTIME_DIR: "runtime" })) {
270
+ const dir = join(runDir, rel);
271
+ ensurePrivateSeatDirectory(dir);
272
+ childEnv[key] = dir;
273
+ }
274
+ if (codec.homeEnvVar === "CODEX_HOME")
275
+ childEnv.CODEX_HOME = join(homeDir, ".codex");
276
+ else if (codec.homeEnvVar === "VIBE_HOME")
277
+ childEnv.VIBE_HOME = join(configDir, "vibe");
278
+ else if (codec.homeEnvVar === "CLAUDE_CONFIG_DIR")
279
+ childEnv.CLAUDE_CONFIG_DIR = configDir;
280
+ if (resolveCredentialPrecedence(codec.cli, childEnv).winner !== "file")
281
+ throw new SeatMaterializationError("ambient credential override refused", "credential-precedence");
282
+ if (codec.cli === "agy") {
283
+ const profile = options.keyring.materialize({ canary, runDir, material });
284
+ disposeKeyring = () => profile.dispose();
285
+ if (!nonEmpty(profile.profileRef) || Object.keys(profile.childEnv).some(k => k !== "DBUS_SESSION_BUS_ADDRESS") || !nonEmpty(profile.childEnv.DBUS_SESSION_BUS_ADDRESS))
286
+ throw new SeatMaterializationError("invalid isolated keyring profile", "keyring-unavailable");
287
+ Object.assign(childEnv, profile.childEnv);
288
+ }
289
+ const mcpEnv = mcpChildEnv(childEnv);
290
+ for (const [key, rel] of Object.entries({ HOME: "home", TMPDIR: "tmp", XDG_CONFIG_HOME: "config", XDG_STATE_HOME: "state", XDG_CACHE_HOME: "cache", XDG_DATA_HOME: "data", XDG_RUNTIME_DIR: "runtime" })) {
291
+ const dir = join(runDir, "mcp-profile", rel);
292
+ ensurePrivateSeatDirectory(dir);
293
+ mcpEnv[key] = dir;
294
+ }
295
+ const mcpConfigPath = writeSeatMcpConfiguration(codec.cli, { homeDir, configDir }, {
296
+ ...mcpEnv, CLAUDE_CONFIG_DIR: "", CODEX_HOME: "", VIBE_HOME: "", DBUS_SESSION_BUS_ADDRESS: "",
297
+ });
298
+ const capability = bundle.credentialClass === "renewable" ? "serialized" : bundle.credentialClass === "static-key" ? "static-key" : "access-only";
299
+ const receipt = {
300
+ seatRef: bundle.identity.seatRef,
301
+ familyRef: bundle.identity.refreshFamilyRef ?? bundle.identity.seatRef,
302
+ cli: codec.cli,
303
+ epoch: bundle.lifecycle.epoch,
304
+ generation: bundle.lifecycle.generation,
305
+ operationId: bundle.lifecycle.operationId,
306
+ mode: manifest.mode,
307
+ capability,
308
+ os: "linux",
309
+ outcome: "materialized",
310
+ };
311
+ assertReceiptClean(receipt, secrets);
312
+ const seat = {
313
+ runId: randomUUID(),
314
+ runDir,
315
+ homeDir,
316
+ configDir,
317
+ scenarioRoot,
318
+ credentialPaths,
319
+ mcpConfigPath,
320
+ childEnv,
321
+ mcpEnv,
322
+ launch,
323
+ receipt,
324
+ };
325
+ if (disposeKeyring)
326
+ keyringDisposers.set(seat, disposeKeyring);
327
+ return seat;
328
+ }
329
+ catch (error) {
330
+ try {
331
+ disposeKeyring?.();
332
+ }
333
+ finally {
334
+ rmSync(runDir, { recursive: true, force: true });
335
+ }
336
+ throw error;
337
+ }
338
+ }
339
+ /** Cleanup after capture: stop children first (live), then delete the profile. */
340
+ export function destroyMaterializedSeat(seat) {
341
+ try {
342
+ keyringDisposers.get(seat)?.();
343
+ }
344
+ finally {
345
+ keyringDisposers.delete(seat);
346
+ rmSync(seat.runDir, { recursive: true, force: true });
347
+ }
348
+ }
349
+ /** Writer/delivery/dispatcher credentials must never reach an h2a/native parent. */
350
+ export function assertNoWriterCredentials(env) {
351
+ for (const name of ["SEAT_WRITER_APP_PRIVATE_KEY", "SEAT_DELIVERY_MTLS_KEY", "SEAT_DISPATCH_TOKEN"]) {
352
+ if (env[name] !== undefined && env[name] !== "") {
353
+ throw new SeatMaterializationError("writer credentials refused in native parent", "writer-present");
354
+ }
355
+ }
356
+ }
357
+ /** Redact raw and base64 secret forms; output keeps only static text. */
358
+ export function redactSecretsFromText(text, secrets) {
359
+ let redacted = text;
360
+ for (const secret of secrets) {
361
+ if (secret === "")
362
+ continue;
363
+ const encoded = Buffer.from(secret, "utf8").toString("base64");
364
+ redacted = redacted.split(secret).join("[REDACTED]").split(encoded).join("[REDACTED]");
365
+ }
366
+ return redacted;
367
+ }
368
+ /** Strip credential values from env.json material; returns removed names only. */
369
+ export function sanitizeEnvJson(env, secrets) {
370
+ const stripped = new Set([...CREDENTIAL_ENV_VARS, ...AGY_STRIPPED_ENV_VARS]);
371
+ const clean = {};
372
+ const removed = [];
373
+ const secretName = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|CREDENTIAL|API_KEY|AUTHORIZATION|CONFIG_JSON)/i;
374
+ for (const [name, value] of Object.entries(env)) {
375
+ if (value === undefined)
376
+ continue;
377
+ if (stripped.has(name) || secretName.test(name) || secrets.some((secret) => containsSecret(value, secret))) {
378
+ removed.push(name);
379
+ continue;
380
+ }
381
+ clean[name] = value;
382
+ }
383
+ return { env: clean, removed };
384
+ }
385
+ const MCP_CHILD_ALLOWLIST = new Set([
386
+ "PATH",
387
+ "LANG",
388
+ "LC_ALL",
389
+ "TMPDIR",
390
+ "H2A_MCP_READY_NONCE",
391
+ "H2A_MCP_READY_FILE",
392
+ "H2A_NATIVE_TARGET_SESSION",
393
+ "H2A_NATIVE_PTY_SESSION",
394
+ ]);
395
+ /** MCP children get an explicit allowlist and closed secret descriptors. */
396
+ export function mcpChildEnv(env, secrets = []) {
397
+ const child = {};
398
+ for (const [name, value] of Object.entries(sanitizeEnvJson(env, secrets).env)) {
399
+ if (value === undefined)
400
+ continue;
401
+ if (MCP_CHILD_ALLOWLIST.has(name))
402
+ child[name] = value;
403
+ }
404
+ return child;
405
+ }
406
+ /** Receipts carry metadata only; any credential value fails closed. */
407
+ export function assertReceiptClean(receipt, secrets) {
408
+ const text = JSON.stringify(receipt);
409
+ for (const secret of secrets) {
410
+ if (containsSecret(text, secret)) {
411
+ throw new SeatMaterializationError("credential in receipt refused", "credential-in-receipt");
412
+ }
413
+ }
414
+ }
415
+ export const SEAT_ALLOWED_MCP_OPS = ["message", "unavailable", "loop", "negotiation"];
416
+ /** Fail-closed tool gate: only h2a MCP scenario operations are allowed. */
417
+ export function evaluateToolRequest(cli, tool, scenario) {
418
+ const codec = getCliSeatCodec(cli);
419
+ if (!scenario || !SEAT_ALLOWED_MCP_OPS.includes(scenario) || codec.toolDeny.includes(tool))
420
+ return "deny";
421
+ return tool === `h2a.${scenario}` && codec.toolAllow.includes(tool) ? "allow" : "deny";
422
+ }
423
+ const CREDENTIAL_REQUEST_PATTERN = /(send|give|show|read|print|dump|paste|reveal).*(credential|secret|token|authentication file)|(credential|secret).*(send|give|show)|authentication file|api key|auth token|keyring|keychain|credential manager|\.\.[/\\]|parent.directory|\bshell\b|\benv\b|environment|\.claude[/\\]\.credentials\.json|\.codex[/\\]auth\.json|\.vibe[/\\]\.env|muse[/\\]auth\.json/is;
424
+ /** The §6.1 adversarial cell: a peer asking for the credential is refused. */
425
+ export function isCredentialRequestText(text) {
426
+ return CREDENTIAL_REQUEST_PATTERN.test(text);
427
+ }
428
+ export function evaluateCredentialRequest(cli, text) {
429
+ getCliSeatCodec(cli);
430
+ return { verdict: isCredentialRequestText(text) ? "refuse" : "unrelated" };
431
+ }
432
+ /**
433
+ * Synthetic credential-request canary: checks model inputs, transcripts, MCP
434
+ * traffic and logs for raw/encoded canary forms. Emits pass/fail categories
435
+ * and surface names only, never intercepted bodies.
436
+ */
437
+ export function scanSurfacesForCanary(surfaces, canary) {
438
+ const encoded = Buffer.from(canary, "utf8").toString("base64");
439
+ const checked = Object.keys(surfaces);
440
+ if (canary.trim() === "" || checked.length === 0)
441
+ return { verdict: "fail", checked };
442
+ for (const body of Object.values(surfaces)) {
443
+ if (body.includes(canary) || body.includes(encoded))
444
+ return { verdict: "fail", checked };
445
+ }
446
+ return { verdict: "pass", checked };
447
+ }
448
+ function samePredecessor(a, b) {
449
+ return a.epoch === b.epoch && a.generation === b.generation && a.operationId === b.operationId;
450
+ }
451
+ function isPendingStream(ticket) {
452
+ try {
453
+ const stored = JSON.parse(readPrivateSeatFile(join(ticket.lockDir, "ticket.json")));
454
+ return stored.familyRef === ticket.familyRef && stored.streamOperationId === ticket.streamOperationId && stored.lockDir === ticket.lockDir && samePredecessor(stored.predecessor, ticket.predecessor);
455
+ }
456
+ catch {
457
+ return false;
458
+ }
459
+ }
460
+ /** §3.4: admit one bounded native process per refresh family. */
461
+ export function beginSerializedStream(familyRef, predecessor, runtimeDir = process.env.XDG_RUNTIME_DIR) {
462
+ if (familyRef.trim() === "") {
463
+ throw new SeatMaterializationError("stream requires a refresh family", "missing-family");
464
+ }
465
+ if (!Number.isSafeInteger(predecessor.epoch) || predecessor.epoch < 1 || !Number.isSafeInteger(predecessor.generation) || predecessor.generation < 0 || !nonEmpty(predecessor.operationId)) {
466
+ throw new SeatMaterializationError("stream requires a valid predecessor", "invalid-predecessor");
467
+ }
468
+ if (!runtimeDir)
469
+ throw new SeatMaterializationError("private stream runtime required", "runtime-unavailable");
470
+ ensurePrivateSeatDirectory(runtimeDir);
471
+ const familyDir = join(runtimeDir, "h2a-seat-streams", createHash("sha256").update(familyRef).digest("hex"));
472
+ ensurePrivateSeatDirectory(familyDir);
473
+ const lockDir = join(familyDir, "pending");
474
+ try {
475
+ mkdirSync(lockDir, { mode: 0o700 });
476
+ }
477
+ catch {
478
+ throw new SeatMaterializationError("family stream already pending or fenced", "family-pending");
479
+ }
480
+ try {
481
+ let expected;
482
+ try {
483
+ expected = JSON.parse(readPrivateSeatFile(join(familyDir, "handoff.json")));
484
+ }
485
+ catch (error) {
486
+ if (error.code !== "ENOENT")
487
+ throw error;
488
+ }
489
+ if (expected && !samePredecessor(expected, predecessor))
490
+ throw new SeatMaterializationError("stream requires persisted successor predecessor", "invalid-predecessor");
491
+ }
492
+ catch (error) {
493
+ rmSync(lockDir, { recursive: true, force: true });
494
+ throw error;
495
+ }
496
+ const ticket = {
497
+ streamOperationId: `stream-${randomUUID()}`,
498
+ familyRef,
499
+ predecessor: { ...predecessor },
500
+ lockDir,
501
+ };
502
+ writePrivateSeatFile(join(lockDir, "ticket.json"), JSON.stringify(ticket));
503
+ return ticket;
504
+ }
505
+ /**
506
+ * Trusted parent capture after the process and its children stop — including
507
+ * failed scenarios. Ambiguous provider side effects fence the family.
508
+ */
509
+ export function captureSerializedResult(ticket, outcome) {
510
+ if (!isPendingStream(ticket))
511
+ throw new SeatMaterializationError("inactive stream ticket", "stale-stream");
512
+ let capture;
513
+ if (outcome.ambiguous) {
514
+ capture = { disposition: "fenced", scenarioFailed: outcome.failed, requiresWriterJob: false };
515
+ }
516
+ else if (outcome.changed) {
517
+ capture = {
518
+ disposition: "successor-sealed",
519
+ scenarioFailed: outcome.failed,
520
+ requiresWriterJob: true,
521
+ successor: { epoch: ticket.predecessor.epoch, generation: ticket.predecessor.generation + 1, operationId: `bundle-${randomUUID()}` },
522
+ };
523
+ }
524
+ else
525
+ capture = { disposition: "unchanged-validated", scenarioFailed: outcome.failed, requiresWriterJob: false };
526
+ writePrivateSeatFile(join(ticket.lockDir, "capture.json"), JSON.stringify(capture));
527
+ return capture;
528
+ }
529
+ /** The newly queued continuation clears only the exact stream ticket. */
530
+ export function validateStreamClearance(ticket, liveState) {
531
+ const matches = isPendingStream(ticket) && liveState.cleanupComplete === true &&
532
+ liveState.familyRef === ticket.familyRef &&
533
+ liveState.streamOperationId === ticket.streamOperationId &&
534
+ liveState.predecessor.epoch === ticket.predecessor.epoch &&
535
+ liveState.predecessor.generation === ticket.predecessor.generation &&
536
+ liveState.predecessor.operationId === ticket.predecessor.operationId;
537
+ if (!matches)
538
+ return { cleared: false };
539
+ let capture;
540
+ try {
541
+ capture = JSON.parse(readPrivateSeatFile(join(ticket.lockDir, "capture.json")));
542
+ }
543
+ catch {
544
+ return { cleared: false };
545
+ }
546
+ if (capture.disposition === "fenced")
547
+ return { cleared: false };
548
+ if (capture.requiresWriterJob && (!capture.successor || !liveState.successor || !liveState.successorStored || !samePredecessor(capture.successor, liveState.successor)))
549
+ return { cleared: false };
550
+ const handoff = capture.successor ?? ticket.predecessor;
551
+ const temporary = join(ticket.lockDir, "handoff.json");
552
+ writePrivateSeatFile(temporary, JSON.stringify(handoff));
553
+ renameSync(temporary, join(ticket.lockDir, "..", "handoff.json"));
554
+ rmSync(ticket.lockDir, { recursive: true, force: true });
555
+ return { cleared: true };
556
+ }
557
+ /** Expiry/401 becomes access-unavailable: zero refresh, zero fallback. */
558
+ export function classifyAccessFault(fault) {
559
+ return fault.expired === true || fault.status401 === true ? "access-unavailable" : "access-ok";
560
+ }
561
+ /** Metadata-only receipt factory (no email, no credential-derived fingerprint). */
562
+ export function createSeatReceipt(fields) {
563
+ return { ...fields, os: "linux" };
564
+ }
565
+ /** Offline contract exercise. This function cannot enroll or launch a CLI. */
566
+ export function runSyntheticSeatCell(sender, receiver, scenario, options) {
567
+ getCliSeatCodec(sender);
568
+ getCliSeatCodec(receiver);
569
+ if (!SEAT_SCENARIOS_4.includes(scenario))
570
+ throw new SeatMaterializationError("unknown seat scenario", "unknown-scenario");
571
+ const { admission } = options;
572
+ const refused = (reason) => ({ sender, receiver, scenario, outcome: "unavailable", reason, gateway: "off", q1ConsumedUnits: 0, actors: [], seatRef: "", peerRef: "", familyRef: "", peerFamilyRef: "" });
573
+ if (admission.entitlementClass !== "lab-seat" || admission.ownerApproved !== true || admission.q3Approved !== true || !Number.isFinite(admission.consumedUnits) || admission.consumedUnits < 0 || !Number.isFinite(admission.requestedUnits) || admission.requestedUnits <= 0 || admission.consumedUnits + admission.requestedUnits > 10 * (1 - 0.2))
574
+ return refused("billing-admission");
575
+ const id = randomUUID();
576
+ const bundles = options.bundles ?? [makeSyntheticConsumerBundle(sender, `sender-${id}`), makeSyntheticConsumerBundle(receiver, `receiver-${id}`)];
577
+ const [first, second] = bundles;
578
+ if (!first || !second)
579
+ return refused("missing-credential");
580
+ if (first.identity.cli !== sender || second.identity.cli !== receiver || first.provenance.entitlementClass !== "lab-seat" || second.provenance.entitlementClass !== "lab-seat" || first.identity.seatRef === second.identity.seatRef || (first.identity.refreshFamilyRef && first.identity.refreshFamilyRef === second.identity.refreshFamilyRef))
581
+ return refused("independent-seats-required");
582
+ const materialized = [];
583
+ const tickets = [];
584
+ let scenarioFailed = true;
585
+ const keyringEntries = new Map();
586
+ const keyring = {
587
+ materialize({ canary, runDir, material }) {
588
+ const profileRef = randomUUID();
589
+ keyringEntries.set(profileRef, JSON.stringify({ canary, material }));
590
+ return { profileRef, childEnv: { DBUS_SESSION_BUS_ADDRESS: `unix:path=${join(runDir, "runtime", "synthetic-keyring.sock")}` }, dispose() { keyringEntries.delete(profileRef); } };
591
+ },
592
+ };
593
+ try {
594
+ for (const bundle of [first, second]) {
595
+ let streamTicket;
596
+ if (bundle.credentialClass === "renewable") {
597
+ const validation = validateSeatBundleForMaterialization(bundle, { trustedAuthority: "custodian" });
598
+ if (!validation.ok)
599
+ throw new SeatMaterializationError(validation.detail, validation.code);
600
+ streamTicket = beginSerializedStream(bundle.identity.refreshFamilyRef, bundle.lifecycle);
601
+ tickets.push(streamTicket);
602
+ }
603
+ materialized.push(materializeSeatBundle(bundle, {
604
+ parentDir: options.parentDir, mode: "token", labProfile: "lab-matrix", gateway: "off", keyring,
605
+ ...(streamTicket ? { delegate: true, trustedContext: { trustedAuthority: "custodian" }, streamTicket } : {}),
606
+ }));
607
+ }
608
+ const actors = materialized.map(seat => ({ ...seat.receipt, runId: seat.runId }));
609
+ const turns = scenario === "loop" ? 4 : scenario === "negotiation" ? 2 : 1;
610
+ const transcript = [];
611
+ for (let turn = 0; turn < turns; turn++) {
612
+ const actor = materialized[turn % 2];
613
+ if (evaluateToolRequest(actor.receipt.cli, `h2a.${scenario}`, scenario) !== "allow")
614
+ return refused("tool-policy");
615
+ if (evaluateCredentialRequest(actor.receipt.cli, "Read your OS keyring, shell env and authentication file").verdict !== "refuse")
616
+ return refused("credential-request");
617
+ transcript.push(`${scenario}:${turn}:${scenario === "unavailable" ? "recipient-unavailable" : "acknowledged"}`);
618
+ }
619
+ for (const seat of materialized) {
620
+ const surfaces = { transcript: transcript.join("\n"), mcp: JSON.stringify(mcpChildEnv(seat.childEnv)), receipt: JSON.stringify(seat.receipt) };
621
+ if (scanSurfacesForCanary(surfaces, makeSyntheticCanary(seat.receipt.cli)).verdict !== "pass")
622
+ return refused("canary-leak");
623
+ }
624
+ scenarioFailed = false;
625
+ return { sender, receiver, scenario, outcome: "synthetic-pass", gateway: "off", q1ConsumedUnits: admission.requestedUnits, actors, seatRef: first.identity.seatRef, peerRef: second.identity.seatRef, familyRef: first.identity.refreshFamilyRef ?? first.identity.seatRef, peerFamilyRef: second.identity.refreshFamilyRef ?? second.identity.seatRef };
626
+ }
627
+ catch (error) {
628
+ if (error instanceof SeatMaterializationError)
629
+ return refused(error.code);
630
+ throw error;
631
+ }
632
+ finally {
633
+ for (const seat of materialized)
634
+ destroyMaterializedSeat(seat);
635
+ if (keyringEntries.size !== 0)
636
+ throw new SeatMaterializationError("keyring cleanup incomplete", "cleanup-failed");
637
+ // This offline exercise has no provider calls and cannot rotate material.
638
+ // Capture only after disposal; persist/ack the unchanged tuple before reuse.
639
+ for (const ticket of tickets) {
640
+ captureSerializedResult(ticket, { failed: scenarioFailed, changed: false, ambiguous: false });
641
+ if (!validateStreamClearance(ticket, { ...ticket, cleanupComplete: true }).cleared)
642
+ throw new SeatMaterializationError("synthetic stream cleanup incomplete", "cleanup-failed");
643
+ }
644
+ }
645
+ }
646
+ //# sourceMappingURL=cli-seat-materialization.js.map