@hellolynk-ai/codex-plugin 0.2.102 → 0.2.103
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/bin/hellolynk-client-setup.mjs +70 -10
- package/hellolynk-client-pack-manifest.json +76 -56
- package/hellolynk-client-pack-pin.json +1 -1
- package/package.json +1 -1
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/hellolynk-codex/package.json +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-host.mjs +269 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-vault.mjs +153 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +20 -8
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +68 -14
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +5 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +7 -3
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +62 -33
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +62 -18
- package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-host.mjs +269 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-vault.mjs +153 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +20 -8
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +53 -6
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +22 -7
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +9 -3
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +68 -14
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +101 -2
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +5 -3
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +40 -24
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +46 -10
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +14 -8
- package/tools/hellolynk-activate-neutral-host-clients.mjs +5 -1
|
@@ -68,7 +68,7 @@ export function withClientAccountLock(client, root, operation) {
|
|
|
68
68
|
|
|
69
69
|
export function createClientAccountActions(options) {
|
|
70
70
|
const client = options?.client;
|
|
71
|
-
const root = options?.storeRoot || defaultNeutralHostStoreRoot(
|
|
71
|
+
const root = options?.storeRoot || defaultNeutralHostStoreRoot(process.platform, options?.env || process.env);
|
|
72
72
|
statePath(client, clientAccountBaseRoot(root));
|
|
73
73
|
const readAccountState = () => {
|
|
74
74
|
const state = readClientAccount(client, root);
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { clientAccountBaseRoot } from "./hellolynk-client-account.mjs";
|
|
4
4
|
import { startClientUpdateRuntime } from "./hellolynk-client-update-runtime.mjs";
|
|
5
|
+
import { clientUpdateCatalogId, MAC_CLIENT_VERSION } from "./hellolynk-client-update.mjs";
|
|
5
6
|
|
|
6
7
|
const running = new Map();
|
|
7
8
|
|
|
@@ -9,7 +10,9 @@ const running = new Map();
|
|
|
9
10
|
// to the Host. A checkout or an uninstalled test fixture never self-updates.
|
|
10
11
|
export function startInstalledClientUpdate(options = {}) {
|
|
11
12
|
const { client, packageRoot, storeRoot } = options;
|
|
12
|
-
|
|
13
|
+
const catalogId = clientUpdateCatalogId(client, process.platform);
|
|
14
|
+
const mac = process.platform === "darwin";
|
|
15
|
+
if (!catalogId || (mac && process.arch !== "arm64") || !path.isAbsolute(String(packageRoot || ""))) return Promise.resolve(null);
|
|
13
16
|
let baseRoot;
|
|
14
17
|
try { baseRoot = clientAccountBaseRoot(storeRoot); } catch { return Promise.resolve(null); }
|
|
15
18
|
const key = `${client}\0${path.resolve(packageRoot)}\0${baseRoot}`;
|
|
@@ -21,8 +24,9 @@ export function startInstalledClientUpdate(options = {}) {
|
|
|
21
24
|
const metadata = await fs.lstat(packageFile);
|
|
22
25
|
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || metadata.size > 64 * 1024) return null;
|
|
23
26
|
const manifest = JSON.parse(await fs.readFile(packageFile, "utf8"));
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const version = mac ? MAC_CLIENT_VERSION : /^\d+\.\d+\.\d+$/u;
|
|
28
|
+
if (manifest.hellolynkClient?.client !== client || !version.test(manifest.version || "")
|
|
29
|
+
|| (mac && (manifest.os?.[0] !== "darwin" || manifest.cpu?.[0] !== "arm64"))) return null;
|
|
26
30
|
return startClientUpdateRuntime({
|
|
27
31
|
root: baseRoot, client, currentVersion: manifest.version,
|
|
28
32
|
installContext: { ...options.installContext, packageRoot: path.resolve(packageRoot) },
|
|
@@ -4,7 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { createClientUpdateManager, extractClientUpdateArchive, compareClientUpdateVersions,
|
|
7
|
+
import { createClientUpdateManager, extractClientUpdateArchive, compareClientUpdateVersions, clientUpdateCatalogId,
|
|
8
|
+
CLIENT_UPDATE_CLIENTS, CLIENT_UPDATE_RELEASES, MAC_CLIENT_VERSION } from "./hellolynk-client-update.mjs";
|
|
8
9
|
import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-store.mjs";
|
|
9
10
|
import { readClientAccount } from "./hellolynk-client-account.mjs";
|
|
10
11
|
import runtimeSafety from "./hellolynk-runtime-safety.cjs";
|
|
@@ -26,7 +27,7 @@ const RETRY_AFTER_MS = 10 * 60_000;
|
|
|
26
27
|
// waits only for DSH's lease, and a downloaded update is applied by a worker in
|
|
27
28
|
// the background. Checking the network is always detached.
|
|
28
29
|
export async function startClientUpdateRuntime(options = {}) {
|
|
29
|
-
if ((options.platform || process.platform)
|
|
30
|
+
if (!clientUpdateCatalogId(options.client, options.platform || process.platform)) return disabled();
|
|
30
31
|
let config;
|
|
31
32
|
try { config = normalize(options); await verifyInstalledContext(config); }
|
|
32
33
|
catch { return disabled(); }
|
|
@@ -94,8 +95,8 @@ export async function runClientUpdateWorker(options = {}) {
|
|
|
94
95
|
function createRuntime(config, options) {
|
|
95
96
|
const { root, client, currentVersion, installContext } = config;
|
|
96
97
|
const directory = path.join(root, "client-updates", client);
|
|
97
|
-
const statePath = path.join(directory, "runtime.json"), leasePath = path.join(directory, "leases.json"), installedPath = path.join(directory, "installed.json");
|
|
98
|
-
const manager = createClientUpdateManager({ ...options, root, client, currentVersion });
|
|
98
|
+
const statePath = path.join(directory, "runtime-v2.json"), leasePath = path.join(directory, "leases.json"), installedPath = path.join(directory, "installed.json");
|
|
99
|
+
const manager = createClientUpdateManager({ ...options, root, client, currentVersion, platform: config.platform });
|
|
99
100
|
const alive = options.isProcessAlive || processAlive;
|
|
100
101
|
async function gate(operation) {
|
|
101
102
|
await safeDirectory(directory);
|
|
@@ -200,7 +201,8 @@ function createRuntime(config, options) {
|
|
|
200
201
|
const attempted = runtime.sha256 === downloaded.sha256 && !(runtime.stage === "applying" || retryable);
|
|
201
202
|
if (downloaded.stage !== "downloaded" || downloaded.error || attempted) return;
|
|
202
203
|
// A damaged worker record means no known worker.
|
|
203
|
-
const
|
|
204
|
+
const workerPath = path.join(directory, "worker-v2.json");
|
|
205
|
+
const worker = await readJson(workerPath, null).catch((error) => { if (!damaged(error)) throw error; return null; });
|
|
204
206
|
if (worker?.client === client && Number.isSafeInteger(worker.pid) && alive(worker.pid)) return;
|
|
205
207
|
const launch = options.spawnWorker || launchWorker;
|
|
206
208
|
let pid;
|
|
@@ -210,13 +212,13 @@ function createRuntime(config, options) {
|
|
|
210
212
|
} catch (error) {
|
|
211
213
|
// Retain only this rejected launch attempt, not raw errors or proof of
|
|
212
214
|
// worker absence. Keep an earlier PID's conservative liveness check.
|
|
213
|
-
await atomicJson(
|
|
215
|
+
await atomicJson(workerPath, { client,
|
|
214
216
|
...(worker?.client === client && Number.isSafeInteger(worker.pid) ? { pid: worker.pid } : {}),
|
|
215
217
|
sha256: downloaded.sha256, error: "client_update_worker_launch_failed", failedAt: Date.now(),
|
|
216
218
|
}).catch(() => {});
|
|
217
219
|
throw error;
|
|
218
220
|
}
|
|
219
|
-
await atomicJson(
|
|
221
|
+
await atomicJson(workerPath, { client, pid });
|
|
220
222
|
});
|
|
221
223
|
}
|
|
222
224
|
async function applyPending() {
|
|
@@ -236,7 +238,7 @@ function createRuntime(config, options) {
|
|
|
236
238
|
if (sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))
|
|
237
239
|
!== sha(await safeFile(path.join(installContext.packageRoot, "hellolynk-client-pack-manifest.json")))) throw failure("adoption_invalid");
|
|
238
240
|
const doctor = await (options.runInstaller || runInstaller)({ executable: installContext.nodePath || process.execPath,
|
|
239
|
-
args: [path.join(installContext.packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor"], cwd: installContext.packageRoot, root, timeoutMs: 180_000 });
|
|
241
|
+
args: [path.join(installContext.packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor", "--json"], cwd: installContext.packageRoot, root, timeoutMs: 180_000 });
|
|
240
242
|
if (!doctorProves(doctor, client, currentVersion)) throw failure("adoption_invalid");
|
|
241
243
|
const adoptedRoot = path.join(path.dirname(root), "releases", `${client}-${sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))}`);
|
|
242
244
|
if (!options.runInstaller) await verifyPackage(adoptedRoot, client, currentVersion);
|
|
@@ -274,7 +276,7 @@ function createRuntime(config, options) {
|
|
|
274
276
|
try {
|
|
275
277
|
await verifyPackage(packageRoot, client, version);
|
|
276
278
|
const inspected = await run({ executable: installContext.nodePath || process.execPath,
|
|
277
|
-
args: [path.join(packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor"], cwd: packageRoot, root, timeoutMs: 180_000 });
|
|
279
|
+
args: [path.join(packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor", "--json"], cwd: packageRoot, root, timeoutMs: 180_000 });
|
|
278
280
|
return doctorProves(inspected, client, version);
|
|
279
281
|
} catch { return false; }
|
|
280
282
|
};
|
|
@@ -397,7 +399,7 @@ async function prepareCandidate(config, record) {
|
|
|
397
399
|
const directory = path.join(config.root, "client-updates", config.client);
|
|
398
400
|
if (!HASH.test(record.sha256) || typeof record.version !== "string" || !/^[0-9A-Za-z.+-]{1,100}$/.test(record.version)
|
|
399
401
|
|| typeof record.filename !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.zip$/.test(record.filename)) throw failure("candidate_invalid");
|
|
400
|
-
const artifactPath = path.join(directory,
|
|
402
|
+
const artifactPath = path.join(directory, CLIENT_UPDATE_RELEASES, `${record.version}-${record.sha256}`, record.filename);
|
|
401
403
|
if (artifactPath !== record.artifactPath) throw failure("candidate_path_invalid");
|
|
402
404
|
const archive = await safeFile(artifactPath, 64 * 1024 * 1024);
|
|
403
405
|
if (archive.length !== record.bytes || sha(archive) !== record.sha256) throw failure("artifact_integrity_invalid");
|
|
@@ -406,43 +408,63 @@ async function prepareCandidate(config, record) {
|
|
|
406
408
|
const extractedRoot = path.join(temporaryRoot, "extracted");
|
|
407
409
|
try {
|
|
408
410
|
// Re-extract the hash-verified archive; never execute a mutable earlier
|
|
409
|
-
// extractedRoot or an executable path supplied by state.
|
|
411
|
+
// extractedRoot or an executable path supplied by update state.
|
|
410
412
|
const sealed = path.join(temporaryRoot, record.filename);
|
|
411
413
|
await fs.writeFile(sealed, archive, { flag: "wx", mode: 0o600 });
|
|
412
|
-
await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot });
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const descriptor = await readJson(path.join(extractedRoot, "trusted-release.json"));
|
|
416
|
-
const manifestBytes = await safeFile(path.join(extractedRoot, "dist", "package", "hellolynk-dsh-nav-build.json"));
|
|
417
|
-
if (descriptor?.contract !== "hellolynk-dsh-nav-release-v1" || descriptor.manifestSha256 !== sha(manifestBytes)) throw failure("package_pin_invalid");
|
|
418
|
-
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
419
|
-
if (manifest.contract !== "hellolynk-dsh-nav-build-v3") throw failure("package_pin_invalid");
|
|
420
|
-
await verifyRecords(path.join(extractedRoot, "dist", "package"), manifest.packageFiles, ["hellolynk-dsh-nav-build.json"]);
|
|
421
|
-
packageRoot = path.join(extractedRoot, "dist", "package", "runtime", "connect");
|
|
422
|
-
await safeFile(path.join(extractedRoot, "scripts", "install.mjs"));
|
|
423
|
-
} else {
|
|
424
|
-
const candidates = [extractedRoot, path.join(extractedRoot, "package"), path.join(extractedRoot, `hellolynk-${config.client}-${record.version}`, "package")];
|
|
425
|
-
const matches = [];
|
|
426
|
-
for (const candidate of candidates) if (await exists(path.join(candidate, "package.json"))) matches.push(candidate);
|
|
427
|
-
if (matches.length !== 1) throw failure("package_identity_invalid");
|
|
428
|
-
packageRoot = matches[0];
|
|
429
|
-
}
|
|
430
|
-
await verifyPackage(packageRoot, config.client, record.version);
|
|
414
|
+
await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot, platform: config.platform });
|
|
415
|
+
const { packageRoot } = await verifyExtractedClientUpdatePackage(extractedRoot,
|
|
416
|
+
{ client: config.client, platform: config.platform, version: record.version });
|
|
431
417
|
return { temporaryRoot, extractedRoot, artifactPath: sealed, packageRoot, setupPath: path.join(packageRoot, "bin", "hellolynk-client-setup.mjs") };
|
|
432
418
|
} catch (error) { await fs.rm(temporaryRoot, { recursive: true, force: true }); throw error; }
|
|
433
419
|
}
|
|
434
420
|
|
|
421
|
+
export async function verifyExtractedClientUpdatePackage(extractedRoot, { client, platform, version }) {
|
|
422
|
+
if (!CLIENT_UPDATE_CLIENTS.includes(client) || typeof version !== "string" || !VERSION.test(version)) throw failure("package_identity_invalid");
|
|
423
|
+
let packageRoot;
|
|
424
|
+
if (client === "dsh") {
|
|
425
|
+
const descriptor = await readJson(path.join(extractedRoot, "trusted-release.json"));
|
|
426
|
+
const manifestBytes = await safeFile(path.join(extractedRoot, "dist", "package", "hellolynk-dsh-nav-build.json"));
|
|
427
|
+
if (descriptor?.contract !== "hellolynk-dsh-nav-release-v1" || descriptor.manifestSha256 !== sha(manifestBytes)) throw failure("package_pin_invalid");
|
|
428
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
429
|
+
if (manifest.contract !== "hellolynk-dsh-nav-build-v3") throw failure("package_pin_invalid");
|
|
430
|
+
await verifyRecords(path.join(extractedRoot, "dist", "package"), manifest.packageFiles, ["hellolynk-dsh-nav-build.json"]);
|
|
431
|
+
packageRoot = path.join(extractedRoot, "dist", "package", "runtime", "connect");
|
|
432
|
+
await safeFile(path.join(extractedRoot, "scripts", "install.mjs"));
|
|
433
|
+
} else {
|
|
434
|
+
const id = clientUpdateCatalogId(client, platform) || client;
|
|
435
|
+
const candidates = [extractedRoot, path.join(extractedRoot, "package"), path.join(extractedRoot, `hellolynk-${id}-${version}`, "package")];
|
|
436
|
+
const matches = [];
|
|
437
|
+
for (const candidate of candidates) if (await exists(path.join(candidate, "package.json"))) matches.push(candidate);
|
|
438
|
+
if (matches.length !== 1) throw failure("package_identity_invalid");
|
|
439
|
+
packageRoot = matches[0];
|
|
440
|
+
}
|
|
441
|
+
const checked = await verifyPackage(packageRoot, client, version);
|
|
442
|
+
return { packageRoot, ...checked };
|
|
443
|
+
}
|
|
444
|
+
|
|
435
445
|
async function verifyPackage(root, client, version) {
|
|
436
446
|
const pkg = await readJson(path.join(root, "package.json"));
|
|
437
447
|
const manifestBytes = await safeFile(path.join(root, "hellolynk-client-pack-manifest.json"));
|
|
438
448
|
const pin = await readJson(path.join(root, "hellolynk-client-pack-pin.json"));
|
|
439
449
|
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
440
450
|
if (pkg.version !== version || pkg.hellolynkClient?.client !== client || pkg.name !== NAMES[client]
|
|
451
|
+
|| (MAC_CLIENT_VERSION.test(version) && (!clientUpdateCatalogId(client, "darwin") || pkg.os?.[0] !== "darwin" || pkg.cpu?.[0] !== "arm64"))
|
|
441
452
|
|| manifest.contract !== "hellolynk-client-npm-pack-manifest-v1" || manifest.client !== client
|
|
442
453
|
|| manifest.packageName !== pkg.name || manifest.version !== version
|
|
443
454
|
|| pin.contract !== "hellolynk-client-npm-pack-pin-v1" || pin.manifestSha256 !== sha(manifestBytes)) throw failure("package_identity_invalid");
|
|
444
455
|
await verifyRecords(root, manifest.files, ["hellolynk-client-pack-manifest.json", "hellolynk-client-pack-pin.json"]);
|
|
445
456
|
if (!manifest.files.some((file) => file.path === "bin/hellolynk-client-setup.mjs")) throw failure("package_pin_invalid");
|
|
457
|
+
if (MAC_CLIENT_VERSION.test(version)) {
|
|
458
|
+
const helpers = client === "codex"
|
|
459
|
+
? ["plugins/hellolynk-codex/runtime/platform/darwin", "plugins/hellolynk-codex/runtime/bootstrap/platform/darwin"]
|
|
460
|
+
: ["runtime/platform/darwin"];
|
|
461
|
+
for (const relative of helpers) {
|
|
462
|
+
const file = `${relative}/hellolynk-keychain-helper.bin`;
|
|
463
|
+
if (!manifest.files.some(record => record.path === file)
|
|
464
|
+
|| (process.platform !== "win32" && !((await fs.lstat(path.join(root, file))).mode & 0o100))) throw failure("package_pin_invalid");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { coreRoot: pkg.hellolynkClient.coreRoot, files: manifest.files };
|
|
446
468
|
}
|
|
447
469
|
async function verifyRecords(root, records, allowedExtra) {
|
|
448
470
|
if (!Array.isArray(records) || records.length < 1 || records.length > 5000) throw failure("package_pin_invalid");
|
|
@@ -471,7 +493,9 @@ async function verifyInstalledContext(config) {
|
|
|
471
493
|
await verifyPackage(context.packageRoot, config.client, config.currentVersion);
|
|
472
494
|
}
|
|
473
495
|
function normalize(value) {
|
|
496
|
+
const platform = value.platform || process.platform;
|
|
474
497
|
if (!CLIENT_UPDATE_CLIENTS.includes(value.client) || typeof value.currentVersion !== "string" || !VERSION.test(value.currentVersion)
|
|
498
|
+
|| (platform === "darwin" && !MAC_CLIENT_VERSION.test(value.currentVersion))
|
|
475
499
|
|| !path.isAbsolute(String(value.root || "")) || !path.isAbsolute(String(value.installContext?.packageRoot || ""))) throw failure("configuration_invalid");
|
|
476
500
|
const context = { packageRoot: path.resolve(value.installContext.packageRoot) };
|
|
477
501
|
for (const name of ["nodePath", "dshProfile", "dshRoot"]) {
|
|
@@ -484,7 +508,7 @@ function normalize(value) {
|
|
|
484
508
|
if (!HASH.test(value.installContext.expectedInstalledManifestSha256)) throw failure("configuration_invalid");
|
|
485
509
|
context.expectedInstalledManifestSha256 = value.installContext.expectedInstalledManifestSha256;
|
|
486
510
|
}
|
|
487
|
-
return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, installContext: context };
|
|
511
|
+
return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, platform, installContext: context };
|
|
488
512
|
}
|
|
489
513
|
async function launchWorker(config) {
|
|
490
514
|
// A host may execute this module from an ephemeral captured import graph.
|
|
@@ -510,8 +534,9 @@ async function launchWorker(config) {
|
|
|
510
534
|
async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
|
|
511
535
|
executable = await runtimeSafety.resolveHelloLynkStandaloneNode({ preferredExecutable: executable });
|
|
512
536
|
const env = { ...runtimeSafety.standaloneHelloLynkNodeEnvironment(process.env), HELLOLYNK_NEUTRAL_STORE_ROOT: root };
|
|
537
|
+
const mac = process.platform === "darwin";
|
|
513
538
|
return new Promise((resolve, reject) => {
|
|
514
|
-
const child = spawn(executable, args, { cwd, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
539
|
+
const child = spawn(executable, args, { cwd, env, detached: mac, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
515
540
|
let stdout = "", stderr = "", finished = false, terminalError = null;
|
|
516
541
|
const timer = setTimeout(() => terminate(failure("installer_timeout")), timeoutMs);
|
|
517
542
|
function finish(error, value) { if (finished) return; finished = true; clearTimeout(timer); error ? reject(error) : resolve(value); }
|
|
@@ -529,6 +554,9 @@ async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
|
|
|
529
554
|
killer.on("error", () => { clearTimeout(killTimer); if (!finished) killChild(); });
|
|
530
555
|
killer.once("close", () => { clearTimeout(killTimer); if (!finished) killChild(); });
|
|
531
556
|
} catch { killChild(); }
|
|
557
|
+
} else if (mac && child.pid) {
|
|
558
|
+
// The installer owns this process group, including a synchronous Codex CLI.
|
|
559
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { killChild(); }
|
|
532
560
|
} else killChild();
|
|
533
561
|
}
|
|
534
562
|
child.on("error", () => {
|
|
@@ -580,7 +608,8 @@ function damaged(error) { return error instanceof SyntaxError || error?.code ===
|
|
|
580
608
|
// Windows paths compare case-insensitively and may differ by a link or short name.
|
|
581
609
|
async function samePath(left, right) {
|
|
582
610
|
if (typeof left !== "string" || typeof right !== "string" || !path.isAbsolute(left) || !path.isAbsolute(right)) return false;
|
|
583
|
-
const
|
|
611
|
+
const canonical = (value) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
612
|
+
const same = (a, b) => canonical(a) === canonical(b);
|
|
584
613
|
if (same(left, right)) return true;
|
|
585
614
|
try { return same(await fs.realpath(left), await fs.realpath(right)); } catch { return false; }
|
|
586
615
|
}
|
|
@@ -6,20 +6,39 @@ import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-
|
|
|
6
6
|
|
|
7
7
|
const CATALOG = "https://hellolynk.com/downloads/clients.json";
|
|
8
8
|
export const CLIENT_UPDATE_CLIENTS = Object.freeze(["codex", "openclaw", "trae-work", "workbuddy", "dsh", "claude-code"]);
|
|
9
|
+
export const MAC_CLIENT_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
|
|
9
10
|
const HOUR = 3_600_000;
|
|
10
11
|
const MAX_ARTIFACT = 64 * 1024 * 1024;
|
|
11
12
|
const HASH = /^[a-f0-9]{64}$/;
|
|
12
|
-
const CONTRACT = "hellolynk-client-update-
|
|
13
|
+
const CONTRACT = "hellolynk-client-update-v2";
|
|
14
|
+
export const CLIENT_UPDATE_RELEASES = "releases-v2";
|
|
15
|
+
// The release freeze pins only public Ed25519 keys here. An empty set refuses all updates.
|
|
16
|
+
export const CLIENT_UPDATE_CATALOG_KEYS = Object.freeze({
|
|
17
|
+
"ed25519-20261109-primary-d4fd7f46475dac48": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAucQohi+be+ksjQhRzYD8ZiyZMUz6Vi/DSphQ3iVOWDM=\n-----END PUBLIC KEY-----\n",
|
|
18
|
+
"ed25519-20261109-backup-5e3d08a775320304": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA2vxHpcgLZQc1cXqxq3dcmZRmE0fAFyeRC3umMVGAosA=\n-----END PUBLIC KEY-----\n"
|
|
19
|
+
});
|
|
20
|
+
export const CLIENT_UPDATE_CATALOG_LIMIT = 256 * 1024;
|
|
13
21
|
// A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
|
|
14
|
-
const BLOCKING = /^client_update_(?:same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
|
|
22
|
+
const BLOCKING = /^client_update_(?:catalog_signature_invalid|same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
|
|
23
|
+
|
|
24
|
+
export function clientUpdateCatalogId(client, platform) {
|
|
25
|
+
if (platform === "win32") return client;
|
|
26
|
+
if (platform === "darwin" && (client === "codex" || client === "claude-code")) return `${client}-macos`;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
// This component only stages verified files. Host lifecycle and client-native
|
|
17
31
|
// installation remain the responsibility of the caller.
|
|
18
32
|
export function createClientUpdateManager(options = {}) {
|
|
19
33
|
const { client, currentVersion, platform = process.platform } = options;
|
|
20
34
|
if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
|
|
35
|
+
const catalogId = clientUpdateCatalogId(client, platform);
|
|
36
|
+
const mac = platform === "darwin";
|
|
37
|
+
if (mac && !MAC_CLIENT_VERSION.test(currentVersion)) fail("configuration_invalid");
|
|
21
38
|
const root = path.join(path.resolve(options.root), "client-updates", client);
|
|
22
|
-
|
|
39
|
+
// A still-running unsigned updater may read, rewrite and prune its own cache.
|
|
40
|
+
const stateFile = path.join(root, "state-v2.json");
|
|
41
|
+
const releases = path.join(root, CLIENT_UPDATE_RELEASES);
|
|
23
42
|
const fetcher = options.fetchImpl || globalThis.fetch;
|
|
24
43
|
const clock = options.clock || Date.now;
|
|
25
44
|
const timeoutMs = bounded(options.timeoutMs, 30_000, 50, 120_000);
|
|
@@ -60,7 +79,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
60
79
|
}
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
function releasePath(record) { return path.join(
|
|
82
|
+
function releasePath(record) { return path.join(releases, `${record.version}-${record.sha256}`); }
|
|
64
83
|
async function save(record) {
|
|
65
84
|
await safeDirectory(root);
|
|
66
85
|
const temporary = path.join(root, `.state-${crypto.randomUUID()}.tmp`);
|
|
@@ -80,12 +99,13 @@ export function createClientUpdateManager(options = {}) {
|
|
|
80
99
|
let old = await readState();
|
|
81
100
|
const now = clock();
|
|
82
101
|
if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
|
|
83
|
-
if (
|
|
102
|
+
if (!catalogId) return save({ ...old, stage: "unsupported", checkedAt: now });
|
|
84
103
|
try {
|
|
85
|
-
const metadata = await request(CATALOG, { limit:
|
|
104
|
+
const metadata = await request(CATALOG, { limit: CLIENT_UPDATE_CATALOG_LIMIT, timeout: timeoutMs, kind: "catalog" });
|
|
86
105
|
let catalog;
|
|
87
106
|
try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
|
|
88
|
-
const
|
|
107
|
+
const verified = verifyClientUpdateCatalog(catalog, options.catalogKeys);
|
|
108
|
+
const artifact = validateClientUpdateCatalogEntry(verified, catalogId, mac);
|
|
89
109
|
// The catalog has no build for this client: there is nothing to install.
|
|
90
110
|
if (!artifact) {
|
|
91
111
|
// A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
|
|
@@ -134,12 +154,12 @@ export function createClientUpdateManager(options = {}) {
|
|
|
134
154
|
if (!bytes) fail("source_unavailable");
|
|
135
155
|
const release = releasePath(artifact);
|
|
136
156
|
await safeDirectory(path.dirname(release));
|
|
137
|
-
const stage = path.join(
|
|
157
|
+
const stage = path.join(releases, `.stage-${crypto.randomUUID()}`);
|
|
138
158
|
await fs.mkdir(stage, { mode: 0o700 });
|
|
139
159
|
try {
|
|
140
160
|
const archivePath = path.join(stage, artifact.filename);
|
|
141
161
|
await fs.writeFile(archivePath, bytes, { flag: "wx", mode: 0o600 });
|
|
142
|
-
await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted") });
|
|
162
|
+
await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted"), platform });
|
|
143
163
|
await fs.rename(stage, release).catch(async (error) => {
|
|
144
164
|
if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error;
|
|
145
165
|
try {
|
|
@@ -147,7 +167,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
147
167
|
return;
|
|
148
168
|
} catch { /* Preserve damaged evidence without reusing it. */ }
|
|
149
169
|
await safeAncestors(release, true);
|
|
150
|
-
const quarantine = path.join(
|
|
170
|
+
const quarantine = path.join(releases, `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
|
|
151
171
|
await fs.rename(release, quarantine);
|
|
152
172
|
try { await fs.rename(stage, release); }
|
|
153
173
|
catch (replacement) { await fs.rename(quarantine, release).catch(() => {}); throw replacement; }
|
|
@@ -172,7 +192,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
172
192
|
});
|
|
173
193
|
}
|
|
174
194
|
|
|
175
|
-
// After an install only the package state
|
|
195
|
+
// After an install only the package the signed state names stays cached; older
|
|
176
196
|
// versions and quarantined copies go. Holding the check lock means no
|
|
177
197
|
// download is in flight to be mistaken for leftovers.
|
|
178
198
|
async function prune() {
|
|
@@ -180,7 +200,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
180
200
|
return withNeutralHostStoreLock(root, "client-update", async () => {
|
|
181
201
|
const state = await readState();
|
|
182
202
|
if (!semver(state.version) || !HASH.test(String(state.sha256 || ""))) return [];
|
|
183
|
-
const keep = path.basename(releasePath(state)),
|
|
203
|
+
const keep = path.basename(releasePath(state)), removed = [];
|
|
184
204
|
await safeAncestors(releases, true);
|
|
185
205
|
let entries;
|
|
186
206
|
try { entries = await fs.readdir(releases, { withFileTypes: true }); }
|
|
@@ -267,15 +287,36 @@ export function createClientUpdateManager(options = {}) {
|
|
|
267
287
|
});
|
|
268
288
|
}
|
|
269
289
|
|
|
270
|
-
function
|
|
290
|
+
export function verifyClientUpdateCatalog(catalog, keys = CLIENT_UPDATE_CATALOG_KEYS) {
|
|
291
|
+
const signed = catalog?.signed;
|
|
292
|
+
if (!signed || typeof signed.keyId !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(signed.keyId)
|
|
293
|
+
|| !Object.hasOwn(keys, signed.keyId) || typeof signed.payload !== "string" || !signed.payload
|
|
294
|
+
|| signed.payload.length > CLIENT_UPDATE_CATALOG_LIMIT || typeof signed.signature !== "string"
|
|
295
|
+
|| !/^[A-Za-z0-9_-]{86}$/.test(signed.signature)) fail("catalog_signature_invalid");
|
|
296
|
+
const payload = Buffer.from(signed.payload, "base64");
|
|
297
|
+
const signature = Buffer.from(signed.signature, "base64url");
|
|
298
|
+
if (payload.toString("base64") !== signed.payload || signature.toString("base64url") !== signed.signature) fail("catalog_signature_invalid");
|
|
299
|
+
let valid = false;
|
|
300
|
+
try {
|
|
301
|
+
const key = crypto.createPublicKey(keys[signed.keyId]);
|
|
302
|
+
valid = key.asymmetricKeyType === "ed25519" && crypto.verify(null, payload, key, signature);
|
|
303
|
+
} catch { /* Invalid pins and invalid signatures both fail closed. */ }
|
|
304
|
+
if (!valid) fail("catalog_signature_invalid");
|
|
305
|
+
const json = payload.toString("utf8");
|
|
306
|
+
if (!Buffer.from(json, "utf8").equals(payload)) fail("catalog_invalid");
|
|
307
|
+
try { return JSON.parse(json); } catch { fail("catalog_invalid"); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function validateClientUpdateCatalogEntry(catalog, client, mac) {
|
|
271
311
|
if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
|
|
272
312
|
const matches = catalog.clients.filter((item) => item?.client === client);
|
|
273
313
|
if (matches.length === 0) return null;
|
|
274
314
|
if (matches.length > 1) fail("catalog_invalid");
|
|
275
315
|
const item = matches[0];
|
|
276
|
-
if (!semver(item.version) || !
|
|
316
|
+
if (!semver(item.version) || (mac && !MAC_CLIENT_VERSION.test(item.version))
|
|
317
|
+
|| !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
|
|
277
318
|
|| !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
|
|
278
|
-
|| !Array.isArray(item.platforms) || !item.platforms.includes("Windows")) fail("catalog_invalid");
|
|
319
|
+
|| !Array.isArray(item.platforms) || !item.platforms.includes(mac ? "macOS" : "Windows")) fail("catalog_invalid");
|
|
279
320
|
const official = new URL(String(item.url || ""), CATALOG);
|
|
280
321
|
if (official.origin !== "https://hellolynk.com" || !official.pathname.startsWith("/downloads/")) fail("catalog_invalid");
|
|
281
322
|
validateUrl(official, "artifact");
|
|
@@ -309,7 +350,7 @@ function validateUrl(url, kind, redirected = false, initial = url) {
|
|
|
309
350
|
if (!direct && !githubCdn) fail("url_invalid");
|
|
310
351
|
}
|
|
311
352
|
|
|
312
|
-
export async function extractClientUpdateArchive({ archivePath, destination, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
|
|
353
|
+
export async function extractClientUpdateArchive({ archivePath, destination, platform = process.platform, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
|
|
313
354
|
if (!path.isAbsolute(archivePath) || !path.isAbsolute(destination)) fail("archive_path_invalid");
|
|
314
355
|
await safeAncestors(archivePath, false);
|
|
315
356
|
const stat = await fs.lstat(archivePath);
|
|
@@ -360,7 +401,7 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
|
|
|
360
401
|
total += size;
|
|
361
402
|
if (total > maxTotalBytes) fail("archive_too_large");
|
|
362
403
|
spans.push([local, start + packed]);
|
|
363
|
-
entries.push({ segments, directory, start, packed, size, method, crc });
|
|
404
|
+
entries.push({ segments, directory, start, packed, size, method, crc, mode });
|
|
364
405
|
cursor += 46 + nameLength + extraLength + commentLength;
|
|
365
406
|
}
|
|
366
407
|
if (cursor !== eocd) fail("archive_invalid");
|
|
@@ -377,7 +418,10 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
|
|
|
377
418
|
try { body = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.size) }); }
|
|
378
419
|
catch { fail("archive_integrity_invalid"); }
|
|
379
420
|
if (body.length !== entry.size || crc32(body) !== entry.crc) fail("archive_integrity_invalid");
|
|
380
|
-
|
|
421
|
+
// A Mac update runs a bundled Keychain helper. Keep its execute bit without
|
|
422
|
+
// inheriting group/other access or special permission bits from the ZIP.
|
|
423
|
+
const ownerExecute = platform === "darwin" ? entry.mode & 0o100 : 0;
|
|
424
|
+
await fs.writeFile(target, body, { flag: "wx", mode: 0o600 | ownerExecute });
|
|
381
425
|
}
|
|
382
426
|
return Object.freeze({ files: entries.filter((item) => !item.directory).length, bytes: total });
|
|
383
427
|
}
|