@timurproko/a1 0.1.8-dev.322 → 0.1.8-dev.332
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/cli/dispatch.js +1 -1
- package/dist/features/owned-ui/project-trust-prompt.js +1 -1
- package/dist/features/owned-ui/settings-app.js +1 -1
- package/dist/features/prompt-history/service.d.ts +31 -1
- package/dist/features/prompt-history/service.js +294 -129
- package/dist/features/prompt-history/store.d.ts +2 -1
- package/dist/features/prompt-history/store.js +17 -4
- package/dist/features/prompt-history/worker.js +6 -3
- package/dist/foundation/launch-context/index.d.ts +36 -6
- package/dist/foundation/launch-context/index.js +64 -11
- package/dist/foundation/launch-guardian/main.js +2 -1
- package/dist/foundation/release/bootstrap.d.ts +5 -0
- package/dist/foundation/release/bootstrap.js +49 -9
- package/dist/foundation/release/cohort-state.js +0 -3
- package/dist/foundation/release/dependency-certification-retention.d.ts +7 -0
- package/dist/foundation/release/dependency-certification-retention.js +88 -0
- package/dist/foundation/release/dependency-certification.d.ts +24 -0
- package/dist/foundation/release/dependency-certification.js +208 -0
- package/dist/foundation/release/dependency-layer.d.ts +3 -4
- package/dist/foundation/release/dependency-layer.js +6 -37
- package/dist/foundation/release/release-gc.js +72 -44
- package/dist/foundation/release/release-store.d.ts +2 -0
- package/dist/foundation/release/release-store.js +4 -3
- package/dist/foundation/release/restart-certification.js +16 -5
- package/dist/foundation/release/update.js +1 -6
- package/dist/foundation/release/warmup.js +16 -5
- package/dist/integrations/pi/components/owned-editor-ux.d.ts +1 -1
- package/dist/integrations/pi/components/owned-editor-ux.js +51 -27
- package/dist/integrations/pi/engine/adapter.d.ts +20 -0
- package/dist/integrations/pi/engine/adapter.js +223 -87
- package/dist/integrations/pi/engine/pending-delivery.d.ts +26 -0
- package/dist/integrations/pi/engine/pending-delivery.js +149 -0
- package/dist/integrations/pi/session-ui/prompt-chips.js +38 -25
- package/dist/integrations/pi/session-ui/prompt-history-controller.d.ts +0 -2
- package/dist/integrations/pi/session-ui/prompt-history-controller.js +3 -8
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +1 -1
- package/dist/integrations/pi/session-ui/session-shell-root.js +5 -1
- package/dist/integrations/pi/session-ui/session-shell.js +9 -6
- package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +2 -2
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +4 -5
- package/dist/integrations/pi/session-ui/text-paste.d.ts +5 -0
- package/dist/integrations/pi/session-ui/text-paste.js +16 -0
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +2 -2
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +82 -39
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/product-identity.json +1 -1
- package/docs/architecture/internal-naming.md +5 -3
- package/docs/manual-code-streaming-cleanup.md +88 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parentPort, workerData } from "node:worker_threads";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
-
import { PromptHistoryStore, classifyHistoryError } from "./store.js";
|
|
3
|
+
import { HistoryStorageError, PromptHistoryStore, classifyHistoryError } from "./store.js";
|
|
4
4
|
const port = parentPort;
|
|
5
5
|
if (port !== null) {
|
|
6
6
|
let store;
|
|
@@ -22,7 +22,8 @@ if (port !== null) {
|
|
|
22
22
|
port.postMessage({ id: request.id, ok: true, value });
|
|
23
23
|
}
|
|
24
24
|
catch (error) {
|
|
25
|
-
port.postMessage({ id: request.id, ok: false, code: classifyHistoryError(error)
|
|
25
|
+
port.postMessage({ id: request.id, ok: false, code: classifyHistoryError(error),
|
|
26
|
+
certainty: error instanceof HistoryStorageError ? error.certainty : "unknown" });
|
|
26
27
|
}
|
|
27
28
|
});
|
|
28
29
|
});
|
|
@@ -34,7 +35,9 @@ async function retry(operation) {
|
|
|
34
35
|
return operation();
|
|
35
36
|
}
|
|
36
37
|
catch (error) {
|
|
37
|
-
if (classifyHistoryError(error) !== "busy"
|
|
38
|
+
if (classifyHistoryError(error) !== "busy"
|
|
39
|
+
|| error instanceof HistoryStorageError && error.certainty === "unknown"
|
|
40
|
+
|| performance.now() >= deadline)
|
|
38
41
|
throw error;
|
|
39
42
|
await delay(25);
|
|
40
43
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The
|
|
1
|
+
/** The contract this build emits; supersedes the pre-cutover key set it still accepts. */
|
|
2
2
|
export declare const PRIVATE_LAUNCH_CONTRACT = "neutral-launch-v1";
|
|
3
3
|
export declare const PRIVATE_ENVIRONMENT: Readonly<{
|
|
4
4
|
releaseRoot: "LAUNCH_CONTEXT_RELEASE_ROOT";
|
|
@@ -8,6 +8,22 @@ export declare const PRIVATE_ENVIRONMENT: Readonly<{
|
|
|
8
8
|
launchProfile: "LAUNCH_CONTEXT_PROFILE";
|
|
9
9
|
immutableWarmup: "LAUNCH_CONTEXT_WARMUP";
|
|
10
10
|
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Keys published by releases that predate the neutral cutover. They are read when
|
|
13
|
+
* an older launcher hands this build a process, and written when this build
|
|
14
|
+
* starts a release that declares no contract of its own, so an upgrade never
|
|
15
|
+
* depends on both sides having been rebuilt at the same moment.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SUPERSEDED_ENVIRONMENT: Readonly<{
|
|
18
|
+
releaseRoot: "A1_RELEASE_ROOT";
|
|
19
|
+
releaseId: "A1_RELEASE_ID";
|
|
20
|
+
releaseDigest: "A1_RELEASE_DIGEST";
|
|
21
|
+
releaseLayers: "A1_RELEASE_LAYERS";
|
|
22
|
+
launchProfile: "A1_LAUNCH_PROFILE";
|
|
23
|
+
immutableWarmup: "A1_IMMUTABLE_WARMUP";
|
|
24
|
+
}>;
|
|
25
|
+
/** A release either declares the current contract or predates contract declaration. */
|
|
26
|
+
export type LaunchContractTarget = typeof PRIVATE_LAUNCH_CONTRACT | "superseded";
|
|
11
27
|
export interface LaunchContext {
|
|
12
28
|
readonly releaseRoot?: string;
|
|
13
29
|
readonly releaseId?: string;
|
|
@@ -17,13 +33,27 @@ export interface LaunchContext {
|
|
|
17
33
|
readonly immutableWarmup?: "1";
|
|
18
34
|
}
|
|
19
35
|
export type LaunchContextRequirement = "optional" | "profile" | "release" | "warmup";
|
|
20
|
-
/** Read current
|
|
36
|
+
/** Read current keys, fall back to superseded keys per field, with bounded diagnostics. */
|
|
21
37
|
export declare function readLaunchContext(environment: NodeJS.ProcessEnv, requirement?: LaunchContextRequirement, platform?: NodeJS.Platform): LaunchContext;
|
|
22
|
-
/**
|
|
23
|
-
export declare function
|
|
24
|
-
/**
|
|
38
|
+
/** The key set a process started for this release must receive. */
|
|
39
|
+
export declare function launchEnvironmentKeys(contract: LaunchContractTarget): Readonly<Record<string, string>>;
|
|
40
|
+
/** Classify metadata by the contract it declares rather than rejecting what it omits. */
|
|
41
|
+
export declare function launchContractTarget(value: {
|
|
42
|
+
readonly launchContract?: unknown;
|
|
43
|
+
}): LaunchContractTarget;
|
|
44
|
+
/** Whether metadata was produced by a build that declares this contract. */
|
|
45
|
+
export declare function isCurrentLaunchContract(value: {
|
|
46
|
+
readonly launchContract?: unknown;
|
|
47
|
+
}): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Rebuild owned context without inheriting a prior session's private selection. The
|
|
50
|
+
* contract selects which key set is written, so this build can start either its
|
|
51
|
+
* own releases or a retained pre-cutover release it must keep serving.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withLaunchContext(environment: NodeJS.ProcessEnv, context: LaunchContext, platform?: NodeJS.Platform, contract?: LaunchContractTarget): NodeJS.ProcessEnv;
|
|
54
|
+
/** Remove both key sets, leaving user and integration settings intact. */
|
|
25
55
|
export declare function withoutLaunchContext(environment: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
26
|
-
/**
|
|
56
|
+
/** Require this build's own contract where nothing older can be accepted, such as recovery capsules. */
|
|
27
57
|
export declare function assertCurrentLaunchContract(value: {
|
|
28
58
|
readonly launchContract?: unknown;
|
|
29
59
|
}): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The
|
|
1
|
+
/** The contract this build emits; supersedes the pre-cutover key set it still accepts. */
|
|
2
2
|
export const PRIVATE_LAUNCH_CONTRACT = "neutral-launch-v1";
|
|
3
3
|
export const PRIVATE_ENVIRONMENT = Object.freeze({
|
|
4
4
|
releaseRoot: "LAUNCH_CONTEXT_RELEASE_ROOT",
|
|
@@ -8,18 +8,39 @@ export const PRIVATE_ENVIRONMENT = Object.freeze({
|
|
|
8
8
|
launchProfile: "LAUNCH_CONTEXT_PROFILE",
|
|
9
9
|
immutableWarmup: "LAUNCH_CONTEXT_WARMUP",
|
|
10
10
|
});
|
|
11
|
+
/**
|
|
12
|
+
* Keys published by releases that predate the neutral cutover. They are read when
|
|
13
|
+
* an older launcher hands this build a process, and written when this build
|
|
14
|
+
* starts a release that declares no contract of its own, so an upgrade never
|
|
15
|
+
* depends on both sides having been rebuilt at the same moment.
|
|
16
|
+
*/
|
|
17
|
+
export const SUPERSEDED_ENVIRONMENT = Object.freeze({
|
|
18
|
+
releaseRoot: "A1_RELEASE_ROOT",
|
|
19
|
+
releaseId: "A1_RELEASE_ID",
|
|
20
|
+
releaseDigest: "A1_RELEASE_DIGEST",
|
|
21
|
+
releaseLayers: "A1_RELEASE_LAYERS",
|
|
22
|
+
launchProfile: "A1_LAUNCH_PROFILE",
|
|
23
|
+
immutableWarmup: "A1_IMMUTABLE_WARMUP",
|
|
24
|
+
});
|
|
11
25
|
const PRIVATE_ENTRIES = Object.entries(PRIVATE_ENVIRONMENT);
|
|
12
|
-
const
|
|
26
|
+
const SUPERSEDED_ENTRIES = Object.entries(SUPERSEDED_ENVIRONMENT);
|
|
27
|
+
const PRIVATE_KEYS = new Set([...Object.values(PRIVATE_ENVIRONMENT), ...Object.values(SUPERSEDED_ENVIRONMENT)]);
|
|
13
28
|
const LOGICAL_KEYS = new Map(PRIVATE_ENTRIES.map(([logical, key]) => [key, logical]));
|
|
14
|
-
|
|
29
|
+
const SUPERSEDED_LOGICAL_KEYS = new Map(SUPERSEDED_ENTRIES.map(([logical, key]) => [key, logical]));
|
|
30
|
+
/** Read current keys, fall back to superseded keys per field, with bounded diagnostics. */
|
|
15
31
|
export function readLaunchContext(environment, requirement = "optional", platform = process.platform) {
|
|
16
32
|
const values = {};
|
|
33
|
+
const superseded = {};
|
|
17
34
|
// Performance: enumerate names only for Windows casing; never fetch unrelated environment values.
|
|
18
35
|
if (platform === "win32") {
|
|
19
36
|
for (const name of Object.keys(environment)) {
|
|
20
|
-
const
|
|
37
|
+
const upper = name.toUpperCase();
|
|
38
|
+
const logical = LOGICAL_KEYS.get(upper);
|
|
21
39
|
if (logical !== undefined)
|
|
22
40
|
acceptValue(values, logical, environment[name]);
|
|
41
|
+
const supersededLogical = SUPERSEDED_LOGICAL_KEYS.get(upper);
|
|
42
|
+
if (supersededLogical !== undefined)
|
|
43
|
+
acceptValue(superseded, supersededLogical, environment[name]);
|
|
23
44
|
}
|
|
24
45
|
}
|
|
25
46
|
else {
|
|
@@ -27,7 +48,16 @@ export function readLaunchContext(environment, requirement = "optional", platfor
|
|
|
27
48
|
if (Object.prototype.propertyIsEnumerable.call(environment, key))
|
|
28
49
|
acceptValue(values, logical, environment[key]);
|
|
29
50
|
}
|
|
51
|
+
for (const [logical, key] of SUPERSEDED_ENTRIES) {
|
|
52
|
+
if (Object.prototype.propertyIsEnumerable.call(environment, key))
|
|
53
|
+
acceptValue(superseded, logical, environment[key]);
|
|
54
|
+
}
|
|
30
55
|
}
|
|
56
|
+
// Compatibility: the current key wins wherever both are present, so a launcher that
|
|
57
|
+
// emits this contract is never overruled by an inherited pre-cutover value.
|
|
58
|
+
for (const [logical, value] of Object.entries(superseded))
|
|
59
|
+
if (values[logical] === undefined)
|
|
60
|
+
values[logical] = value;
|
|
31
61
|
if (values.launchProfile !== undefined && values.launchProfile !== "a1" && values.launchProfile !== "pi") {
|
|
32
62
|
throw invalidContext("launchProfile", "unsupported profile");
|
|
33
63
|
}
|
|
@@ -37,16 +67,39 @@ export function readLaunchContext(environment, requirement = "optional", platfor
|
|
|
37
67
|
throw invalidContext("releaseDigest", "invalid digest");
|
|
38
68
|
if (requirement === "profile")
|
|
39
69
|
requireFields(values, ["launchProfile"]);
|
|
40
|
-
if (requirement === "release" || requirement === "warmup")
|
|
41
|
-
requireFields(values, ["releaseRoot", "releaseId", "releaseDigest"
|
|
70
|
+
if (requirement === "release" || requirement === "warmup") {
|
|
71
|
+
requireFields(values, ["releaseRoot", "releaseId", "releaseDigest"]);
|
|
72
|
+
// Compatibility: Windows drops an empty value from a child environment block, so a
|
|
73
|
+
// release with no dependency layer arrives without the key it was given. The
|
|
74
|
+
// layer list is metadata of the release being started, never authority, and an
|
|
75
|
+
// absent list means the same thing the empty string does.
|
|
76
|
+
if (values.releaseLayers === undefined)
|
|
77
|
+
values.releaseLayers = "";
|
|
78
|
+
}
|
|
42
79
|
if (requirement === "warmup")
|
|
43
80
|
requireFields(values, ["immutableWarmup"]);
|
|
44
81
|
return Object.freeze(values);
|
|
45
82
|
}
|
|
46
|
-
/**
|
|
47
|
-
export function
|
|
83
|
+
/** The key set a process started for this release must receive. */
|
|
84
|
+
export function launchEnvironmentKeys(contract) {
|
|
85
|
+
return contract === PRIVATE_LAUNCH_CONTRACT ? PRIVATE_ENVIRONMENT : SUPERSEDED_ENVIRONMENT;
|
|
86
|
+
}
|
|
87
|
+
/** Classify metadata by the contract it declares rather than rejecting what it omits. */
|
|
88
|
+
export function launchContractTarget(value) {
|
|
89
|
+
return value.launchContract === PRIVATE_LAUNCH_CONTRACT ? PRIVATE_LAUNCH_CONTRACT : "superseded";
|
|
90
|
+
}
|
|
91
|
+
/** Whether metadata was produced by a build that declares this contract. */
|
|
92
|
+
export function isCurrentLaunchContract(value) {
|
|
93
|
+
return value.launchContract === PRIVATE_LAUNCH_CONTRACT;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Rebuild owned context without inheriting a prior session's private selection. The
|
|
97
|
+
* contract selects which key set is written, so this build can start either its
|
|
98
|
+
* own releases or a retained pre-cutover release it must keep serving.
|
|
99
|
+
*/
|
|
100
|
+
export function withLaunchContext(environment, context, platform = process.platform, contract = PRIVATE_LAUNCH_CONTRACT) {
|
|
48
101
|
const result = withoutLaunchContext(environment, platform);
|
|
49
|
-
for (const [logical, key] of
|
|
102
|
+
for (const [logical, key] of Object.entries(launchEnvironmentKeys(contract))) {
|
|
50
103
|
const value = context[logical];
|
|
51
104
|
if (value !== undefined)
|
|
52
105
|
result[key] = value;
|
|
@@ -54,11 +107,11 @@ export function withLaunchContext(environment, context, platform = process.platf
|
|
|
54
107
|
readLaunchContext(result, "optional", platform);
|
|
55
108
|
return result;
|
|
56
109
|
}
|
|
57
|
-
/** Remove
|
|
110
|
+
/** Remove both key sets, leaving user and integration settings intact. */
|
|
58
111
|
export function withoutLaunchContext(environment, platform = process.platform) {
|
|
59
112
|
return Object.fromEntries(Object.entries(environment).filter(([key]) => !PRIVATE_KEYS.has(platform === "win32" ? key.toUpperCase() : key)));
|
|
60
113
|
}
|
|
61
|
-
/**
|
|
114
|
+
/** Require this build's own contract where nothing older can be accepted, such as recovery capsules. */
|
|
62
115
|
export function assertCurrentLaunchContract(value) {
|
|
63
116
|
if (value.launchContract !== PRIVATE_LAUNCH_CONTRACT) {
|
|
64
117
|
throw new Error("Unsupported private launch contract. Stop existing processes and install the current package directly with npm; review disposable runtime/release state before any manual reset. User settings, sessions, and history must be preserved.");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PRODUCT_TEXT } from "../../product-identity.js";
|
|
2
3
|
import { readLaunchContext } from "../launch-context/index.js";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { assertLaunchProfileId, sessionSelectionArguments } from "../lifecycle/index.js";
|
|
@@ -175,5 +176,5 @@ function exitCode(outcome) {
|
|
|
175
176
|
return 1;
|
|
176
177
|
}
|
|
177
178
|
function diagnosticError(message, code) {
|
|
178
|
-
return Object.assign(new Error(
|
|
179
|
+
return Object.assign(new Error(`${PRODUCT_TEXT.displayName}: ${message}`), { code });
|
|
179
180
|
}
|
|
@@ -36,6 +36,11 @@ export interface SupervisorStartupAttempt extends SupervisorStartupAttemptIdenti
|
|
|
36
36
|
}>;
|
|
37
37
|
}
|
|
38
38
|
export declare function startSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<SupervisorStartupAttempt>;
|
|
39
|
+
/**
|
|
40
|
+
* Hand a release the key set its own build reads. A retained pre-cutover release is
|
|
41
|
+
* still startable this way, which is what lets a launch fall back to the previous
|
|
42
|
+
* version instead of failing while an installation is being replaced.
|
|
43
|
+
*/
|
|
39
44
|
export declare function releaseEnvironment(environment: NodeJS.ProcessEnv, release: MaterializedRelease, profile?: LaunchProfileId): NodeJS.ProcessEnv;
|
|
40
45
|
export declare function waitForVerifiedEndpoint(path: string, release: MaterializedRelease, timeoutMs: number, startup?: SupervisorStartupAttempt): Promise<void>;
|
|
41
46
|
export declare function readEndpointMetadata(path: string): Promise<SupervisorEndpointMetadata | null>;
|
|
@@ -15,7 +15,7 @@ import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
|
15
15
|
import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument } from "./restart-certification.js";
|
|
16
16
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
17
17
|
import { markStartupPhase } from "../startup/index.js";
|
|
18
|
-
import {
|
|
18
|
+
import { launchContractTarget, readLaunchContext, withLaunchContext } from "../launch-context/index.js";
|
|
19
19
|
export async function runBootstrap(options) {
|
|
20
20
|
const environment = withLaunchContext(options.environment ?? process.env, { launchProfile: options.launchIntent?.profileId ?? "a1" });
|
|
21
21
|
await markStartupPhase(environment, "bootstrap-start");
|
|
@@ -29,9 +29,6 @@ export async function runBootstrap(options) {
|
|
|
29
29
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
30
30
|
const stateStore = new CohortStateStore(paths.dataDir);
|
|
31
31
|
let state = await stateStore.read();
|
|
32
|
-
const activeRecord = state.references.active === null ? undefined : state.releases[state.references.active];
|
|
33
|
-
if (activeRecord)
|
|
34
|
-
assertCurrentLaunchContract(activeRecord);
|
|
35
32
|
// Invariant: records left by cohorts whose processes are gone say nothing about ownership, and there
|
|
36
33
|
// can now be several of them. Clearing them first keeps the decision below about what is
|
|
37
34
|
// actually running.
|
|
@@ -91,7 +88,20 @@ export async function runBootstrap(options) {
|
|
|
91
88
|
return await launchUi(retained, environment, sessionArgs);
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
|
-
|
|
91
|
+
// Concurrency: an installation being replaced is not a reason to refuse a launch. While
|
|
92
|
+
// npm is rewriting the package tree the candidate cannot be read consistently, so a
|
|
93
|
+
// launch that cannot take the installed payload starts the retained active release
|
|
94
|
+
// instead and leaves activation to the launch that follows the replacement.
|
|
95
|
+
let candidate;
|
|
96
|
+
try {
|
|
97
|
+
candidate = await materializeRelease(options.packageRoot, paths.dataDir);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const fallback = await launchRetainedActive(state, paths, environment, sessionArgs, output);
|
|
101
|
+
if (fallback === null)
|
|
102
|
+
throw error;
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
95
105
|
await stateStore.recordCandidate(candidate);
|
|
96
106
|
state = await stateStore.read();
|
|
97
107
|
if (!state.references.active) {
|
|
@@ -186,6 +196,34 @@ export async function runBootstrap(options) {
|
|
|
186
196
|
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, selected.releaseId, environment).endpointMetadataPath, selected, 8_000, startup);
|
|
187
197
|
return await launchUi(selected, environment, sessionArgs);
|
|
188
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Start the retained active cohort when the installed payload cannot be taken. The
|
|
201
|
+
* release already carries certified evidence, so this reuses it rather than
|
|
202
|
+
* certifying anything new, and reports no selection when there is nothing safe
|
|
203
|
+
* to fall back to.
|
|
204
|
+
*/
|
|
205
|
+
async function launchRetainedActive(state, paths, environment, sessionArgs, output) {
|
|
206
|
+
const activeId = state.references.active;
|
|
207
|
+
const active = activeId === null ? undefined : state.releases[activeId];
|
|
208
|
+
if (!active || active.approval !== "approved")
|
|
209
|
+
return null;
|
|
210
|
+
try {
|
|
211
|
+
const retained = await readCertifiedReleaseManifest(active, resolve(paths.dataDir, "releases"));
|
|
212
|
+
const retainedPaths = resolveCohortEndpoint(paths, retained.releaseId, environment);
|
|
213
|
+
const endpoint = await readEndpointMetadata(retainedPaths.endpointMetadataPath);
|
|
214
|
+
if (!endpoint || await probeOwnership(endpoint) !== "live-verified") {
|
|
215
|
+
if (endpoint)
|
|
216
|
+
await removeEndpointArtifacts(retainedPaths.endpointMetadataPath, retainedPaths.endpoint);
|
|
217
|
+
const startup = await startSupervisor(retained, environment);
|
|
218
|
+
await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
|
|
219
|
+
}
|
|
220
|
+
output.write(`${PRODUCT_TEXT.diagnostic(`installation is being replaced; starting the retained release ${retained.packageVersion}`)}\n`);
|
|
221
|
+
return await launchUi(retained, environment, sessionArgs);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
189
227
|
async function readInstalledVersion(packageRoot) {
|
|
190
228
|
const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
|
|
191
229
|
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
|
|
@@ -194,7 +232,6 @@ async function readInstalledVersion(packageRoot) {
|
|
|
194
232
|
return manifest.version;
|
|
195
233
|
}
|
|
196
234
|
export async function certifyMaterializedRelease(release, dataDir, verification = {}) {
|
|
197
|
-
assertCurrentLaunchContract(release);
|
|
198
235
|
if (!consumeMaterializationProof(release)) {
|
|
199
236
|
await verifyMaterializedRelease(release.releaseRoot, release, resolve(dataDir, "releases"), verification);
|
|
200
237
|
}
|
|
@@ -202,7 +239,6 @@ export async function certifyMaterializedRelease(release, dataDir, verification
|
|
|
202
239
|
}
|
|
203
240
|
/** Persist current-format evidence after an authenticated parent has certified the exact release. */
|
|
204
241
|
export async function recordParentCertifiedRelease(release, dataDir) {
|
|
205
|
-
assertCurrentLaunchContract(release);
|
|
206
242
|
const path = resolve(dataDir, `certification-${release.releaseId}.json`);
|
|
207
243
|
const restartSeal = await createRestartSeal(release, dataDir);
|
|
208
244
|
await chmod(path, 0o600).catch(() => { });
|
|
@@ -243,15 +279,19 @@ async function launchUi(release, environment, sessionArgs) {
|
|
|
243
279
|
child.once("close", (code, signal) => resolvePromise(restoreAfterOwnedExit(readLaunchContext(environment).launchProfile === "a1", code, signal)));
|
|
244
280
|
});
|
|
245
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Hand a release the key set its own build reads. A retained pre-cutover release is
|
|
284
|
+
* still startable this way, which is what lets a launch fall back to the previous
|
|
285
|
+
* version instead of failing while an installation is being replaced.
|
|
286
|
+
*/
|
|
246
287
|
export function releaseEnvironment(environment, release, profile) {
|
|
247
|
-
assertCurrentLaunchContract(release);
|
|
248
288
|
return withLaunchContext(environment, {
|
|
249
289
|
releaseId: release.releaseId,
|
|
250
290
|
releaseLayers: (release.dependencyLayers ?? []).map(layer => layer.layerId).join(","),
|
|
251
291
|
releaseRoot: release.releaseRoot,
|
|
252
292
|
releaseDigest: release.contentDigest,
|
|
253
293
|
...(profile === undefined ? {} : { launchProfile: profile }),
|
|
254
|
-
});
|
|
294
|
+
}, process.platform, launchContractTarget(release));
|
|
255
295
|
}
|
|
256
296
|
export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
|
|
257
297
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
5
|
-
import { assertCurrentLaunchContract } from "../launch-context/index.js";
|
|
6
5
|
export const RELEASE_COHORT_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
|
|
7
6
|
const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
|
|
8
7
|
/**
|
|
@@ -122,7 +121,6 @@ export class CohortStateStore {
|
|
|
122
121
|
const release = requiredRelease(current, releaseId);
|
|
123
122
|
if (release.approval !== "approved")
|
|
124
123
|
throw new Error(`cannot activate unverified release ${releaseId}`);
|
|
125
|
-
assertCurrentLaunchContract(release);
|
|
126
124
|
const prior = current.references.active;
|
|
127
125
|
return {
|
|
128
126
|
...current,
|
|
@@ -150,7 +148,6 @@ export class CohortStateStore {
|
|
|
150
148
|
const rollback = requiredRelease(current, rollbackId);
|
|
151
149
|
if (rollback.approval !== "approved")
|
|
152
150
|
throw new Error(`cannot roll back to unverified release ${rollbackId}`);
|
|
153
|
-
assertCurrentLaunchContract(rollback);
|
|
154
151
|
return {
|
|
155
152
|
...current,
|
|
156
153
|
references: {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface DependencyCertificationProtection {
|
|
2
|
+
readonly referenced: Set<string>;
|
|
3
|
+
readonly legacyRequired: Set<string>;
|
|
4
|
+
readonly uncertain: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Inspect retained and not-yet-collected release metadata, never dependency payload bytes. */
|
|
7
|
+
export declare function dependencyCertificationProtection(dataDir: string, retainedReleaseIds?: readonly string[]): Promise<DependencyCertificationProtection>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
4
|
+
import { dependencyLayerCertificationPath } from "./dependency-certification.js";
|
|
5
|
+
import { RELEASE_MANIFEST_FILENAME } from "./release-store.js";
|
|
6
|
+
import { readRestartCertifiedRelease } from "./restart-certification.js";
|
|
7
|
+
/** Inspect retained and not-yet-collected release metadata, never dependency payload bytes. */
|
|
8
|
+
export async function dependencyCertificationProtection(dataDir, retainedReleaseIds = []) {
|
|
9
|
+
const referenced = new Set();
|
|
10
|
+
const legacyRequired = new Set();
|
|
11
|
+
const canonicalData = await realpath(dataDir);
|
|
12
|
+
const releasesRoot = resolve(canonicalData, "releases");
|
|
13
|
+
const roots = [];
|
|
14
|
+
let uncertain = false;
|
|
15
|
+
const rootMetadata = await lstat(releasesRoot);
|
|
16
|
+
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink())
|
|
17
|
+
return { referenced, legacyRequired, uncertain: true };
|
|
18
|
+
const entries = await readdir(releasesRoot, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (entry.isSymbolicLink()) {
|
|
21
|
+
uncertain = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!entry.isDirectory() || entry.name.startsWith(".candidate-"))
|
|
25
|
+
continue;
|
|
26
|
+
if (entry.name === ".trash") {
|
|
27
|
+
for (const trash of await readdir(resolve(releasesRoot, entry.name), { withFileTypes: true })) {
|
|
28
|
+
if (trash.isSymbolicLink())
|
|
29
|
+
uncertain = true;
|
|
30
|
+
else if (trash.isDirectory() && !trash.name.startsWith(".candidate"))
|
|
31
|
+
roots.push(resolve(releasesRoot, entry.name, trash.name));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
roots.push(resolve(releasesRoot, entry.name));
|
|
36
|
+
}
|
|
37
|
+
const discoveredIds = new Set(roots.map(root => basename(root).split("--", 1)[0]));
|
|
38
|
+
if (retainedReleaseIds.some(id => !discoveredIds.has(id)))
|
|
39
|
+
uncertain = true;
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
try {
|
|
42
|
+
const manifestPath = resolve(root, RELEASE_MANIFEST_FILENAME);
|
|
43
|
+
const metadata = await lstat(manifestPath);
|
|
44
|
+
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
45
|
+
throw new Error("release manifest is not a regular file");
|
|
46
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
47
|
+
if (!manifest || !/^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/.test(manifest.releaseId)
|
|
48
|
+
|| (manifest.dependencyLayers !== undefined && !Array.isArray(manifest.dependencyLayers)))
|
|
49
|
+
throw new Error("invalid retained release manifest");
|
|
50
|
+
const seal = await canonicalConsumerSeal(canonicalData, manifest.releaseId, manifest.contentDigest);
|
|
51
|
+
for (const layer of manifest.dependencyLayers ?? []) {
|
|
52
|
+
if (!layer || !/^dependencies-[a-f0-9]{32}$/.test(layer.layerId))
|
|
53
|
+
throw new Error("invalid retained dependency reference");
|
|
54
|
+
referenced.add(layer.layerId);
|
|
55
|
+
const evidence = seal?.dependencyLayers.filter(item => item.layerId === layer.layerId);
|
|
56
|
+
if (evidence?.length !== 1 || evidence[0].contentDigest !== layer.contentDigest
|
|
57
|
+
|| evidence[0].certification?.path !== dependencyLayerCertificationPath(canonicalData, layer.layerId)) {
|
|
58
|
+
// Compatibility: an absent, legacy, or uncertain seal cannot prove canonical-only use.
|
|
59
|
+
legacyRequired.add(layer.layerId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Security: an unreadable retained manifest might reference any layer; do not guess ownership.
|
|
65
|
+
uncertain = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { referenced, legacyRequired, uncertain };
|
|
69
|
+
}
|
|
70
|
+
async function canonicalConsumerSeal(dataDir, releaseId, contentDigest) {
|
|
71
|
+
try {
|
|
72
|
+
const path = resolve(dataDir, `certification-${releaseId}.json`);
|
|
73
|
+
const metadata = await lstat(path);
|
|
74
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o222) !== 0)
|
|
75
|
+
return null;
|
|
76
|
+
const document = JSON.parse(await readFile(path, "utf8"));
|
|
77
|
+
if (document.schema !== PRODUCT_IDENTITY.evidence.releaseCertificationSchema || document.releaseId !== releaseId
|
|
78
|
+
|| document.contentDigest !== contentDigest || !document.restartSeal)
|
|
79
|
+
return null;
|
|
80
|
+
// Security: path claims alone do not prove a canonical-only consumer. Reuse the bounded
|
|
81
|
+
// restart validator so stale, unsupported, or tampered seals conservatively retain legacy files.
|
|
82
|
+
await readRestartCertifiedRelease({ releaseId, contentDigest, releaseRoot: resolve(dataDir, "releases", releaseId) }, dataDir);
|
|
83
|
+
return document.restartSeal;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const DEPENDENCY_CERTIFICATIONS_DIRECTORY = "dependency-certifications";
|
|
2
|
+
type LayerIdentity = {
|
|
3
|
+
readonly layerId: string;
|
|
4
|
+
readonly contentDigest: string;
|
|
5
|
+
};
|
|
6
|
+
export interface ReadDependencyCertificationOptions {
|
|
7
|
+
/** Only authenticated parent-started supervisors may upgrade missing platform evidence. */
|
|
8
|
+
readonly allowLegacyParentCertification?: boolean;
|
|
9
|
+
/** Cleanup must validate canonical evidence without migrating or falling back. */
|
|
10
|
+
readonly canonicalOnly?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Derive the canonical record path without accepting path components as layer identities. */
|
|
13
|
+
export declare function dependencyLayerCertificationPath(dataDir: string, layerId: string): string;
|
|
14
|
+
/** Keep the historical filename solely for compatibility reads and managed cleanup. */
|
|
15
|
+
export declare function legacyDependencyLayerCertificationPath(dataDir: string, layerId: string): string;
|
|
16
|
+
/** Validate the dedicated directory before reading, creating, or deleting any contained record. */
|
|
17
|
+
export declare function dependencyCertificationDirectory(dataDir: string, create?: boolean): Promise<string | null>;
|
|
18
|
+
/** Resolve only direct regular managed records; callers never follow directory or record links. */
|
|
19
|
+
export declare function managedDependencyCertificationPath(dataDir: string, layerId: string, legacy?: boolean): Promise<string | null>;
|
|
20
|
+
/** Prefer canonical evidence; copy validated legacy evidence without changing its sealed source. */
|
|
21
|
+
export declare function readDependencyCertification(dataDir: string, identity: LayerIdentity, options?: ReadDependencyCertificationOptions): Promise<void>;
|
|
22
|
+
/** Publish certification after materialization/full verification, preserving an already-valid winner. */
|
|
23
|
+
export declare function writeDependencyCertification(dataDir: string, identity: LayerIdentity): Promise<void>;
|
|
24
|
+
export {};
|