@evomap/evolver-proxy 2.0.0-beta.9 → 2.0.0
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-proxy.d.ts +26 -2
- package/dist/bin/evolver-proxy.js +284 -51
- package/dist/daemon/atpConsent.js +5 -2
- package/dist/daemon/collaborationFacade.js +23 -13
- package/dist/daemon/proxyDaemon.d.ts +52 -0
- package/dist/daemon/proxyDaemon.js +1067 -24
- package/dist/daemon/publishRecallVerifier.d.ts +114 -0
- package/dist/daemon/publishRecallVerifier.js +495 -0
- package/dist/daemon/selectHub.js +5 -3
- package/dist/daemon/systemdNotifier.d.ts +46 -0
- package/dist/daemon/systemdNotifier.js +153 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/lifecycle/claimNudge.d.ts +20 -0
- package/dist/lifecycle/claimNudge.js +124 -0
- package/dist/lifecycle/manager.d.ts +4 -0
- package/dist/lifecycle/manager.js +15 -2
- package/dist/llm/server.js +24 -4
- package/dist/llm/upstream.d.ts +5 -1
- package/dist/llm/upstream.js +24 -1
- package/dist/private/accountAssetCompatibility.d.ts +29 -0
- package/dist/private/accountAssetCompatibility.js +196 -0
- package/dist/private/adapterLoader.d.ts +21 -1
- package/dist/private/adapterLoader.js +242 -7
- package/dist/private/nodeCredentialStore.d.ts +23 -0
- package/dist/private/nodeCredentialStore.js +210 -0
- package/dist/selfUpdate/bootstrap.d.ts +69 -0
- package/dist/selfUpdate/bootstrap.js +282 -0
- package/dist/selfUpdate/builtinKey.d.ts +4 -0
- package/dist/selfUpdate/builtinKey.js +16 -0
- package/dist/selfUpdate/executor.d.ts +1 -1
- package/dist/selfUpdate/executor.js +1 -1
- package/dist/selfUpdate/failureCodes.d.ts +4 -0
- package/dist/selfUpdate/failureCodes.js +7 -0
- package/dist/selfUpdate/migration.d.ts +93 -0
- package/dist/selfUpdate/migration.js +315 -0
- package/dist/selfUpdate/policy.d.ts +19 -2
- package/dist/selfUpdate/policy.js +82 -2
- package/dist/selfUpdate/releaseBinary.js +4 -1
- package/dist/sync/engine.d.ts +12 -0
- package/dist/sync/engine.js +255 -64
- package/package.json +7 -4
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
const DIRECTORY_MODE = 0o700;
|
|
6
|
+
const FILE_MODE = 0o600;
|
|
7
|
+
const NODE_SECRET_RE = /^[a-f0-9]{64}$/i;
|
|
8
|
+
const WINDOWS_PROTECTED_VALUE_RE = /^[a-z0-9+/]+={0,2}$/i;
|
|
9
|
+
const MAX_PROTECTED_VALUE_LENGTH = 16_384;
|
|
10
|
+
export class PrivateNodeCredentialReadError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('stored private node credential is unreadable');
|
|
13
|
+
this.name = 'PrivateNodeCredentialReadError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class PrivateNodeCredentialStore {
|
|
17
|
+
directory;
|
|
18
|
+
path;
|
|
19
|
+
platform;
|
|
20
|
+
windowsProtector;
|
|
21
|
+
constructor(proxyStorePath, options = {}) {
|
|
22
|
+
this.platform = options.platform ?? process.platform;
|
|
23
|
+
this.windowsProtector = this.platform === 'win32'
|
|
24
|
+
? options.windowsProtector ?? createWindowsDpapiProtector()
|
|
25
|
+
: undefined;
|
|
26
|
+
if (this.windowsProtector)
|
|
27
|
+
verifyWindowsProtector(this.windowsProtector);
|
|
28
|
+
this.directory = join(dirname(resolve(proxyStorePath)), '.private-credentials');
|
|
29
|
+
this.path = join(this.directory, this.platform === 'win32' ? 'node-secret.dpapi' : 'node-secret');
|
|
30
|
+
this.prepareDirectory();
|
|
31
|
+
}
|
|
32
|
+
read() {
|
|
33
|
+
if (!existsSync(this.path))
|
|
34
|
+
return undefined;
|
|
35
|
+
this.assertRegularFile(this.path);
|
|
36
|
+
if (this.platform !== 'win32')
|
|
37
|
+
chmodSync(this.path, FILE_MODE);
|
|
38
|
+
const storedValue = readFileSync(this.path, 'utf8').trim();
|
|
39
|
+
try {
|
|
40
|
+
const value = this.windowsProtector
|
|
41
|
+
? this.windowsProtector.unprotect(assertWindowsProtectedValue(storedValue))
|
|
42
|
+
: storedValue;
|
|
43
|
+
if (!NODE_SECRET_RE.test(value))
|
|
44
|
+
throw new PrivateNodeCredentialReadError();
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new PrivateNodeCredentialReadError();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
write(nodeSecret) {
|
|
52
|
+
if (!NODE_SECRET_RE.test(nodeSecret))
|
|
53
|
+
throw new Error('private node credential is invalid');
|
|
54
|
+
const storedValue = this.windowsProtector
|
|
55
|
+
? assertWindowsProtectedValue(this.windowsProtector.protect(nodeSecret))
|
|
56
|
+
: nodeSecret;
|
|
57
|
+
this.prepareDirectory();
|
|
58
|
+
if (existsSync(this.path))
|
|
59
|
+
this.assertRegularFile(this.path);
|
|
60
|
+
const temporaryPath = join(this.directory, `.node-secret.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
61
|
+
let descriptor;
|
|
62
|
+
try {
|
|
63
|
+
descriptor = openSync(temporaryPath, 'wx', FILE_MODE);
|
|
64
|
+
writeFileSync(descriptor, storedValue, 'utf8');
|
|
65
|
+
fsyncSync(descriptor);
|
|
66
|
+
closeSync(descriptor);
|
|
67
|
+
descriptor = undefined;
|
|
68
|
+
renameSync(temporaryPath, this.path);
|
|
69
|
+
if (this.platform !== 'win32')
|
|
70
|
+
chmodSync(this.path, FILE_MODE);
|
|
71
|
+
if (this.platform !== 'win32')
|
|
72
|
+
syncDirectory(this.directory);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (descriptor !== undefined)
|
|
76
|
+
closeSync(descriptor);
|
|
77
|
+
if (existsSync(temporaryPath))
|
|
78
|
+
unlinkSync(temporaryPath);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
prepareDirectory() {
|
|
83
|
+
const parent = dirname(this.directory);
|
|
84
|
+
const parentStat = lstatSync(parent);
|
|
85
|
+
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
|
|
86
|
+
throw new Error(`private credential parent must be a real directory: ${parent}`);
|
|
87
|
+
}
|
|
88
|
+
if (this.platform !== 'win32' && (parentStat.mode & 0o022) !== 0) {
|
|
89
|
+
throw new Error(`private credential parent must not be group/world-writable: ${parent}`);
|
|
90
|
+
}
|
|
91
|
+
const directoryExisted = existsSync(this.directory);
|
|
92
|
+
mkdirSync(this.directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
93
|
+
const stat = lstatSync(this.directory);
|
|
94
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
95
|
+
throw new Error(`private credential path must be a real directory: ${this.directory}`);
|
|
96
|
+
}
|
|
97
|
+
if (this.platform !== 'win32')
|
|
98
|
+
chmodSync(this.directory, DIRECTORY_MODE);
|
|
99
|
+
if (!directoryExisted && this.platform !== 'win32')
|
|
100
|
+
syncDirectory(parent);
|
|
101
|
+
}
|
|
102
|
+
assertRegularFile(path) {
|
|
103
|
+
const stat = lstatSync(path);
|
|
104
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
105
|
+
throw new Error(`private credential must be a regular file: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function assertWindowsProtectedValue(value) {
|
|
110
|
+
const trimmed = value.trim();
|
|
111
|
+
if (!trimmed || trimmed.length > MAX_PROTECTED_VALUE_LENGTH || !WINDOWS_PROTECTED_VALUE_RE.test(trimmed)) {
|
|
112
|
+
throw new Error('stored private node credential ciphertext is invalid');
|
|
113
|
+
}
|
|
114
|
+
return trimmed;
|
|
115
|
+
}
|
|
116
|
+
function createWindowsDpapiProtector() {
|
|
117
|
+
const systemRoot = process.env['SystemRoot']?.trim();
|
|
118
|
+
if (!systemRoot || !isAbsolute(systemRoot)) {
|
|
119
|
+
throw new Error('Windows private credential persistence requires an absolute SystemRoot');
|
|
120
|
+
}
|
|
121
|
+
const executable = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
122
|
+
let executableIsSafe = false;
|
|
123
|
+
try {
|
|
124
|
+
const executableStat = lstatSync(executable);
|
|
125
|
+
executableIsSafe = executableStat.isFile() && !executableStat.isSymbolicLink();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Normalize filesystem errors so local paths are not included in startup logs.
|
|
129
|
+
}
|
|
130
|
+
if (!executableIsSafe) {
|
|
131
|
+
throw new Error('Windows private credential persistence requires Windows PowerShell');
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
preflight: () => {
|
|
135
|
+
const canary = randomBytes(32).toString('hex');
|
|
136
|
+
if (runWindowsPowerShell(executable, WINDOWS_DPAPI_PREFLIGHT_SCRIPT, canary) !== canary) {
|
|
137
|
+
throw new Error('Windows private credential protection preflight failed');
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
protect: (secret) => runWindowsPowerShell(executable, WINDOWS_DPAPI_PROTECT_SCRIPT, secret),
|
|
141
|
+
unprotect: (protectedValue) => runWindowsPowerShell(executable, WINDOWS_DPAPI_UNPROTECT_SCRIPT, protectedValue),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function verifyWindowsProtector(protector) {
|
|
145
|
+
try {
|
|
146
|
+
if (protector.preflight) {
|
|
147
|
+
protector.preflight();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const canary = randomBytes(32).toString('hex');
|
|
151
|
+
const protectedCanary = assertWindowsProtectedValue(protector.protect(canary));
|
|
152
|
+
if (protector.unprotect(protectedCanary) !== canary) {
|
|
153
|
+
throw new Error('round trip mismatch');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new Error('Windows private credential protection preflight failed');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function runWindowsPowerShell(executable, script, input) {
|
|
161
|
+
try {
|
|
162
|
+
return execFileSync(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
input,
|
|
165
|
+
maxBuffer: 64 * 1024,
|
|
166
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
167
|
+
timeout: 15_000,
|
|
168
|
+
windowsHide: true,
|
|
169
|
+
}).trim();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new Error('Windows private credential protection failed');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const WINDOWS_DPAPI_PREFLIGHT_SCRIPT = [
|
|
176
|
+
"$ErrorActionPreference = 'Stop'",
|
|
177
|
+
'Add-Type -AssemblyName System.Security',
|
|
178
|
+
'$plain = [Console]::In.ReadToEnd()',
|
|
179
|
+
'$bytes = [Text.Encoding]::UTF8.GetBytes($plain)',
|
|
180
|
+
'$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
|
|
181
|
+
'$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, $scope)',
|
|
182
|
+
'$restored = [Security.Cryptography.ProtectedData]::Unprotect($protected, $null, $scope)',
|
|
183
|
+
'[Console]::Out.Write([Text.Encoding]::UTF8.GetString($restored))',
|
|
184
|
+
].join('; ');
|
|
185
|
+
const WINDOWS_DPAPI_PROTECT_SCRIPT = [
|
|
186
|
+
"$ErrorActionPreference = 'Stop'",
|
|
187
|
+
'Add-Type -AssemblyName System.Security',
|
|
188
|
+
'$plain = [Console]::In.ReadToEnd()',
|
|
189
|
+
'$bytes = [Text.Encoding]::UTF8.GetBytes($plain)',
|
|
190
|
+
'$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
|
|
191
|
+
'$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, $scope)',
|
|
192
|
+
'[Console]::Out.Write([Convert]::ToBase64String($protected))',
|
|
193
|
+
].join('; ');
|
|
194
|
+
const WINDOWS_DPAPI_UNPROTECT_SCRIPT = [
|
|
195
|
+
"$ErrorActionPreference = 'Stop'",
|
|
196
|
+
'Add-Type -AssemblyName System.Security',
|
|
197
|
+
'$protected = [Convert]::FromBase64String([Console]::In.ReadToEnd())',
|
|
198
|
+
'$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
|
|
199
|
+
'$bytes = [Security.Cryptography.ProtectedData]::Unprotect($protected, $null, $scope)',
|
|
200
|
+
'[Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))',
|
|
201
|
+
].join('; ');
|
|
202
|
+
function syncDirectory(path) {
|
|
203
|
+
const descriptor = openSync(path, 'r');
|
|
204
|
+
try {
|
|
205
|
+
fsyncSync(descriptor);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
closeSync(descriptor);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { type MigrationOptions } from './migration.js';
|
|
3
|
+
export declare const BOOTSTRAP_SUCCESS_FILE = "bootstrap.json";
|
|
4
|
+
export type BootstrapSkipReason = 'already_supervised' | 'already_bootstrapped' | 'unsupported_install_shape' | 'policy_not_auto' | 'bootstrap_disabled' | 'ci_environment' | 'container_environment' | 'recent_failure';
|
|
5
|
+
export interface BootstrapDecision {
|
|
6
|
+
proceed: boolean;
|
|
7
|
+
reason?: BootstrapSkipReason;
|
|
8
|
+
}
|
|
9
|
+
export interface BootstrapOutcome {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* 'bootstrapped' / 'migrated' on success; failure/skip reason otherwise. Migration
|
|
13
|
+
* failures record 'migration_failed' / 'migration_timeout' (both cooldown-worthy).
|
|
14
|
+
*/
|
|
15
|
+
reason: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface BootstrapRunOptions {
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
21
|
+
execPath?: string;
|
|
22
|
+
argv1?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
now?: number;
|
|
25
|
+
exists?: (path: string) => boolean;
|
|
26
|
+
readFile?: (path: string) => string;
|
|
27
|
+
writeFile?: (path: string, content: string) => void;
|
|
28
|
+
spawnFn?: typeof spawn;
|
|
29
|
+
/**
|
|
30
|
+
* Extra seams forwarded to the one-time npm/JS → standalone migration (migration.ts).
|
|
31
|
+
* Bootstrap-level seams (exists/readFile/writeFile/spawnFn/now/execPath) win when both
|
|
32
|
+
* are supplied, so the decision and the migration observe the same injected world.
|
|
33
|
+
*/
|
|
34
|
+
migration?: MigrationOptions;
|
|
35
|
+
}
|
|
36
|
+
/** Lifecycle state dir mirror of evolver-cli lifecyclePaths (kept dependency-free across packages). */
|
|
37
|
+
export declare function resolveBootstrapStateDir(env: NodeJS.ProcessEnv): string;
|
|
38
|
+
export declare function looksLikeContainer(exists: (path: string) => boolean, readFile: (path: string) => string): boolean;
|
|
39
|
+
/** True when a recent bootstrap/migration attempt failed within the cooldown window. */
|
|
40
|
+
export declare function recentBootstrapFailure(env: NodeJS.ProcessEnv, readFile: (path: string) => string, now: number): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether an unsupervised (degraded) startup should attempt first-run bootstrap.
|
|
43
|
+
* Pure — filesystem access is injectable for tests.
|
|
44
|
+
*/
|
|
45
|
+
export declare function shouldBootstrap(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform, options?: Pick<BootstrapRunOptions, 'exists' | 'readFile' | 'now' | 'execPath'>): BootstrapDecision;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the `lifecycle bootstrap` invocation for the current install shape: standalone binary,
|
|
48
|
+
* CLI entry through node, or the npm-installed @evomap/evolver-cli sibling. Returns undefined when
|
|
49
|
+
* no CLI can be located (degrade to the existing warning instead of failing startup).
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveBootstrapCliInvocation(options?: Pick<BootstrapRunOptions, 'execPath' | 'argv1' | 'exists'>): {
|
|
52
|
+
command: string;
|
|
53
|
+
args: string[];
|
|
54
|
+
} | undefined;
|
|
55
|
+
/** Best-effort attempt marker; never throws — bootstrap bookkeeping must not break startup. */
|
|
56
|
+
export declare function recordBootstrapAttempt(env: NodeJS.ProcessEnv, outcome: BootstrapOutcome, options?: Pick<BootstrapRunOptions, 'writeFile' | 'now'>): void;
|
|
57
|
+
/** Spawn `evolver lifecycle bootstrap` and await its result within a bounded timeout. */
|
|
58
|
+
export declare function runBootstrap(options: BootstrapRunOptions): Promise<BootstrapOutcome>;
|
|
59
|
+
export interface DegradedStartupBootstrapResult {
|
|
60
|
+
/** True when bootstrap succeeded and startup should exit so the new service takes over. */
|
|
61
|
+
handedOver: boolean;
|
|
62
|
+
/** Operator-facing single-line message (stdout when handed over, stderr otherwise). */
|
|
63
|
+
message: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Orchestrate bootstrap for a degraded (default-auto, unsupervised) startup: decide, attempt,
|
|
67
|
+
* record, and produce the operator message. Never throws.
|
|
68
|
+
*/
|
|
69
|
+
export declare function bootstrapDegradedSelfUpdateStartup(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform, options?: Omit<BootstrapRunOptions, 'env' | 'platform'>): Promise<DegradedStartupBootstrapResult>;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// First-run supervision bootstrap for the DEFAULT self-update policy.
|
|
2
|
+
//
|
|
3
|
+
// Default auto self-update degrades to 'off' without a durable supervisor attestation (policy.ts).
|
|
4
|
+
// To make `npm install` a complete zero-config path, an unsupervised foreground startup may ONCE
|
|
5
|
+
// register its own user-level durable launcher (`evolver lifecycle bootstrap`) and hand over to it:
|
|
6
|
+
// the generated launcher carries the EVOLVER_SELF_UPDATE_SUPERVISOR attestation, so the next
|
|
7
|
+
// supervised startup runs auto self-update with the unchanged signature/health-check/rollback gates.
|
|
8
|
+
//
|
|
9
|
+
// Bootstrap is a convenience, never an escalation: it is skipped for attested runs, explicit
|
|
10
|
+
// non-auto policies, the EVOLVER_SELF_BOOTSTRAP kill switch, CI, containers, and within a
|
|
11
|
+
// cooldown window after a failed attempt. The npm/JS install shape has no bindable
|
|
12
|
+
// self-update target, so instead of launcher bootstrap it attempts a one-time migration to
|
|
13
|
+
// the standalone release binary (migration.ts). Any failure degrades back to the existing
|
|
14
|
+
// 'off + warning' startup.
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { basename, dirname, join, resolve as resolvePath } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { isSelfUpdateExplicit, resolveSelfUpdatePolicy, selfUpdateSupervisorAttested, } from './policy.js';
|
|
22
|
+
import { expandHomePath } from '../bin/envFile.js';
|
|
23
|
+
import { resolveSelfUpdateTarget } from './releaseBinary.js';
|
|
24
|
+
import { migrateToStandaloneBinary } from './migration.js';
|
|
25
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
26
|
+
export const BOOTSTRAP_SUCCESS_FILE = 'bootstrap.json';
|
|
27
|
+
const BOOTSTRAP_ATTEMPT_FILE = 'bootstrap-attempt.json';
|
|
28
|
+
const BOOTSTRAP_FAILURE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
// The CLI-side service activation itself spawns up to ~60s (lifecycle powershell/schtasks
|
|
30
|
+
// activation), so a shorter timeout would kill the child mid-activation and leave the
|
|
31
|
+
// half-installed state: the service registered but bootstrap judged failed.
|
|
32
|
+
const BOOTSTRAP_TIMEOUT_MS = 90_000;
|
|
33
|
+
const BOOTSTRAP_ENV_FILE_HANDOFF = 'EVOLVER_INTERNAL_BOOTSTRAP_ENV_FILE';
|
|
34
|
+
/** Lifecycle state dir mirror of evolver-cli lifecyclePaths (kept dependency-free across packages). */
|
|
35
|
+
export function resolveBootstrapStateDir(env) {
|
|
36
|
+
const explicit = env['EVOLVER_LIFECYCLE_STATE_DIR']?.trim();
|
|
37
|
+
const home = env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? join(homedir(), '.evomap');
|
|
38
|
+
return resolvePath(explicit || join(home, 'lifecycle'));
|
|
39
|
+
}
|
|
40
|
+
function bootstrapChildEnv(env) {
|
|
41
|
+
const childEnv = { ...env };
|
|
42
|
+
const envFile = env['EVOLVER_ENV_FILE']?.trim();
|
|
43
|
+
delete childEnv['EVOLVER_ENV_FILE'];
|
|
44
|
+
delete childEnv[BOOTSTRAP_ENV_FILE_HANDOFF];
|
|
45
|
+
if (envFile)
|
|
46
|
+
childEnv[BOOTSTRAP_ENV_FILE_HANDOFF] = resolvePath(expandHomePath(envFile));
|
|
47
|
+
// Resolve while the foreground proxy still owns cwd, then carry that identity through the
|
|
48
|
+
// bootstrap child and generated service launcher. Service managers do not share one cwd.
|
|
49
|
+
childEnv['EVOLVER_LIFECYCLE_STATE_DIR'] = resolveBootstrapStateDir(env);
|
|
50
|
+
return childEnv;
|
|
51
|
+
}
|
|
52
|
+
const defaultReadTextFile = (path) => readFileSync(path, 'utf8');
|
|
53
|
+
export function looksLikeContainer(exists, readFile) {
|
|
54
|
+
if (exists('/.dockerenv'))
|
|
55
|
+
return true;
|
|
56
|
+
try {
|
|
57
|
+
return /docker|containerd|kubepods|podman|lxc/.test(readFile('/proc/1/cgroup'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function readBootstrapAttempt(env, readFile) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(readFile(join(resolveBootstrapStateDir(env), BOOTSTRAP_ATTEMPT_FILE)));
|
|
66
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
67
|
+
return undefined;
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Cooldown-worthy attempt outcomes. Migration adds its own failure/timeout outcomes so a
|
|
75
|
+
// broken release (download/verify/install/register) does not get retried on every startup.
|
|
76
|
+
const BOOTSTRAP_FAILURE_OUTCOMES = new Set([
|
|
77
|
+
'failed', 'timeout', 'cli_not_found', 'migration_failed', 'migration_timeout',
|
|
78
|
+
]);
|
|
79
|
+
/** True when a recent bootstrap/migration attempt failed within the cooldown window. */
|
|
80
|
+
export function recentBootstrapFailure(env, readFile, now) {
|
|
81
|
+
const attempt = readBootstrapAttempt(env, readFile);
|
|
82
|
+
if (!attempt)
|
|
83
|
+
return false;
|
|
84
|
+
if (typeof attempt['outcome'] !== 'string' || !BOOTSTRAP_FAILURE_OUTCOMES.has(attempt['outcome']))
|
|
85
|
+
return false;
|
|
86
|
+
if (typeof attempt['attemptedAt'] !== 'string')
|
|
87
|
+
return false;
|
|
88
|
+
const attemptedAt = Date.parse(attempt['attemptedAt']);
|
|
89
|
+
if (Number.isNaN(attemptedAt))
|
|
90
|
+
return false;
|
|
91
|
+
return now - attemptedAt < BOOTSTRAP_FAILURE_COOLDOWN_MS;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decide whether an unsupervised (degraded) startup should attempt first-run bootstrap.
|
|
95
|
+
* Pure — filesystem access is injectable for tests.
|
|
96
|
+
*/
|
|
97
|
+
export function shouldBootstrap(env, platform = process.platform, options = {}) {
|
|
98
|
+
if (selfUpdateSupervisorAttested(env))
|
|
99
|
+
return { proceed: false, reason: 'already_supervised' };
|
|
100
|
+
const bootstrapSwitch = env['EVOLVER_SELF_BOOTSTRAP']?.trim();
|
|
101
|
+
if (bootstrapSwitch === '0' || bootstrapSwitch === 'off')
|
|
102
|
+
return { proceed: false, reason: 'bootstrap_disabled' };
|
|
103
|
+
if (isSelfUpdateExplicit(env) && resolveSelfUpdatePolicy(env) !== 'auto') {
|
|
104
|
+
return { proceed: false, reason: 'policy_not_auto' };
|
|
105
|
+
}
|
|
106
|
+
const exists = options.exists ?? existsSync;
|
|
107
|
+
if (exists(join(resolveBootstrapStateDir(env), BOOTSTRAP_SUCCESS_FILE))) {
|
|
108
|
+
return { proceed: false, reason: 'already_bootstrapped' };
|
|
109
|
+
}
|
|
110
|
+
// The npm/JS install shape has no replaceable standalone binary target, so the launcher
|
|
111
|
+
// bootstrap would register a supervised instance that crashes at self-update target
|
|
112
|
+
// resolution on every startup (crash-loop under the service manager). Skip it; an explicit
|
|
113
|
+
// EVOLVER_SELF_UPDATE_TARGET_PATH keeps the target bindable and bypasses this guard.
|
|
114
|
+
try {
|
|
115
|
+
resolveSelfUpdateTarget({ env, processExecPath: options.execPath });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { proceed: false, reason: 'unsupported_install_shape' };
|
|
119
|
+
}
|
|
120
|
+
const ci = env['CI']?.trim();
|
|
121
|
+
if (ci && ci.toLowerCase() !== 'false' && ci !== '0')
|
|
122
|
+
return { proceed: false, reason: 'ci_environment' };
|
|
123
|
+
if (platform === 'linux' && looksLikeContainer(exists, options.readFile ?? defaultReadTextFile)) {
|
|
124
|
+
return { proceed: false, reason: 'container_environment' };
|
|
125
|
+
}
|
|
126
|
+
if (recentBootstrapFailure(env, options.readFile ?? defaultReadTextFile, options.now ?? Date.now())) {
|
|
127
|
+
return { proceed: false, reason: 'recent_failure' };
|
|
128
|
+
}
|
|
129
|
+
return { proceed: true };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the `lifecycle bootstrap` invocation for the current install shape: standalone binary,
|
|
133
|
+
* CLI entry through node, or the npm-installed @evomap/evolver-cli sibling. Returns undefined when
|
|
134
|
+
* no CLI can be located (degrade to the existing warning instead of failing startup).
|
|
135
|
+
*/
|
|
136
|
+
export function resolveBootstrapCliInvocation(options = {}) {
|
|
137
|
+
const execPath = options.execPath ?? process.execPath;
|
|
138
|
+
const argv1 = options.argv1 ?? process.argv[1];
|
|
139
|
+
const exists = options.exists ?? existsSync;
|
|
140
|
+
const executableName = basename(execPath).toLowerCase();
|
|
141
|
+
if (/^evolver(?:\.exe|-(?:darwin-(?:arm64|x64)|linux-(?:arm64|x64)|windows-x64\.exe))?$/.test(executableName)) {
|
|
142
|
+
return { command: execPath, args: ['lifecycle', 'bootstrap'] };
|
|
143
|
+
}
|
|
144
|
+
if (argv1 && basename(argv1).toLowerCase() === 'cli.js') {
|
|
145
|
+
return { command: execPath, args: [argv1, 'lifecycle', 'bootstrap'] };
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const entry = requireFromHere.resolve('@evomap/evolver-cli');
|
|
149
|
+
const cliPath = join(dirname(entry), 'cli.js');
|
|
150
|
+
if (exists(cliPath))
|
|
151
|
+
return { command: execPath, args: [cliPath, 'lifecycle', 'bootstrap'] };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Not resolvable from the installed proxy package — fall through to the monorepo layout.
|
|
155
|
+
}
|
|
156
|
+
const local = fileURLToPath(new URL('../../../evolver-cli/dist/cli.js', import.meta.url));
|
|
157
|
+
if (exists(local))
|
|
158
|
+
return { command: execPath, args: [local, 'lifecycle', 'bootstrap'] };
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
/** Best-effort attempt marker; never throws — bootstrap bookkeeping must not break startup. */
|
|
162
|
+
export function recordBootstrapAttempt(env, outcome, options = {}) {
|
|
163
|
+
const path = join(resolveBootstrapStateDir(env), BOOTSTRAP_ATTEMPT_FILE);
|
|
164
|
+
const record = {
|
|
165
|
+
attemptedAt: new Date(options.now ?? Date.now()).toISOString(),
|
|
166
|
+
outcome: outcome.reason,
|
|
167
|
+
...(outcome.detail ? { detail: outcome.detail } : {}),
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
const writeFile = options.writeFile ?? ((target, content) => {
|
|
171
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
172
|
+
writeFileSync(target, content, { encoding: 'utf8', mode: 0o600 });
|
|
173
|
+
});
|
|
174
|
+
writeFile(path, `${JSON.stringify(record)}\n`);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Marker is advisory; startup continues regardless.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Spawn `evolver lifecycle bootstrap` and await its result within a bounded timeout. */
|
|
181
|
+
export async function runBootstrap(options) {
|
|
182
|
+
const invocation = resolveBootstrapCliInvocation(options);
|
|
183
|
+
if (!invocation)
|
|
184
|
+
return { ok: false, reason: 'cli_not_found' };
|
|
185
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
186
|
+
const timeoutMs = options.timeoutMs ?? BOOTSTRAP_TIMEOUT_MS;
|
|
187
|
+
return new Promise((resolvePromise) => {
|
|
188
|
+
let child;
|
|
189
|
+
try {
|
|
190
|
+
child = spawnFn(invocation.command, invocation.args, {
|
|
191
|
+
stdio: 'ignore',
|
|
192
|
+
windowsHide: true,
|
|
193
|
+
env: bootstrapChildEnv(options.env),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
resolvePromise({ ok: false, reason: 'failed', detail: error instanceof Error ? error.message : String(error) });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
let settled = false;
|
|
201
|
+
const settle = (outcome) => {
|
|
202
|
+
if (settled)
|
|
203
|
+
return;
|
|
204
|
+
settled = true;
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
resolvePromise(outcome);
|
|
207
|
+
};
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
child.kill();
|
|
210
|
+
settle({ ok: false, reason: 'timeout' });
|
|
211
|
+
}, timeoutMs);
|
|
212
|
+
child.once('error', (error) => {
|
|
213
|
+
settle({ ok: false, reason: 'failed', detail: error.message });
|
|
214
|
+
});
|
|
215
|
+
child.once('exit', (code) => {
|
|
216
|
+
if (code === 0)
|
|
217
|
+
settle({ ok: true, reason: 'bootstrapped' });
|
|
218
|
+
else
|
|
219
|
+
settle({ ok: false, reason: 'failed', detail: `exit ${code ?? 'null'}` });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Orchestrate bootstrap for a degraded (default-auto, unsupervised) startup: decide, attempt,
|
|
225
|
+
* record, and produce the operator message. Never throws.
|
|
226
|
+
*/
|
|
227
|
+
export async function bootstrapDegradedSelfUpdateStartup(env, platform = process.platform, options = {}) {
|
|
228
|
+
const bootstrapEnv = {
|
|
229
|
+
...env,
|
|
230
|
+
EVOLVER_LIFECYCLE_STATE_DIR: resolveBootstrapStateDir(env),
|
|
231
|
+
};
|
|
232
|
+
const decision = shouldBootstrap(bootstrapEnv, platform, options);
|
|
233
|
+
if (!decision.proceed) {
|
|
234
|
+
const reason = decision.reason ?? 'skipped';
|
|
235
|
+
recordBootstrapAttempt(bootstrapEnv, { ok: false, reason }, options);
|
|
236
|
+
if (reason === 'unsupported_install_shape') {
|
|
237
|
+
// Do not suggest `evolver lifecycle bootstrap` here: under the npm/JS install shape it
|
|
238
|
+
// would register a supervised service that crashes at self-update target resolution on
|
|
239
|
+
// every startup (crash-loop). Only a standalone release binary can host self-update —
|
|
240
|
+
// so attempt the one-time migration to it; any skip/failure keeps the degraded startup.
|
|
241
|
+
const migration = await migrateToStandaloneBinary(env, platform, {
|
|
242
|
+
...options.migration,
|
|
243
|
+
...(options.execPath !== undefined ? { execPath: options.execPath } : {}),
|
|
244
|
+
...(options.exists !== undefined ? { exists: options.exists } : {}),
|
|
245
|
+
...(options.readFile !== undefined ? { readFile: options.readFile } : {}),
|
|
246
|
+
...(options.writeFile !== undefined ? { writeFile: options.writeFile } : {}),
|
|
247
|
+
...(options.spawnFn !== undefined ? { spawnFn: options.spawnFn } : {}),
|
|
248
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
249
|
+
});
|
|
250
|
+
if (migration.outcome === 'migrated') {
|
|
251
|
+
return { handedOver: true, message: migration.message };
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
handedOver: false,
|
|
255
|
+
message: '[evolver-proxy] self-update: running from the npm/JS install shape, which has no standalone '
|
|
256
|
+
+ 'binary target for self-update; bootstrap skipped, continuing with self-update off. '
|
|
257
|
+
+ 'Install the standalone binary from GitHub Releases and start it to enable self-update. '
|
|
258
|
+
+ `(${migration.message})`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
handedOver: false,
|
|
263
|
+
message: '[evolver-proxy] self-update: default auto requires a durable supervisor attestation; '
|
|
264
|
+
+ `running with self-update off (bootstrap skipped: ${reason}). `
|
|
265
|
+
+ 'Run `evolver lifecycle bootstrap` or `evolver lifecycle install-service` to enable.',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const outcome = await runBootstrap({ env: bootstrapEnv, platform, ...options });
|
|
269
|
+
recordBootstrapAttempt(bootstrapEnv, outcome, options);
|
|
270
|
+
if (outcome.ok) {
|
|
271
|
+
return {
|
|
272
|
+
handedOver: true,
|
|
273
|
+
message: '[evolver-proxy] self-update: registered durable service supervision via `evolver lifecycle bootstrap`; '
|
|
274
|
+
+ 'handing over to the service manager and exiting so it can take the IPC port.',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
handedOver: false,
|
|
279
|
+
message: `[evolver-proxy] self-update: first-run bootstrap failed (${outcome.reason}); `
|
|
280
|
+
+ 'running with self-update off. Run `evolver lifecycle install-service` manually to enable.',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Ed25519 SPKI public key (base64 DER) matching the v2-beta release environment signing key. */
|
|
2
|
+
export declare const BUILTIN_SELF_UPDATE_PUBLIC_KEY = "MCowBQYDK2VwAyEAAgoV6aWwJd5zlxOcPqWuxkDB+isQnKydFStV8X3DxMk=";
|
|
3
|
+
/** Resolve the self-update verification public key: env override wins, else the built-in key. */
|
|
4
|
+
export declare function resolveSelfUpdatePublicKey(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Built-in Ed25519 verification public key for the official v2-beta self-update channel.
|
|
2
|
+
//
|
|
3
|
+
// A public key is not secret: embedding it lets nodes installed through a trusted
|
|
4
|
+
// distribution channel (npm tarball or prebuilt release binary) verify signed update
|
|
5
|
+
// manifests with zero per-node configuration. Trust bootstraps from the install
|
|
6
|
+
// channel itself (npm registry integrity / release artifact provenance), and every
|
|
7
|
+
// later self-update stays signature-gated.
|
|
8
|
+
//
|
|
9
|
+
// EVOLVER_SELF_UPDATE_PUBLIC_KEY overrides this when set (rotation / private fleets).
|
|
10
|
+
/** Ed25519 SPKI public key (base64 DER) matching the v2-beta release environment signing key. */
|
|
11
|
+
export const BUILTIN_SELF_UPDATE_PUBLIC_KEY = 'MCowBQYDK2VwAyEAAgoV6aWwJd5zlxOcPqWuxkDB+isQnKydFStV8X3DxMk=';
|
|
12
|
+
/** Resolve the self-update verification public key: env override wins, else the built-in key. */
|
|
13
|
+
export function resolveSelfUpdatePublicKey(env = process.env) {
|
|
14
|
+
const configured = env['EVOLVER_SELF_UPDATE_PUBLIC_KEY']?.trim();
|
|
15
|
+
return configured || BUILTIN_SELF_UPDATE_PUBLIC_KEY;
|
|
16
|
+
}
|
|
@@ -81,7 +81,7 @@ export declare function _resetSelfUpdateMutex(): void;
|
|
|
81
81
|
* Execute a force_update directive end to end: decide → (mutex) → download → VERIFY → atomic replace → restart.
|
|
82
82
|
*
|
|
83
83
|
* Order is load-bearing:
|
|
84
|
-
* 1. policy off → do nothing (
|
|
84
|
+
* 1. policy off → do nothing (explicit opt-out; auto is hard-gated upstream by supervisor + public key).
|
|
85
85
|
* 2. decideUpdate (pure): reject bad manifests, NOOP when already satisfied (no download, no restart).
|
|
86
86
|
* 3. mutex: exactly one execution; concurrent callers get `already_in_progress` and touch no disk.
|
|
87
87
|
* 4. download the staged release.
|
|
@@ -35,7 +35,7 @@ function report(deps, result) {
|
|
|
35
35
|
* Execute a force_update directive end to end: decide → (mutex) → download → VERIFY → atomic replace → restart.
|
|
36
36
|
*
|
|
37
37
|
* Order is load-bearing:
|
|
38
|
-
* 1. policy off → do nothing (
|
|
38
|
+
* 1. policy off → do nothing (explicit opt-out; auto is hard-gated upstream by supervisor + public key).
|
|
39
39
|
* 2. decideUpdate (pure): reject bad manifests, NOOP when already satisfied (no download, no restart).
|
|
40
40
|
* 3. mutex: exactly one execution; concurrent callers get `already_in_progress` and touch no disk.
|
|
41
41
|
* 4. download the staged release.
|
|
@@ -23,6 +23,10 @@ export declare const SELF_UPDATE_FAILURE_CODES: Readonly<{
|
|
|
23
23
|
readonly RESTART_FAILED: "restart_failed";
|
|
24
24
|
readonly READ_BACK_FAILED: "read_back_failed";
|
|
25
25
|
readonly ROLLBACK_FAILED: "rollback_failed";
|
|
26
|
+
readonly MIGRATION_DOWNLOAD_FAILED: "migration_download_failed";
|
|
27
|
+
readonly MIGRATION_VERIFY_FAILED: "migration_verify_failed";
|
|
28
|
+
readonly MIGRATION_INSTALL_FAILED: "migration_install_failed";
|
|
29
|
+
readonly MIGRATION_REGISTER_FAILED: "migration_register_failed";
|
|
26
30
|
}>;
|
|
27
31
|
export type SelfUpdateFailureCode = typeof SELF_UPDATE_FAILURE_CODES[keyof typeof SELF_UPDATE_FAILURE_CODES];
|
|
28
32
|
export interface ClassifiedSelfUpdateError {
|
|
@@ -28,6 +28,13 @@ export const SELF_UPDATE_FAILURE_CODES = Object.freeze({
|
|
|
28
28
|
RESTART_FAILED: 'restart_failed',
|
|
29
29
|
READ_BACK_FAILED: 'read_back_failed',
|
|
30
30
|
ROLLBACK_FAILED: 'rollback_failed',
|
|
31
|
+
// One-time npm/JS → standalone binary migration (migration.ts). Append-only:
|
|
32
|
+
// these surface in attempt-marker detail strings and operator messages so
|
|
33
|
+
// telemetry can attribute first-run migration failures by phase.
|
|
34
|
+
MIGRATION_DOWNLOAD_FAILED: 'migration_download_failed',
|
|
35
|
+
MIGRATION_VERIFY_FAILED: 'migration_verify_failed',
|
|
36
|
+
MIGRATION_INSTALL_FAILED: 'migration_install_failed',
|
|
37
|
+
MIGRATION_REGISTER_FAILED: 'migration_register_failed',
|
|
31
38
|
});
|
|
32
39
|
export class SelfUpdateFailureError extends Error {
|
|
33
40
|
failureCode;
|