@evomap/evolver-proxy 2.0.0-beta.2 → 2.0.0-beta.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/dist/bin/evolver-llm-proxy.js +0 -0
- package/dist/bin/evolver-proxy.d.ts +67 -6
- package/dist/bin/evolver-proxy.js +386 -76
- package/dist/daemon/collaborationFacade.js +3 -3
- package/dist/llm/traceControl.js +1 -1
- package/dist/selfUpdate/executor.d.ts +10 -5
- package/dist/selfUpdate/executor.js +81 -6
- package/dist/selfUpdate/failureCodes.d.ts +6 -0
- package/dist/selfUpdate/failureCodes.js +6 -0
- package/dist/selfUpdate/index.d.ts +4 -1
- package/dist/selfUpdate/index.js +4 -1
- package/dist/selfUpdate/lastUpdate.d.ts +3 -1
- package/dist/selfUpdate/lastUpdate.js +37 -6
- package/dist/selfUpdate/releaseBinary.d.ts +10 -0
- package/dist/selfUpdate/releaseBinary.js +43 -6
- package/dist/selfUpdate/transaction.d.ts +109 -0
- package/dist/selfUpdate/transaction.js +1174 -0
- package/dist/selfUpdate/unixController.d.ts +15 -0
- package/dist/selfUpdate/unixController.js +186 -0
- package/dist/selfUpdate/version.d.ts +6 -2
- package/dist/selfUpdate/version.js +5 -3
- package/dist/selfUpdate/windowsController.d.ts +23 -0
- package/dist/selfUpdate/windowsController.js +274 -0
- package/dist/selfUpdate/windowsUpdater.d.ts +79 -0
- package/dist/selfUpdate/windowsUpdater.js +715 -0
- package/package.json +7 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ops } from '@evomap/evolver-core';
|
|
2
2
|
import { type SelfUpdateFailureCode } from './failureCodes.js';
|
|
3
|
+
import type { DurableSelfUpdateSession } from './transaction.js';
|
|
3
4
|
type DownloadedArtifact = ops.DownloadedArtifact;
|
|
4
5
|
/** Structured outcome codes — reported to telemetry; the hub sees WHY an update did/didn't apply. */
|
|
5
|
-
export type SelfUpdateOutcome = 'applied' | 'noop' | 'rejected_decision' | 'rejected_verification' | 'download_failed' | 'replace_failed' | 'already_in_progress' | 'disabled';
|
|
6
|
+
export type SelfUpdateOutcome = 'applied' | 'noop' | 'rejected_decision' | 'rejected_verification' | 'download_failed' | 'replace_failed' | 'restart_failed' | 'rollback_failed' | 'already_in_progress' | 'disabled';
|
|
6
7
|
export interface SelfUpdateResult {
|
|
7
8
|
outcome: SelfUpdateOutcome;
|
|
8
9
|
reason: string;
|
|
@@ -13,13 +14,15 @@ export interface SelfUpdateResult {
|
|
|
13
14
|
/**
|
|
14
15
|
* Which download path produced the staged binary: `'binary'` is the normal
|
|
15
16
|
* precompiled-asset happy path, `'tarball'` means the binary download failed
|
|
16
|
-
* and Channel 1b (release `.tar.gz`) fallback was used. The
|
|
17
|
+
* and Channel 1b (release `.tar.gz`) fallback was used. The selected-artifact
|
|
17
18
|
* gate ran in BOTH cases, so apply-semantics are identical, but "tarball
|
|
18
19
|
* used in production" is a useful CDN/rate-limit signal for the hub.
|
|
19
20
|
* Persisted into `last_update.json` (lastUpdate.LastUpdatePayload.applied_via)
|
|
20
21
|
* on success so the hub can observe the channel directly.
|
|
21
22
|
*/
|
|
22
23
|
appliedVia?: 'binary' | 'tarball';
|
|
24
|
+
/** Durable installs are not successful until the relaunched daemon completes startup health checks. */
|
|
25
|
+
confirmationPending?: true;
|
|
23
26
|
}
|
|
24
27
|
/** The hub's force_update directive (inbound message payload). */
|
|
25
28
|
export interface ForceUpdateDirective {
|
|
@@ -36,7 +39,7 @@ export interface ForceUpdateDirective {
|
|
|
36
39
|
export interface DownloadResult {
|
|
37
40
|
/** Where the new version was staged (e.g. a tmp dir). Passed to atomicReplace on success. */
|
|
38
41
|
stagedPath: string;
|
|
39
|
-
/**
|
|
42
|
+
/** Exactly one selected artifact (bytes or precomputed sha256) for verification. */
|
|
40
43
|
artifacts: readonly DownloadedArtifact[];
|
|
41
44
|
/**
|
|
42
45
|
* Which channel actually produced the staged bytes — defaults to `'binary'`
|
|
@@ -63,8 +66,10 @@ export interface SelfUpdateDeps {
|
|
|
63
66
|
download: (targetVersion: string, directive: ForceUpdateDirective) => Promise<DownloadResult>;
|
|
64
67
|
/** Atomically replace the install tree with the staged path (preserving node_modules/.env/etc). Throws on fail. */
|
|
65
68
|
atomicReplace: (stagedPath: string) => Promise<void>;
|
|
69
|
+
/** Optional durable transaction: cross-process lock + journal + backup + recovery-aware install. */
|
|
70
|
+
beginTransaction?: (targetVersion: string) => Promise<DurableSelfUpdateSession>;
|
|
66
71
|
/** Signal a restart so the supervisor relaunches the new version. v1 convention: process.exit(78). */
|
|
67
|
-
restart: () => void
|
|
72
|
+
restart: () => void | Promise<void>;
|
|
68
73
|
/** Optional Ed25519 public key (PEM / raw base64). When set, an unsigned/badly-signed manifest is REJECTED. */
|
|
69
74
|
publicKey?: string;
|
|
70
75
|
/** Best-effort telemetry sink for the structured outcome (never throws into the update path). */
|
|
@@ -80,7 +85,7 @@ export declare function _resetSelfUpdateMutex(): void;
|
|
|
80
85
|
* 2. decideUpdate (pure): reject bad manifests, NOOP when already satisfied (no download, no restart).
|
|
81
86
|
* 3. mutex: exactly one execution; concurrent callers get `already_in_progress` and touch no disk.
|
|
82
87
|
* 4. download the staged release.
|
|
83
|
-
* 5.
|
|
88
|
+
* 5. verifySelectedManifestArtifact (pure) — THE GATE. Fail → no write, no restart.
|
|
84
89
|
* 6. atomicReplace, then restart(). Only reached after verification passed.
|
|
85
90
|
*
|
|
86
91
|
* Never throws: every failure becomes a structured SelfUpdateResult so the daemon can report it and keep running
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// shells out or touches a real install tree by accident.
|
|
5
5
|
//
|
|
6
6
|
// THE HARD GATE (verify-before-apply, non-negotiable): after download and BEFORE any filesystem write or restart,
|
|
7
|
-
// the executor calls the PURE core
|
|
7
|
+
// the executor calls the PURE core verifySelectedManifestArtifact. If verification fails — bad sha256,
|
|
8
|
+
// missing/invalid signature
|
|
8
9
|
// when a key is configured, anything — the executor writes NOTHING, restarts NOTHING, and returns a structured
|
|
9
10
|
// failure. The old version stays intact and runnable. A compromised hub cannot turn this channel into fleet RCE.
|
|
10
11
|
//
|
|
@@ -12,7 +13,7 @@
|
|
|
12
13
|
// once. The second caller short-circuits with `already_in_progress` and performs no I/O.
|
|
13
14
|
import { ops } from '@evomap/evolver-core';
|
|
14
15
|
import { SELF_UPDATE_FAILURE_CODES, classifySelfUpdateError, codeForDecisionReject, } from './failureCodes.js';
|
|
15
|
-
const { decideUpdate,
|
|
16
|
+
const { decideUpdate, verifySelectedManifestArtifact } = ops;
|
|
16
17
|
// Process-level mutex. Module scope is correct: there is one daemon per process, and v1's _forceUpdateInFlight had
|
|
17
18
|
// the same lifetime. Guards against two force_update envelopes (or a heartbeat-driven + mailbox-driven trigger)
|
|
18
19
|
// racing the same upgrade and replacing files twice / double-restarting.
|
|
@@ -38,7 +39,7 @@ function report(deps, result) {
|
|
|
38
39
|
* 2. decideUpdate (pure): reject bad manifests, NOOP when already satisfied (no download, no restart).
|
|
39
40
|
* 3. mutex: exactly one execution; concurrent callers get `already_in_progress` and touch no disk.
|
|
40
41
|
* 4. download the staged release.
|
|
41
|
-
* 5.
|
|
42
|
+
* 5. verifySelectedManifestArtifact (pure) — THE GATE. Fail → no write, no restart.
|
|
42
43
|
* 6. atomicReplace, then restart(). Only reached after verification passed.
|
|
43
44
|
*
|
|
44
45
|
* Never throws: every failure becomes a structured SelfUpdateResult so the daemon can report it and keep running
|
|
@@ -103,13 +104,29 @@ export async function executeForceUpdate(directive, deps) {
|
|
|
103
104
|
}
|
|
104
105
|
inFlight = true;
|
|
105
106
|
const targetVersion = decision.targetVersion ?? manifest?.version ?? '';
|
|
107
|
+
let transaction;
|
|
106
108
|
try {
|
|
109
|
+
if (deps.beginTransaction) {
|
|
110
|
+
try {
|
|
111
|
+
transaction = await deps.beginTransaction(targetVersion);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const classified = classifySelfUpdateError(err, SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED);
|
|
115
|
+
return report(deps, {
|
|
116
|
+
outcome: classified.failureCode === SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED ? 'already_in_progress' : 'replace_failed',
|
|
117
|
+
reason: classified.detail,
|
|
118
|
+
failureCode: classified.failureCode,
|
|
119
|
+
targetVersion,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
107
123
|
// 4. Download the staged release.
|
|
108
124
|
let dl;
|
|
109
125
|
try {
|
|
110
126
|
dl = await deps.download(targetVersion, effectiveDirective);
|
|
111
127
|
}
|
|
112
128
|
catch (err) {
|
|
129
|
+
await transaction?.abort(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_FAILED).catch(() => { });
|
|
113
130
|
const classified = classifySelfUpdateError(err, SELF_UPDATE_FAILURE_CODES.DOWNLOAD_FAILED);
|
|
114
131
|
return report(deps, {
|
|
115
132
|
outcome: 'download_failed',
|
|
@@ -118,9 +135,25 @@ export async function executeForceUpdate(directive, deps) {
|
|
|
118
135
|
targetVersion,
|
|
119
136
|
});
|
|
120
137
|
}
|
|
138
|
+
if (transaction) {
|
|
139
|
+
try {
|
|
140
|
+
dl = await transaction.adoptDownloaded(dl);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
await transaction.abort(SELF_UPDATE_FAILURE_CODES.REPLACE_FAILED).catch(() => { });
|
|
144
|
+
const classified = classifySelfUpdateError(err, SELF_UPDATE_FAILURE_CODES.REPLACE_FAILED);
|
|
145
|
+
return report(deps, {
|
|
146
|
+
outcome: 'replace_failed',
|
|
147
|
+
reason: classified.detail,
|
|
148
|
+
failureCode: classified.failureCode,
|
|
149
|
+
targetVersion,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
121
153
|
// 5. THE GATE: verify the downloaded bytes against the (optionally signed) manifest BEFORE any write.
|
|
122
|
-
const verification =
|
|
154
|
+
const verification = verifySelectedManifestArtifact(manifest, dl.artifacts, ...(deps.publicKey ? [deps.publicKey] : []));
|
|
123
155
|
if (!verification.ok) {
|
|
156
|
+
await transaction?.abort(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION).catch(() => { });
|
|
124
157
|
// Verification failed → write NOTHING, restart NOTHING. Old version stays intact and runnable.
|
|
125
158
|
return report(deps, {
|
|
126
159
|
outcome: 'rejected_verification',
|
|
@@ -129,9 +162,25 @@ export async function executeForceUpdate(directive, deps) {
|
|
|
129
162
|
targetVersion,
|
|
130
163
|
});
|
|
131
164
|
}
|
|
165
|
+
try {
|
|
166
|
+
await transaction?.markVerified(dl.artifacts);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
await transaction?.abort(SELF_UPDATE_FAILURE_CODES.REPLACE_FAILED).catch(() => { });
|
|
170
|
+
const classified = classifySelfUpdateError(err, SELF_UPDATE_FAILURE_CODES.REPLACE_FAILED);
|
|
171
|
+
return report(deps, {
|
|
172
|
+
outcome: 'replace_failed',
|
|
173
|
+
reason: classified.detail,
|
|
174
|
+
failureCode: classified.failureCode,
|
|
175
|
+
targetVersion,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
132
178
|
// 6. Verified. Atomic replace, then signal restart. A replace failure leaves the old version intact.
|
|
133
179
|
try {
|
|
134
|
-
|
|
180
|
+
if (transaction)
|
|
181
|
+
await transaction.install();
|
|
182
|
+
else
|
|
183
|
+
await deps.atomicReplace(dl.stagedPath);
|
|
135
184
|
}
|
|
136
185
|
catch (err) {
|
|
137
186
|
const classified = classifySelfUpdateError(err, SELF_UPDATE_FAILURE_CODES.COPY_FAILED);
|
|
@@ -147,12 +196,38 @@ export async function executeForceUpdate(directive, deps) {
|
|
|
147
196
|
reason: 'verified_and_replaced',
|
|
148
197
|
targetVersion,
|
|
149
198
|
appliedVia: dl.appliedVia ?? 'binary',
|
|
199
|
+
...(transaction ? { confirmationPending: true } : {}),
|
|
150
200
|
};
|
|
151
201
|
report(deps, result);
|
|
152
|
-
|
|
202
|
+
try {
|
|
203
|
+
await transaction?.markRestartRequested();
|
|
204
|
+
await deps.restart(); // v1 convention: exit(78) → supervisor relaunches the new version.
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const classified = classifySelfUpdateError(err, SELF_UPDATE_FAILURE_CODES.RESTART_FAILED);
|
|
208
|
+
try {
|
|
209
|
+
await transaction?.rollback(classified.failureCode);
|
|
210
|
+
}
|
|
211
|
+
catch (rollbackError) {
|
|
212
|
+
const rollback = classifySelfUpdateError(rollbackError, SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED);
|
|
213
|
+
return report(deps, {
|
|
214
|
+
outcome: 'rollback_failed',
|
|
215
|
+
reason: rollback.detail,
|
|
216
|
+
failureCode: rollback.failureCode,
|
|
217
|
+
targetVersion,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return report(deps, {
|
|
221
|
+
outcome: 'restart_failed',
|
|
222
|
+
reason: classified.detail,
|
|
223
|
+
failureCode: classified.failureCode,
|
|
224
|
+
targetVersion,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
153
227
|
return result;
|
|
154
228
|
}
|
|
155
229
|
finally {
|
|
230
|
+
await transaction?.release().catch(() => { });
|
|
156
231
|
// Released so a later legitimate update (after a failed attempt) can proceed. On the success path the process
|
|
157
232
|
// is exiting anyway; releasing is harmless and keeps the mutex honest if restart() is a test fake that returns.
|
|
158
233
|
inFlight = false;
|
|
@@ -17,6 +17,12 @@ export declare const SELF_UPDATE_FAILURE_CODES: Readonly<{
|
|
|
17
17
|
readonly FALLBACK_DOWNLOAD_FAILED: "fallback_download_failed";
|
|
18
18
|
readonly FALLBACK_EXTRACT_FAILED: "fallback_extract_failed";
|
|
19
19
|
readonly FALLBACK_MISSING_BINARY: "fallback_missing_binary";
|
|
20
|
+
readonly UPDATE_LOCKED: "update_locked";
|
|
21
|
+
readonly RECOVERY_REQUIRED: "recovery_required";
|
|
22
|
+
readonly UNSAFE_UPDATE_PATH: "unsafe_update_path";
|
|
23
|
+
readonly RESTART_FAILED: "restart_failed";
|
|
24
|
+
readonly READ_BACK_FAILED: "read_back_failed";
|
|
25
|
+
readonly ROLLBACK_FAILED: "rollback_failed";
|
|
20
26
|
}>;
|
|
21
27
|
export type SelfUpdateFailureCode = typeof SELF_UPDATE_FAILURE_CODES[keyof typeof SELF_UPDATE_FAILURE_CODES];
|
|
22
28
|
export interface ClassifiedSelfUpdateError {
|
|
@@ -22,6 +22,12 @@ export const SELF_UPDATE_FAILURE_CODES = Object.freeze({
|
|
|
22
22
|
FALLBACK_DOWNLOAD_FAILED: 'fallback_download_failed',
|
|
23
23
|
FALLBACK_EXTRACT_FAILED: 'fallback_extract_failed',
|
|
24
24
|
FALLBACK_MISSING_BINARY: 'fallback_missing_binary',
|
|
25
|
+
UPDATE_LOCKED: 'update_locked',
|
|
26
|
+
RECOVERY_REQUIRED: 'recovery_required',
|
|
27
|
+
UNSAFE_UPDATE_PATH: 'unsafe_update_path',
|
|
28
|
+
RESTART_FAILED: 'restart_failed',
|
|
29
|
+
READ_BACK_FAILED: 'read_back_failed',
|
|
30
|
+
ROLLBACK_FAILED: 'rollback_failed',
|
|
25
31
|
});
|
|
26
32
|
export class SelfUpdateFailureError extends Error {
|
|
27
33
|
failureCode;
|
|
@@ -2,4 +2,7 @@ export * from './executor.js';
|
|
|
2
2
|
export * from './version.js';
|
|
3
3
|
export * from './policy.js';
|
|
4
4
|
export * from './releaseBinary.js';
|
|
5
|
-
export * from './
|
|
5
|
+
export * from './transaction.js';
|
|
6
|
+
export * from './failureCodes.js';
|
|
7
|
+
export * from './unixController.js';
|
|
8
|
+
export * from './windowsController.js';
|
package/dist/selfUpdate/index.js
CHANGED
|
@@ -2,4 +2,7 @@ export * from './executor.js';
|
|
|
2
2
|
export * from './version.js';
|
|
3
3
|
export * from './policy.js';
|
|
4
4
|
export * from './releaseBinary.js';
|
|
5
|
-
export * from './
|
|
5
|
+
export * from './transaction.js';
|
|
6
|
+
export * from './failureCodes.js';
|
|
7
|
+
export * from './unixController.js';
|
|
8
|
+
export * from './windowsController.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mailbox } from '@evomap/evolver-core';
|
|
2
2
|
import type { ForceUpdateDirective, SelfUpdateResult } from './executor.js';
|
|
3
|
+
import type { SelfUpdateRecoveryResult } from './transaction.js';
|
|
3
4
|
type MailboxStore = mailbox.MailboxStore;
|
|
4
5
|
type LastUpdateStatus = 'success' | 'failed' | 'skipped' | 'pending';
|
|
5
6
|
export interface LastUpdatePayload {
|
|
@@ -12,7 +13,7 @@ export interface LastUpdatePayload {
|
|
|
12
13
|
/**
|
|
13
14
|
* Which download channel produced the bytes that got applied: `'binary'` is
|
|
14
15
|
* the precompiled-asset happy path, `'tarball'` means Channel 1b fallback
|
|
15
|
-
* (release `.tar.gz`) was used. Persisted on success so the hub can see
|
|
16
|
+
* (release `.tar.gz`) was used. Persisted while confirmation is pending and on success so the hub can see
|
|
16
17
|
* "primary CDN is degraded — fallback carrying production" without having
|
|
17
18
|
* to mine telemetry. Absent on non-success or when the executor predates
|
|
18
19
|
* the appliedVia field.
|
|
@@ -36,6 +37,7 @@ export declare function reportPendingSelfUpdateLastUpdate(store: MailboxStore, d
|
|
|
36
37
|
fromVersion: string;
|
|
37
38
|
now?: number;
|
|
38
39
|
}): boolean;
|
|
40
|
+
export declare function finalizeSelfUpdateRecoveryLastUpdate(store: MailboxStore, recovery: SelfUpdateRecoveryResult, now?: number): boolean;
|
|
39
41
|
export declare function lastUpdateFromSelfUpdateResult(directive: ForceUpdateDirective, result: SelfUpdateResult, opts: {
|
|
40
42
|
fromVersion: string;
|
|
41
43
|
now: number;
|
|
@@ -86,6 +86,37 @@ export function reportPendingSelfUpdateLastUpdate(store, directive, opts = { fro
|
|
|
86
86
|
...(directive.directive_id ? { directive_id: String(directive.directive_id) } : {}),
|
|
87
87
|
}, now);
|
|
88
88
|
}
|
|
89
|
+
export function finalizeSelfUpdateRecoveryLastUpdate(store, recovery, now = Date.now()) {
|
|
90
|
+
if (recovery.outcome !== 'confirmed'
|
|
91
|
+
&& recovery.outcome !== 'rolled_back'
|
|
92
|
+
&& recovery.outcome !== 'blocked')
|
|
93
|
+
return false;
|
|
94
|
+
const toVersion = concreteVersion(recovery.targetVersion);
|
|
95
|
+
const fromVersion = concreteVersion(recovery.fromVersion);
|
|
96
|
+
if (!toVersion)
|
|
97
|
+
return false;
|
|
98
|
+
const current = readPendingLastUpdate(store, now);
|
|
99
|
+
if (current && current.to_version !== toVersion)
|
|
100
|
+
return false;
|
|
101
|
+
const common = {
|
|
102
|
+
to_version: toVersion,
|
|
103
|
+
finished_at: Math.max(now, FINISHED_AT_MIN_MS),
|
|
104
|
+
...(current?.directive_id ? { directive_id: current.directive_id } : {}),
|
|
105
|
+
...(current?.from_version ? { from_version: current.from_version } : fromVersion ? { from_version: fromVersion } : {}),
|
|
106
|
+
};
|
|
107
|
+
if (recovery.outcome === 'confirmed') {
|
|
108
|
+
return writeLastUpdate(store, {
|
|
109
|
+
...common,
|
|
110
|
+
status: 'success',
|
|
111
|
+
...(current?.applied_via ? { applied_via: current.applied_via } : {}),
|
|
112
|
+
}, now);
|
|
113
|
+
}
|
|
114
|
+
return writeLastUpdate(store, {
|
|
115
|
+
...common,
|
|
116
|
+
status: 'failed',
|
|
117
|
+
error: clampString(hubNs.redactString(`${recovery.failureCode ?? 'self_update_recovery_failed'}: ${recovery.outcome}`), LAST_UPDATE_ERROR_MAX),
|
|
118
|
+
}, now);
|
|
119
|
+
}
|
|
89
120
|
export function lastUpdateFromSelfUpdateResult(directive, result, opts) {
|
|
90
121
|
if (result.outcome === 'already_in_progress' || result.outcome === 'disabled')
|
|
91
122
|
return undefined;
|
|
@@ -94,11 +125,11 @@ export function lastUpdateFromSelfUpdateResult(directive, result, opts) {
|
|
|
94
125
|
return undefined;
|
|
95
126
|
const base = {
|
|
96
127
|
to_version: toVersion,
|
|
97
|
-
status:
|
|
128
|
+
status: statusForResult(result),
|
|
98
129
|
finished_at: Math.max(opts.now, FINISHED_AT_MIN_MS),
|
|
99
130
|
...(directive.directive_id ? { directive_id: String(directive.directive_id) } : {}),
|
|
100
131
|
};
|
|
101
|
-
if (base.status === 'success') {
|
|
132
|
+
if (base.status === 'success' || base.status === 'pending') {
|
|
102
133
|
return {
|
|
103
134
|
...base,
|
|
104
135
|
from_version: clampString(opts.fromVersion, LAST_UPDATE_FROM_VERSION_MAX),
|
|
@@ -114,10 +145,10 @@ export function lastUpdateFromSelfUpdateResult(directive, result, opts) {
|
|
|
114
145
|
}
|
|
115
146
|
return base;
|
|
116
147
|
}
|
|
117
|
-
function
|
|
118
|
-
if (outcome === 'applied')
|
|
119
|
-
return 'success';
|
|
120
|
-
if (outcome === 'noop')
|
|
148
|
+
function statusForResult(result) {
|
|
149
|
+
if (result.outcome === 'applied')
|
|
150
|
+
return result.confirmationPending ? 'pending' : 'success';
|
|
151
|
+
if (result.outcome === 'noop')
|
|
121
152
|
return 'skipped';
|
|
122
153
|
return 'failed';
|
|
123
154
|
}
|
|
@@ -11,8 +11,18 @@ export interface ReleaseBinaryOptions {
|
|
|
11
11
|
targetPath?: string;
|
|
12
12
|
processExecPath?: string;
|
|
13
13
|
requireSignedManifest?: boolean;
|
|
14
|
+
maxPrimaryBinaryBytes?: number;
|
|
14
15
|
maxExtractedTarballBytes?: number;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Hard ceiling for primary release binaries. The binary is buffered before its
|
|
19
|
+
* manifest hash is verified, so an unbounded response could exhaust memory
|
|
20
|
+
* before the verification gate runs. 128MiB leaves headroom above current
|
|
21
|
+
* single-platform binaries while bounding that pre-verification allocation.
|
|
22
|
+
*/
|
|
23
|
+
export declare const MAX_PRIMARY_BINARY_BYTES: number;
|
|
24
|
+
/** Release metadata is untrusted and buffered before parsing or verification. */
|
|
25
|
+
export declare const MAX_RELEASE_METADATA_BYTES: number;
|
|
16
26
|
/**
|
|
17
27
|
* Hard ceiling for Channel 1b tarball downloads. A compromised/corrupt release
|
|
18
28
|
* could advertise a multi-GB tar.gz and OOM us because tarballBytes is buffered
|
|
@@ -8,6 +8,15 @@ import { SELF_UPDATE_FAILURE_CODES, SelfUpdateFailureError, selfUpdateFailure }
|
|
|
8
8
|
const DEFAULT_RELEASES_URL = 'https://github.com/EvoMap/evolver/releases';
|
|
9
9
|
const SIGNED_MANIFEST_ASSET = 'evolver-update-manifest.json';
|
|
10
10
|
const RELEASE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* Hard ceiling for primary release binaries. The binary is buffered before its
|
|
13
|
+
* manifest hash is verified, so an unbounded response could exhaust memory
|
|
14
|
+
* before the verification gate runs. 128MiB leaves headroom above current
|
|
15
|
+
* single-platform binaries while bounding that pre-verification allocation.
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_PRIMARY_BINARY_BYTES = 128 * 1024 * 1024;
|
|
18
|
+
/** Release metadata is untrusted and buffered before parsing or verification. */
|
|
19
|
+
export const MAX_RELEASE_METADATA_BYTES = 256 * 1024;
|
|
11
20
|
/**
|
|
12
21
|
* Hard ceiling for Channel 1b tarball downloads. A compromised/corrupt release
|
|
13
22
|
* could advertise a multi-GB tar.gz and OOM us because tarballBytes is buffered
|
|
@@ -75,7 +84,7 @@ export async function downloadGithubReleaseArtifact(targetVersion, directive, op
|
|
|
75
84
|
}
|
|
76
85
|
async function downloadBinaryAsset(version, assetName, directive, opts) {
|
|
77
86
|
const assetUrl = releaseDownloadUrl(directive.release_url, version, assetName);
|
|
78
|
-
const bytes = Buffer.from(await fetchBytes(assetUrl, opts.fetchFn));
|
|
87
|
+
const bytes = Buffer.from(await fetchBytes(assetUrl, opts.fetchFn, resolvedPrimaryBinaryLimit(opts.maxPrimaryBinaryBytes)));
|
|
79
88
|
if (bytes.byteLength === 0) {
|
|
80
89
|
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_INCOMPLETE, `empty release asset:${assetName}`);
|
|
81
90
|
}
|
|
@@ -192,7 +201,7 @@ export async function atomicReplaceExecutable(stagedPath, opts = {}) {
|
|
|
192
201
|
}
|
|
193
202
|
}
|
|
194
203
|
export function resolveSelfUpdateTarget(opts = {}) {
|
|
195
|
-
const explicitTarget = opts.targetPath ?? opts.env?.['EVOLVER_SELF_UPDATE_TARGET_PATH'];
|
|
204
|
+
const explicitTarget = opts.targetPath ?? opts.env?.['EVOLVER_SELF_UPDATE_TARGET_PATH']?.trim();
|
|
196
205
|
if (explicitTarget)
|
|
197
206
|
return { path: explicitTarget, explicit: true };
|
|
198
207
|
const execPath = opts.processExecPath ?? process.execPath;
|
|
@@ -207,7 +216,10 @@ function releaseDownloadUrl(releaseUrl, version, assetName) {
|
|
|
207
216
|
base = new URL(releaseUrl && releaseUrl.trim() ? releaseUrl : DEFAULT_RELEASES_URL);
|
|
208
217
|
}
|
|
209
218
|
catch (err) {
|
|
210
|
-
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_FAILED,
|
|
219
|
+
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_FAILED, 'invalid_release_url', { cause: err });
|
|
220
|
+
}
|
|
221
|
+
if (base.username || base.password) {
|
|
222
|
+
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_FAILED, 'invalid_release_url_credentials');
|
|
211
223
|
}
|
|
212
224
|
if (base.protocol !== 'https:' || base.hostname !== 'github.com') {
|
|
213
225
|
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_FAILED, 'invalid_release_url_origin');
|
|
@@ -242,8 +254,8 @@ async function fetchSignedReleaseManifest(releaseUrl, version, assetName, fetchF
|
|
|
242
254
|
if (!manifestVersion) {
|
|
243
255
|
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_INCOMPLETE, 'signed_manifest_invalid_version');
|
|
244
256
|
}
|
|
245
|
-
if (
|
|
246
|
-
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOADED_VERSION_MISMATCH, '
|
|
257
|
+
if (manifestVersion !== version) {
|
|
258
|
+
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOADED_VERSION_MISMATCH, 'signed_manifest_version_mismatch');
|
|
247
259
|
}
|
|
248
260
|
const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
|
|
249
261
|
if (!artifacts.some((artifact) => artifact && typeof artifact.path === 'string' && basename(artifact.path) === assetName)) {
|
|
@@ -253,7 +265,8 @@ async function fetchSignedReleaseManifest(releaseUrl, version, assetName, fetchF
|
|
|
253
265
|
}
|
|
254
266
|
async function fetchText(url, fetchFn) {
|
|
255
267
|
const fetched = await fetchWith(url, fetchFn);
|
|
256
|
-
|
|
268
|
+
const bytes = await readBodyWithTimeout(url, 'text', () => readBytesWithLimit(fetched.response, MAX_RELEASE_METADATA_BYTES, fetched.abort), fetched.abort);
|
|
269
|
+
return Buffer.from(bytes).toString('utf8');
|
|
257
270
|
}
|
|
258
271
|
async function fetchBytes(url, fetchFn, maxBytes) {
|
|
259
272
|
const fetched = await fetchWith(url, fetchFn);
|
|
@@ -267,6 +280,12 @@ async function readBytesWithLimit(response, maxBytes, abort) {
|
|
|
267
280
|
}
|
|
268
281
|
return arr;
|
|
269
282
|
}
|
|
283
|
+
const declaredBytes = parseContentLength(response.headers.get('content-length'));
|
|
284
|
+
if (declaredBytes !== undefined && declaredBytes > maxBytes) {
|
|
285
|
+
abort();
|
|
286
|
+
await response.body.cancel().catch(() => { });
|
|
287
|
+
throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.DOWNLOAD_INCOMPLETE, `release_body_too_large:${declaredBytes} > ${maxBytes}`);
|
|
288
|
+
}
|
|
270
289
|
const reader = response.body.getReader();
|
|
271
290
|
const chunks = [];
|
|
272
291
|
let totalBytes = 0;
|
|
@@ -297,6 +316,15 @@ async function readBytesWithLimit(response, maxBytes, abort) {
|
|
|
297
316
|
}
|
|
298
317
|
return out.buffer;
|
|
299
318
|
}
|
|
319
|
+
function parseContentLength(raw) {
|
|
320
|
+
const trimmed = raw?.trim();
|
|
321
|
+
if (!trimmed || !/^\d+$/.test(trimmed))
|
|
322
|
+
return undefined;
|
|
323
|
+
const parsed = Number(trimmed);
|
|
324
|
+
if (!Number.isSafeInteger(parsed))
|
|
325
|
+
return Number.POSITIVE_INFINITY;
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
300
328
|
async function fetchWith(url, fetchFn) {
|
|
301
329
|
const fn = fetchFn ?? globalThis.fetch;
|
|
302
330
|
if (!fn)
|
|
@@ -472,6 +500,15 @@ function resolvedExtractedTarballLimit(requested) {
|
|
|
472
500
|
return MAX_EXTRACTED_TARBALL_BYTES;
|
|
473
501
|
return Math.min(Math.floor(requested), MAX_EXTRACTED_TARBALL_BYTES);
|
|
474
502
|
}
|
|
503
|
+
function resolvedPrimaryBinaryLimit(requested) {
|
|
504
|
+
if (requested === undefined)
|
|
505
|
+
return MAX_PRIMARY_BINARY_BYTES;
|
|
506
|
+
if (!Number.isFinite(requested) || requested <= 0)
|
|
507
|
+
return MAX_PRIMARY_BINARY_BYTES;
|
|
508
|
+
// Tests may lower the cap without providing a production escape hatch that
|
|
509
|
+
// could raise or disable the hard safety boundary.
|
|
510
|
+
return Math.min(Math.floor(requested), MAX_PRIMARY_BINARY_BYTES);
|
|
511
|
+
}
|
|
475
512
|
function readTarString(block, start, length) {
|
|
476
513
|
let end = start;
|
|
477
514
|
const max = start + length;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { DownloadResult } from './executor.js';
|
|
2
|
+
import { type ReleaseBinaryOptions } from './releaseBinary.js';
|
|
3
|
+
export type SelfUpdateJournalStage = 'preparing' | 'downloaded' | 'verified' | 'backed_up' | 'install_pending' | 'installed' | 'restarted' | 'health_check_pending' | 'rolling_back' | 'rollback_pending' | 'confirmed' | 'rolled_back' | 'rollback_failed';
|
|
4
|
+
export interface SelfUpdateJournal {
|
|
5
|
+
schema_version: 2;
|
|
6
|
+
transaction_id: string;
|
|
7
|
+
stage: SelfUpdateJournalStage;
|
|
8
|
+
from_version: string;
|
|
9
|
+
target_version: string;
|
|
10
|
+
platform: NodeJS.Platform;
|
|
11
|
+
arch: NodeJS.Architecture;
|
|
12
|
+
installing_pid: number;
|
|
13
|
+
created_at: string;
|
|
14
|
+
updated_at: string;
|
|
15
|
+
recovery_attempts: number;
|
|
16
|
+
/** Canonical logical install path: real parent directory plus the target leaf name. */
|
|
17
|
+
target_path: string;
|
|
18
|
+
/** Normalized operator-configured spelling used only when its parent can no longer be resolved. */
|
|
19
|
+
configured_target_path?: string;
|
|
20
|
+
staged_name?: string;
|
|
21
|
+
backup_name?: string;
|
|
22
|
+
failure_code?: string;
|
|
23
|
+
verified_sha256?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface DurableSelfUpdateSession {
|
|
26
|
+
adoptDownloaded(download: DownloadResult): Promise<DownloadResult>;
|
|
27
|
+
markVerified(artifacts: readonly {
|
|
28
|
+
bytes?: Uint8Array;
|
|
29
|
+
sha256?: string;
|
|
30
|
+
}[]): Promise<void>;
|
|
31
|
+
install(): Promise<void>;
|
|
32
|
+
markRestartRequested(): Promise<void>;
|
|
33
|
+
abort(failureCode: string): Promise<void>;
|
|
34
|
+
rollback(failureCode: string): Promise<void>;
|
|
35
|
+
release(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export interface SelfUpdateRecoveryResult {
|
|
38
|
+
outcome: 'none' | 'pending_health' | 'rollback_pending' | 'confirmed' | 'rolled_back' | 'blocked';
|
|
39
|
+
stage?: SelfUpdateJournalStage;
|
|
40
|
+
targetVersion?: string;
|
|
41
|
+
fromVersion?: string;
|
|
42
|
+
restartRequired?: boolean;
|
|
43
|
+
failureCode?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface StagedBinaryProbeOptions {
|
|
46
|
+
cwd: string;
|
|
47
|
+
env: NodeJS.ProcessEnv;
|
|
48
|
+
timeout: number;
|
|
49
|
+
windowsHide: boolean;
|
|
50
|
+
maxBuffer: number;
|
|
51
|
+
}
|
|
52
|
+
export type StagedBinaryProbe = (targetPath: string, args: readonly string[], options: StagedBinaryProbeOptions) => Promise<{
|
|
53
|
+
stdout: string;
|
|
54
|
+
}>;
|
|
55
|
+
export interface DurableSelfUpdateOptions extends ReleaseBinaryOptions {
|
|
56
|
+
stateDir?: string;
|
|
57
|
+
currentVersion: string;
|
|
58
|
+
platform?: NodeJS.Platform;
|
|
59
|
+
arch?: NodeJS.Architecture;
|
|
60
|
+
pid?: number;
|
|
61
|
+
now?: () => Date;
|
|
62
|
+
readBackVersion?: (targetPath: string) => Promise<string>;
|
|
63
|
+
stagedBinaryProbe?: StagedBinaryProbe;
|
|
64
|
+
/** Test hook invoked after a stale lock generation is observed and before its successor is published. */
|
|
65
|
+
beforeStaleLockReclaim?: () => void | Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
export type SelfUpdateRecoveryOptions = Omit<DurableSelfUpdateOptions, 'currentVersion'> & {
|
|
68
|
+
currentVersion?: string;
|
|
69
|
+
/** Runs after a durable journal is loaded and before recovery changes the journal, target, or managed artifacts. */
|
|
70
|
+
beforeJournalMutation?: () => void | Promise<void>;
|
|
71
|
+
};
|
|
72
|
+
export interface StableUnixRecoveryControllerOptions extends SelfUpdateRecoveryOptions {
|
|
73
|
+
platform?: NodeJS.Platform;
|
|
74
|
+
}
|
|
75
|
+
export interface StableWindowsRecoveryControllerOptions extends SelfUpdateRecoveryOptions {
|
|
76
|
+
platform?: NodeJS.Platform;
|
|
77
|
+
}
|
|
78
|
+
export declare function inspectDurableSelfUpdate(options: SelfUpdateRecoveryOptions): Promise<SelfUpdateRecoveryResult>;
|
|
79
|
+
export declare function resolveStableUnixRecoveryControllerPath(options: StableUnixRecoveryControllerOptions): Promise<string>;
|
|
80
|
+
export declare function stableUnixRecoveryControllerPathForTarget(targetPath: string, stateDir?: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* Installs an executable copy outside the mutable target path. The transaction
|
|
83
|
+
* lock and the existing no-follow file primitives keep service installation
|
|
84
|
+
* from racing an update or copying through a symlink.
|
|
85
|
+
*/
|
|
86
|
+
export declare function provisionStableUnixRecoveryController(options: StableUnixRecoveryControllerOptions): Promise<string>;
|
|
87
|
+
export declare function bindStableUnixRecoveryController(options: StableUnixRecoveryControllerOptions, processExecPath: string): Promise<{
|
|
88
|
+
controllerPath: string;
|
|
89
|
+
targetPath: string;
|
|
90
|
+
}>;
|
|
91
|
+
export declare function bindStableWindowsRecoveryController(options: StableWindowsRecoveryControllerOptions, processExecPath: string): Promise<{
|
|
92
|
+
controllerPath: string;
|
|
93
|
+
stateDir: string;
|
|
94
|
+
targetPath: string;
|
|
95
|
+
}>;
|
|
96
|
+
export declare function stableWindowsRecoveryControllerPathForStateDir(stateDir: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Provision or refresh the long-lived controller while it is not running.
|
|
99
|
+
* Service installation stops the Scheduled Task before calling this command;
|
|
100
|
+
* each self-update only replaces the separate windows-updater worker path.
|
|
101
|
+
*/
|
|
102
|
+
export declare function provisionStableWindowsRecoveryController(options: StableWindowsRecoveryControllerOptions, processExecPath: string): Promise<string>;
|
|
103
|
+
export declare function beginDurableSelfUpdate(targetVersion: string, options: DurableSelfUpdateOptions): Promise<DurableSelfUpdateSession>;
|
|
104
|
+
export declare function recoverDurableSelfUpdate(options: SelfUpdateRecoveryOptions): Promise<SelfUpdateRecoveryResult>;
|
|
105
|
+
export declare function markWindowsInstallApplied(options: SelfUpdateRecoveryOptions): Promise<SelfUpdateRecoveryResult>;
|
|
106
|
+
export declare function confirmDurableSelfUpdate(options: SelfUpdateRecoveryOptions): Promise<SelfUpdateRecoveryResult>;
|
|
107
|
+
export declare function rollbackDurableSelfUpdate(options: SelfUpdateRecoveryOptions, failureCode: string): Promise<SelfUpdateRecoveryResult>;
|
|
108
|
+
export declare function normalizeCanonicalSelfUpdateTargetPath(targetPath: string, platform?: NodeJS.Platform): string;
|
|
109
|
+
export declare function preflightManagedStagedBinary(targetPath: string, expectedVersion: string, probe?: StagedBinaryProbe): Promise<void>;
|