@hellolynk-ai/codex-plugin 0.2.102-macos.1 → 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.
Files changed (26) hide show
  1. package/README.md +17 -17
  2. package/bin/hellolynk-client-setup.mjs +7 -2
  3. package/hellolynk-client-pack-manifest.json +36 -56
  4. package/hellolynk-client-pack-pin.json +1 -1
  5. package/package.json +4 -8
  6. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +1 -1
  7. package/plugins/hellolynk-codex/.mcp.json +19 -20
  8. package/plugins/hellolynk-codex/package.json +1 -1
  9. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-host.mjs +45 -32
  10. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +3 -3
  11. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +47 -34
  12. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +46 -16
  13. package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-host.mjs +45 -32
  14. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
  15. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +34 -11
  16. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +11 -27
  17. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
  18. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +46 -10
  19. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +2 -2
  20. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -1193
  21. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -33
  22. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +42 -42
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.bin +0 -0
  24. package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.swift +0 -149
  25. package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.bin +0 -0
  26. package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.swift +0 -149
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createClientUpdateManager, extractClientUpdateArchive, compareClientUpdateVersions, clientUpdateCatalogId,
8
- CLIENT_UPDATE_CLIENTS, MAC_CODEX_VERSION } from "./hellolynk-client-update.mjs";
8
+ CLIENT_UPDATE_CLIENTS, CLIENT_UPDATE_RELEASES, MAC_CLIENT_VERSION } from "./hellolynk-client-update.mjs";
9
9
  import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-store.mjs";
10
10
  import { readClientAccount } from "./hellolynk-client-account.mjs";
11
11
  import runtimeSafety from "./hellolynk-runtime-safety.cjs";
@@ -95,7 +95,7 @@ export async function runClientUpdateWorker(options = {}) {
95
95
  function createRuntime(config, options) {
96
96
  const { root, client, currentVersion, installContext } = config;
97
97
  const directory = path.join(root, "client-updates", client);
98
- const statePath = path.join(directory, "runtime.json"), leasePath = path.join(directory, "leases.json"), installedPath = path.join(directory, "installed.json");
98
+ const statePath = path.join(directory, "runtime-v2.json"), leasePath = path.join(directory, "leases.json"), installedPath = path.join(directory, "installed.json");
99
99
  const manager = createClientUpdateManager({ ...options, root, client, currentVersion, platform: config.platform });
100
100
  const alive = options.isProcessAlive || processAlive;
101
101
  async function gate(operation) {
@@ -201,7 +201,8 @@ function createRuntime(config, options) {
201
201
  const attempted = runtime.sha256 === downloaded.sha256 && !(runtime.stage === "applying" || retryable);
202
202
  if (downloaded.stage !== "downloaded" || downloaded.error || attempted) return;
203
203
  // A damaged worker record means no known worker.
204
- const worker = await readJson(path.join(directory, "worker.json"), null).catch((error) => { if (!damaged(error)) throw error; return null; });
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; });
205
206
  if (worker?.client === client && Number.isSafeInteger(worker.pid) && alive(worker.pid)) return;
206
207
  const launch = options.spawnWorker || launchWorker;
207
208
  let pid;
@@ -211,13 +212,13 @@ function createRuntime(config, options) {
211
212
  } catch (error) {
212
213
  // Retain only this rejected launch attempt, not raw errors or proof of
213
214
  // worker absence. Keep an earlier PID's conservative liveness check.
214
- await atomicJson(path.join(directory, "worker.json"), { client,
215
+ await atomicJson(workerPath, { client,
215
216
  ...(worker?.client === client && Number.isSafeInteger(worker.pid) ? { pid: worker.pid } : {}),
216
217
  sha256: downloaded.sha256, error: "client_update_worker_launch_failed", failedAt: Date.now(),
217
218
  }).catch(() => {});
218
219
  throw error;
219
220
  }
220
- await atomicJson(path.join(directory, "worker.json"), { client, pid });
221
+ await atomicJson(workerPath, { client, pid });
221
222
  });
222
223
  }
223
224
  async function applyPending() {
@@ -237,7 +238,7 @@ function createRuntime(config, options) {
237
238
  if (sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))
238
239
  !== sha(await safeFile(path.join(installContext.packageRoot, "hellolynk-client-pack-manifest.json")))) throw failure("adoption_invalid");
239
240
  const doctor = await (options.runInstaller || runInstaller)({ executable: installContext.nodePath || process.execPath,
240
- 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 });
241
242
  if (!doctorProves(doctor, client, currentVersion)) throw failure("adoption_invalid");
242
243
  const adoptedRoot = path.join(path.dirname(root), "releases", `${client}-${sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))}`);
243
244
  if (!options.runInstaller) await verifyPackage(adoptedRoot, client, currentVersion);
@@ -275,7 +276,7 @@ function createRuntime(config, options) {
275
276
  try {
276
277
  await verifyPackage(packageRoot, client, version);
277
278
  const inspected = await run({ executable: installContext.nodePath || process.execPath,
278
- 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 });
279
280
  return doctorProves(inspected, client, version);
280
281
  } catch { return false; }
281
282
  };
@@ -398,7 +399,7 @@ async function prepareCandidate(config, record) {
398
399
  const directory = path.join(config.root, "client-updates", config.client);
399
400
  if (!HASH.test(record.sha256) || typeof record.version !== "string" || !/^[0-9A-Za-z.+-]{1,100}$/.test(record.version)
400
401
  || typeof record.filename !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.zip$/.test(record.filename)) throw failure("candidate_invalid");
401
- const artifactPath = path.join(directory, "releases", `${record.version}-${record.sha256}`, record.filename);
402
+ const artifactPath = path.join(directory, CLIENT_UPDATE_RELEASES, `${record.version}-${record.sha256}`, record.filename);
402
403
  if (artifactPath !== record.artifactPath) throw failure("candidate_path_invalid");
403
404
  const archive = await safeFile(artifactPath, 64 * 1024 * 1024);
404
405
  if (archive.length !== record.bytes || sha(archive) !== record.sha256) throw failure("artifact_integrity_invalid");
@@ -407,51 +408,63 @@ async function prepareCandidate(config, record) {
407
408
  const extractedRoot = path.join(temporaryRoot, "extracted");
408
409
  try {
409
410
  // Re-extract the hash-verified archive; never execute a mutable earlier
410
- // extractedRoot or an executable path supplied by state.json.
411
+ // extractedRoot or an executable path supplied by update state.
411
412
  const sealed = path.join(temporaryRoot, record.filename);
412
413
  await fs.writeFile(sealed, archive, { flag: "wx", mode: 0o600 });
413
414
  await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot, platform: config.platform });
414
- let packageRoot;
415
- if (config.client === "dsh") {
416
- const descriptor = await readJson(path.join(extractedRoot, "trusted-release.json"));
417
- const manifestBytes = await safeFile(path.join(extractedRoot, "dist", "package", "hellolynk-dsh-nav-build.json"));
418
- if (descriptor?.contract !== "hellolynk-dsh-nav-release-v1" || descriptor.manifestSha256 !== sha(manifestBytes)) throw failure("package_pin_invalid");
419
- const manifest = JSON.parse(manifestBytes.toString("utf8"));
420
- if (manifest.contract !== "hellolynk-dsh-nav-build-v3") throw failure("package_pin_invalid");
421
- await verifyRecords(path.join(extractedRoot, "dist", "package"), manifest.packageFiles, ["hellolynk-dsh-nav-build.json"]);
422
- packageRoot = path.join(extractedRoot, "dist", "package", "runtime", "connect");
423
- await safeFile(path.join(extractedRoot, "scripts", "install.mjs"));
424
- } else {
425
- const id = clientUpdateCatalogId(config.client, config.platform) || config.client;
426
- const candidates = [extractedRoot, path.join(extractedRoot, "package"), path.join(extractedRoot, `hellolynk-${id}-${record.version}`, "package")];
427
- const matches = [];
428
- for (const candidate of candidates) if (await exists(path.join(candidate, "package.json"))) matches.push(candidate);
429
- if (matches.length !== 1) throw failure("package_identity_invalid");
430
- packageRoot = matches[0];
431
- }
432
- await verifyPackage(packageRoot, config.client, record.version);
415
+ const { packageRoot } = await verifyExtractedClientUpdatePackage(extractedRoot,
416
+ { client: config.client, platform: config.platform, version: record.version });
433
417
  return { temporaryRoot, extractedRoot, artifactPath: sealed, packageRoot, setupPath: path.join(packageRoot, "bin", "hellolynk-client-setup.mjs") };
434
418
  } catch (error) { await fs.rm(temporaryRoot, { recursive: true, force: true }); throw error; }
435
419
  }
436
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
+
437
445
  async function verifyPackage(root, client, version) {
438
446
  const pkg = await readJson(path.join(root, "package.json"));
439
447
  const manifestBytes = await safeFile(path.join(root, "hellolynk-client-pack-manifest.json"));
440
448
  const pin = await readJson(path.join(root, "hellolynk-client-pack-pin.json"));
441
449
  const manifest = JSON.parse(manifestBytes.toString("utf8"));
442
450
  if (pkg.version !== version || pkg.hellolynkClient?.client !== client || pkg.name !== NAMES[client]
443
- || (MAC_CODEX_VERSION.test(version) && (client !== "codex" || pkg.os?.[0] !== "darwin" || pkg.cpu?.[0] !== "arm64"))
451
+ || (MAC_CLIENT_VERSION.test(version) && (!clientUpdateCatalogId(client, "darwin") || pkg.os?.[0] !== "darwin" || pkg.cpu?.[0] !== "arm64"))
444
452
  || manifest.contract !== "hellolynk-client-npm-pack-manifest-v1" || manifest.client !== client
445
453
  || manifest.packageName !== pkg.name || manifest.version !== version
446
454
  || pin.contract !== "hellolynk-client-npm-pack-pin-v1" || pin.manifestSha256 !== sha(manifestBytes)) throw failure("package_identity_invalid");
447
455
  await verifyRecords(root, manifest.files, ["hellolynk-client-pack-manifest.json", "hellolynk-client-pack-pin.json"]);
448
456
  if (!manifest.files.some((file) => file.path === "bin/hellolynk-client-setup.mjs")) throw failure("package_pin_invalid");
449
- if (process.platform !== "win32" && MAC_CODEX_VERSION.test(version)) {
450
- for (const relative of ["platform/darwin", "bootstrap/platform/darwin"]) {
451
- const helper = path.join(root, "plugins/hellolynk-codex/runtime", relative, "hellolynk-keychain-helper.bin");
452
- if (!((await fs.lstat(helper)).mode & 0o100)) 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");
453
465
  }
454
466
  }
467
+ return { coreRoot: pkg.hellolynkClient.coreRoot, files: manifest.files };
455
468
  }
456
469
  async function verifyRecords(root, records, allowedExtra) {
457
470
  if (!Array.isArray(records) || records.length < 1 || records.length > 5000) throw failure("package_pin_invalid");
@@ -482,7 +495,7 @@ async function verifyInstalledContext(config) {
482
495
  function normalize(value) {
483
496
  const platform = value.platform || process.platform;
484
497
  if (!CLIENT_UPDATE_CLIENTS.includes(value.client) || typeof value.currentVersion !== "string" || !VERSION.test(value.currentVersion)
485
- || (platform === "darwin" && (value.client !== "codex" || !MAC_CODEX_VERSION.test(value.currentVersion)))
498
+ || (platform === "darwin" && !MAC_CLIENT_VERSION.test(value.currentVersion))
486
499
  || !path.isAbsolute(String(value.root || "")) || !path.isAbsolute(String(value.installContext?.packageRoot || ""))) throw failure("configuration_invalid");
487
500
  const context = { packageRoot: path.resolve(value.installContext.packageRoot) };
488
501
  for (const name of ["nodePath", "dshProfile", "dshRoot"]) {
@@ -6,17 +6,24 @@ 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_CODEX_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
9
+ export const MAC_CLIENT_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
10
10
  const HOUR = 3_600_000;
11
11
  const MAX_ARTIFACT = 64 * 1024 * 1024;
12
12
  const HASH = /^[a-f0-9]{64}$/;
13
- const CONTRACT = "hellolynk-client-update-v1";
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;
14
21
  // A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
15
- 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)$/;
16
23
 
17
24
  export function clientUpdateCatalogId(client, platform) {
18
25
  if (platform === "win32") return client;
19
- if (platform === "darwin" && client === "codex") return "codex-macos";
26
+ if (platform === "darwin" && (client === "codex" || client === "claude-code")) return `${client}-macos`;
20
27
  return null;
21
28
  }
22
29
 
@@ -26,10 +33,12 @@ export function createClientUpdateManager(options = {}) {
26
33
  const { client, currentVersion, platform = process.platform } = options;
27
34
  if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
28
35
  const catalogId = clientUpdateCatalogId(client, platform);
29
- const mac = catalogId === "codex-macos";
30
- if (mac && !MAC_CODEX_VERSION.test(currentVersion)) fail("configuration_invalid");
36
+ const mac = platform === "darwin";
37
+ if (mac && !MAC_CLIENT_VERSION.test(currentVersion)) fail("configuration_invalid");
31
38
  const root = path.join(path.resolve(options.root), "client-updates", client);
32
- const stateFile = path.join(root, "state.json");
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);
33
42
  const fetcher = options.fetchImpl || globalThis.fetch;
34
43
  const clock = options.clock || Date.now;
35
44
  const timeoutMs = bounded(options.timeoutMs, 30_000, 50, 120_000);
@@ -70,7 +79,7 @@ export function createClientUpdateManager(options = {}) {
70
79
  }
71
80
  }
72
81
 
73
- function releasePath(record) { return path.join(root, "releases", `${record.version}-${record.sha256}`); }
82
+ function releasePath(record) { return path.join(releases, `${record.version}-${record.sha256}`); }
74
83
  async function save(record) {
75
84
  await safeDirectory(root);
76
85
  const temporary = path.join(root, `.state-${crypto.randomUUID()}.tmp`);
@@ -92,10 +101,11 @@ export function createClientUpdateManager(options = {}) {
92
101
  if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
93
102
  if (!catalogId) return save({ ...old, stage: "unsupported", checkedAt: now });
94
103
  try {
95
- const metadata = await request(CATALOG, { limit: 256 * 1024, timeout: timeoutMs, kind: "catalog" });
104
+ const metadata = await request(CATALOG, { limit: CLIENT_UPDATE_CATALOG_LIMIT, timeout: timeoutMs, kind: "catalog" });
96
105
  let catalog;
97
106
  try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
98
- const artifact = validateCatalog(catalog, catalogId, mac);
107
+ const verified = verifyClientUpdateCatalog(catalog, options.catalogKeys);
108
+ const artifact = validateClientUpdateCatalogEntry(verified, catalogId, mac);
99
109
  // The catalog has no build for this client: there is nothing to install.
100
110
  if (!artifact) {
101
111
  // A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
@@ -144,7 +154,7 @@ export function createClientUpdateManager(options = {}) {
144
154
  if (!bytes) fail("source_unavailable");
145
155
  const release = releasePath(artifact);
146
156
  await safeDirectory(path.dirname(release));
147
- const stage = path.join(root, "releases", `.stage-${crypto.randomUUID()}`);
157
+ const stage = path.join(releases, `.stage-${crypto.randomUUID()}`);
148
158
  await fs.mkdir(stage, { mode: 0o700 });
149
159
  try {
150
160
  const archivePath = path.join(stage, artifact.filename);
@@ -157,7 +167,7 @@ export function createClientUpdateManager(options = {}) {
157
167
  return;
158
168
  } catch { /* Preserve damaged evidence without reusing it. */ }
159
169
  await safeAncestors(release, true);
160
- const quarantine = path.join(root, "releases", `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
170
+ const quarantine = path.join(releases, `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
161
171
  await fs.rename(release, quarantine);
162
172
  try { await fs.rename(stage, release); }
163
173
  catch (replacement) { await fs.rename(quarantine, release).catch(() => {}); throw replacement; }
@@ -182,7 +192,7 @@ export function createClientUpdateManager(options = {}) {
182
192
  });
183
193
  }
184
194
 
185
- // After an install only the package state.json names stays cached; older
195
+ // After an install only the package the signed state names stays cached; older
186
196
  // versions and quarantined copies go. Holding the check lock means no
187
197
  // download is in flight to be mistaken for leftovers.
188
198
  async function prune() {
@@ -190,7 +200,7 @@ export function createClientUpdateManager(options = {}) {
190
200
  return withNeutralHostStoreLock(root, "client-update", async () => {
191
201
  const state = await readState();
192
202
  if (!semver(state.version) || !HASH.test(String(state.sha256 || ""))) return [];
193
- const keep = path.basename(releasePath(state)), releases = path.join(root, "releases"), removed = [];
203
+ const keep = path.basename(releasePath(state)), removed = [];
194
204
  await safeAncestors(releases, true);
195
205
  let entries;
196
206
  try { entries = await fs.readdir(releases, { withFileTypes: true }); }
@@ -277,13 +287,33 @@ export function createClientUpdateManager(options = {}) {
277
287
  });
278
288
  }
279
289
 
280
- function validateCatalog(catalog, client, mac) {
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) {
281
311
  if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
282
312
  const matches = catalog.clients.filter((item) => item?.client === client);
283
313
  if (matches.length === 0) return null;
284
314
  if (matches.length > 1) fail("catalog_invalid");
285
315
  const item = matches[0];
286
- if (!semver(item.version) || (mac && !MAC_CODEX_VERSION.test(item.version))
316
+ if (!semver(item.version) || (mac && !MAC_CLIENT_VERSION.test(item.version))
287
317
  || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
288
318
  || !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
289
319
  || !Array.isArray(item.platforms) || !item.platforms.includes(mac ? "macOS" : "Windows")) fail("catalog_invalid");
@@ -12,13 +12,13 @@ function unique(records, missing, ambiguous) {
12
12
  if (records.length !== 1) fail(records.length ? ambiguous : missing);
13
13
  return records[0];
14
14
  }
15
- function checkBindingPaths(b) {
15
+ function checkBindingPaths(b, code) {
16
16
  try {
17
17
  checkDarwinPath(b.runtimeRoot, { directory: true });
18
18
  checkDarwinPath(b.releaseRoot, { directory: true });
19
19
  if (checkDarwinPath(b.profileManifestPath).size > 65536 ||
20
- checkDarwinPath(join(b.releaseRoot, b.runnerRelativePath)).size > 1048576) fail("neutral_codex_launch_manifest_invalid");
21
- } catch { fail("neutral_codex_launch_manifest_invalid"); }
20
+ checkDarwinPath(join(b.releaseRoot, b.runnerRelativePath)).size > 1048576) fail(code);
21
+ } catch { fail(code); }
22
22
  }
23
23
  function capMatches(cap, b) {
24
24
  return cap.state === "active" && fieldsMatch(cap, b,
@@ -38,51 +38,60 @@ function revokeCapability(s, r) {
38
38
  }
39
39
  return { capabilityId: r.capabilityId, revokedAt: r.revokedAt, state: "revoked" };
40
40
  }
41
- function codexBinding(s, command, input, oldId) {
42
- const b = s.codex;
43
- if (command === "ResolveCodexNeutralBinding" || command === "VerifyCodexNeutralBinding") {
44
- if (!b) fail("neutral_codex_binding_configuration_required");
45
- checkBindingPaths(b);
46
- const active = s.caps.filter(c => c.adapterId === "codex" && c.state === "active");
41
+ function neutralBinding(s, adapter, action, input, oldId) {
42
+ const codex = adapter === "codex";
43
+ const key = codex ? "codex" : "claudeCode";
44
+ const error = suffix => `neutral_${adapter.replace("-", "_")}_${suffix}`;
45
+ const check = binding => checkBindingPaths(binding, error("launch_manifest_invalid"));
46
+ const b = s[key];
47
+ if (action === "Resolve" || action === "Verify") {
48
+ if (!b) fail(error("binding_configuration_required"));
49
+ // Claude retains its identity across uninstall; Verify can inspect a
50
+ // retained binding whose old release is gone. Resolve always needs it.
51
+ if (codex || action === "Resolve") check(b);
52
+ const active = s.caps.filter(c => c.adapterId === adapter && c.state === "active");
47
53
  const cap = unique(active.filter(c => capMatches(c, b)),
48
- active.length ? "neutral_codex_capability_binding_mismatch" : "neutral_codex_capability_missing", "neutral_codex_capability_ambiguous");
49
- if (command.startsWith("Resolve")) return { binding: b, record: cap };
54
+ error(active.length ? "capability_binding_mismatch" : "capability_missing"), error("capability_ambiguous"));
55
+ if (action === "Resolve") return { binding: b, record: cap };
50
56
  const {secret, ...record} = cap;
51
57
  return { binding: b, record };
52
58
  }
53
- if (command === "SaveCodexNeutralBinding" || command === "RemoveCodexNeutralBinding") {
54
- checkBindingPaths(input);
55
- if (command.startsWith("Remove")) {
56
- if (!equal(b, input)) fail("neutral_codex_binding_invalid");
57
- s.codex = null;
59
+ if (action === "Save" || action === "Remove") {
60
+ if (input.adapterId !== adapter) fail(error("binding_invalid"));
61
+ if (action === "Remove") {
62
+ if (codex) check(input);
63
+ if (!equal(b, input)) fail(error("binding_invalid"));
64
+ if (codex) s.codex = null;
65
+ else delete s.claudeCode;
58
66
  return { removed: true };
59
67
  }
60
- if (b && !equal(b, input)) fail("neutral_codex_binding_invalid");
61
- s.codex = input;
68
+ check(input);
69
+ if (b && !equal(b, input)) fail(error("binding_invalid"));
70
+ s[key] = input;
62
71
  return { binding: input };
63
72
  }
64
73
  const { previousBinding: previous, replacementBinding: next, replacementRecord } = input;
65
- checkBindingPaths(previous);
66
- checkBindingPaths(next);
67
- if (!fieldsMatch(previous, next, ["contract","region","principalId","delegateId","hostInstallationId","profileId","adapterId","version","runtimeRoot"])) {
68
- fail("neutral_codex_capability_binding_mismatch");
74
+ if (codex) check(previous);
75
+ check(next);
76
+ if (next.adapterId !== adapter || !fieldsMatch(previous, next, ["contract","region","principalId","delegateId","hostInstallationId","profileId","adapterId","version","runtimeRoot"])) {
77
+ fail(error("capability_binding_mismatch"));
69
78
  }
70
- if (!equal(b, previous)) fail("neutral_codex_binding_invalid");
79
+ if (!equal(b, previous)) fail(error("binding_invalid"));
71
80
  const active = s.caps.filter(c => capMatches(c, next));
72
- if (command === "ReplaceCodexNeutralBinding") {
73
- if (!equal(previous.scopes, next.scopes)) fail("neutral_codex_capability_binding_mismatch");
74
- if (active.length !== 1) fail("neutral_codex_capability_ambiguous");
75
- s.codex = next;
81
+ if (codex && action === "Replace") {
82
+ if (!equal(previous.scopes, next.scopes)) fail(error("capability_binding_mismatch"));
83
+ if (active.length !== 1) fail(error("capability_ambiguous"));
84
+ s[key] = next;
76
85
  return { binding: next };
77
86
  }
78
- if (command !== "MigrateCodexNeutralBinding") fail("host_protected_store_command_invalid");
87
+ if (action !== "Migrate") fail("host_protected_store_command_invalid");
79
88
  const old = s.caps.find(c => c.capabilityId === oldId);
80
- if (!old || !capMatches(old, previous) || !capMatches(replacementRecord, next)) fail("neutral_codex_capability_binding_mismatch");
89
+ if (!old || !capMatches(old, previous) || !capMatches(replacementRecord, next)) fail(error("capability_binding_mismatch"));
81
90
  const other = active.filter(c => c.capabilityId !== oldId);
82
- if (other.length > 1) fail("neutral_codex_capability_ambiguous");
91
+ if (other.length > 1) fail(error("capability_ambiguous"));
83
92
  if (!other.length) addCapability(s, replacementRecord);
84
93
  revokeCapability(s, old);
85
- s.codex = next;
94
+ s[key] = next;
86
95
  return { binding: next, record: other[0] || replacementRecord, replacedCapabilityId: oldId };
87
96
  }
88
97
 
@@ -247,9 +256,13 @@ export function invokeDarwinHost(q) {
247
256
  case "DisconnectLocalClient":
248
257
  for (const cap of s.caps.filter(c => c.adapterId === input.client && c.state === "active")) revokeCapability(s,cap);
249
258
  if (input.client === "codex") s.codex = null;
259
+ if (input.client === "claude-code") delete s.claudeCode;
250
260
  return {disconnected:true,client:input.client};
251
261
  }
252
- if (q.command.endsWith("CodexNeutralBinding")) return codexBinding(s,q.command,input,q.capabilityId);
262
+ const bindingCommand = /^(Resolve|Verify|Save|Remove|Replace|Migrate)(Codex|ClaudeCode)NeutralBinding$/.exec(q.command);
263
+ if (bindingCommand) {
264
+ return neutralBinding(s, bindingCommand[2] === "Codex" ? "codex" : "claude-code", bindingCommand[1], input, q.capabilityId);
265
+ }
253
266
  if (q.command.includes("Transport")) return transport(s,q,input);
254
267
  fail("host_protected_store_command_unsupported");
255
268
  });
@@ -34,7 +34,7 @@ export const HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT = "hellolynk-neutral-host-p
34
34
  export const HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT = "hellolynk-neutral-host-discovery-v1";
35
35
  // Raised with every release batch, together with the six client versions. A Host reports it
36
36
  // in its health; the higher release stays and the lower one makes room.
37
- export const HELLOLYNK_HOST_RELEASE_ORDINAL = 15;
37
+ export const HELLOLYNK_HOST_RELEASE_ORDINAL = 16;
38
38
 
39
39
  const INSTALLATION_LOCK_CONTRACT = "hellolynk-neutral-host-installation-lock-v2";
40
40
  const INSTALLATION_LOCK_MAX_BYTES = 4 * 1024;
@@ -95,9 +95,18 @@ export async function runningReleaseNames(releasesRoot) {
95
95
  export async function entryProcessList(client, releasesRoot, pids = []) {
96
96
  if (!ENTRY[client] && client !== 'codex') return [];
97
97
  if (!Array.isArray(pids) || pids.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw failure('release_retention_process_check_invalid');
98
- if (process.platform === 'darwin' && client === 'codex') {
99
- await rootPath(releasesRoot);
100
- return parseDarwinCodexEntryProcesses(await darwinPs('pid=,ppid=,lstart=,command='), pids);
98
+ if (process.platform === 'darwin') {
99
+ const root = await rootPath(releasesRoot);
100
+ const stdout = await darwinPs('pid=,ppid=,lstart=,command=');
101
+ if (client === 'codex') return parseDarwinCodexEntryProcesses(stdout, pids);
102
+ const entries = [...new Set([root, path.resolve(releasesRoot)])]
103
+ .map(value => path.join(path.dirname(value), ...ENTRY[client]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
104
+ const pattern = new RegExp(`(?:^|["\\s])(?:${entries.join('|')})(?=["\\s]|$)`, 'u');
105
+ const wanted = new Set(pids);
106
+ return [...parseDarwinProcesses(stdout).values()].flatMap(({ pid, startedAt, command }) => {
107
+ const entry = pattern.test(command);
108
+ return entry || wanted.has(pid) ? [{ pid, startedAt, entry }] : [];
109
+ });
101
110
  }
102
111
  if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
103
112
  const root = await rootPath(releasesRoot);
@@ -130,7 +139,7 @@ async function darwinPs(fields) {
130
139
 
131
140
  // Keep command lines inside the process probe. Only a PID, its start time and
132
141
  // whether it might still be serving the Codex cache reach the retention logic.
133
- export function parseDarwinCodexEntryProcesses(stdout, pids = []) {
142
+ function parseDarwinProcesses(stdout) {
134
143
  const processes = new Map();
135
144
  for (const line of stdout.trim().split('\n')) {
136
145
  const fields = /^\s*(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/u.exec(line);
@@ -139,6 +148,11 @@ export function parseDarwinCodexEntryProcesses(stdout, pids = []) {
139
148
  const startedAt = Date.parse(`${started} UTC`);
140
149
  processes.set(Number(pid), { pid: Number(pid), parent: Number(parent), startedAt: Number.isFinite(startedAt) ? startedAt : null, command });
141
150
  }
151
+ return processes;
152
+ }
153
+
154
+ export function parseDarwinCodexEntryProcesses(stdout, pids = []) {
155
+ const processes = parseDarwinProcesses(stdout);
142
156
  const wanted = new Set(pids);
143
157
  const proxy = /(?:^|["\s/])(?:\.\/)?server\/mcp-proxy\.mjs(?=["\s]|$)/u;
144
158
  const codexApp = /(?:^|["/])(?:Codex|ChatGPT)\.app\/Contents\//iu;
@@ -198,19 +212,28 @@ async function entryReleases(releasesRoot, root, client, pattern, dependencies)
198
212
  // live processes can delete its executable before the next client startup.
199
213
  export async function configuredReleaseNames(releasesRoot) {
200
214
  if (process.platform === 'darwin') {
201
- const pattern = darwinReleasePattern(await rootPath(releasesRoot), releasesRoot);
215
+ const root = await rootPath(releasesRoot);
216
+ const pattern = darwinReleasePattern(root, releasesRoot);
202
217
  const names = new Set();
218
+ const readAgent = async file => {
219
+ const { stdout } = await execute('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file],
220
+ { timeout: 15000, maxBuffer: 256 * 1024 });
221
+ const { Program, WorkingDirectory, ProgramArguments } = JSON.parse(stdout);
222
+ for (const value of [Program, WorkingDirectory, ...(ProgramArguments || [])]) {
223
+ if (typeof value === 'string') for (const name of darwinReleaseNames(value, pattern)) names.add(name);
224
+ }
225
+ };
226
+ // A receiver set to start on demand keeps its definition outside LaunchAgents.
227
+ const definition = path.join(path.dirname(root), 'host', 'claude-native-service.plist');
228
+ if (await fs.lstat(definition).catch(error => { if (error.code === 'ENOENT') return null; throw error; })) {
229
+ await readAgent(definition);
230
+ }
203
231
  for (const directory of [path.join(homedir(), 'Library', 'LaunchAgents'), '/Library/LaunchAgents', '/Library/LaunchDaemons']) {
204
232
  let entries;
205
233
  try { entries = await fs.readdir(directory); }
206
234
  catch (error) { if (error.code === 'ENOENT') continue; throw error; }
207
235
  for (const entry of entries.filter(name => /hellolynk/i.test(name) && name.endsWith('.plist'))) {
208
- const { stdout } = await execute('/usr/bin/plutil', ['-convert', 'json', '-o', '-', path.join(directory, entry)],
209
- { timeout: 15000, maxBuffer: 256 * 1024 });
210
- const { Program, WorkingDirectory, ProgramArguments } = JSON.parse(stdout);
211
- for (const value of [Program, WorkingDirectory, ...(ProgramArguments || [])]) {
212
- if (typeof value === 'string') for (const name of darwinReleaseNames(value, pattern)) names.add(name);
213
- }
236
+ await readAgent(path.join(directory, entry));
214
237
  }
215
238
  }
216
239
  return [...names].sort();
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  {
6
6
  "path": "release/package.json",
7
- "sha256": "befbca064334e8ff7f9d192f095ac1bd786a3882385a450fc2c2d2a5c9b2b7dd"
7
+ "sha256": "f786baab2e9f35836813c6b77e1cbc0fb8c0a3c0a2055b2e6ea777f2428cd232"
8
8
  },
9
9
  {
10
10
  "path": "neutral-host-bootstrap.mjs",
@@ -40,19 +40,19 @@
40
40
  },
41
41
  {
42
42
  "path": "core/hellolynk-client-update-entry.mjs",
43
- "sha256": "1e513a4a98437dc60b49f9f39fc3958808c3acfaeb6c5bb638efea2e61d4b1ee"
43
+ "sha256": "d9548eacda8632976e42aafc65f0cef2ec9c5233d2d53b31b02aec0a1702b5eb"
44
44
  },
45
45
  {
46
46
  "path": "core/hellolynk-client-update-runtime.mjs",
47
- "sha256": "96aa12ba21619a0cd78ef61c63be6f43fdb61f892201ce923ee35e0912ae2e52"
47
+ "sha256": "aa233e2cdbdfadb7da6aa489591bc12c4121101b94696f93f6e6cb5d3e7c7870"
48
48
  },
49
49
  {
50
50
  "path": "core/hellolynk-client-update.mjs",
51
- "sha256": "93bf5f2ecc72731d70367306989fa18c82fa7bf9e4b5f785a1376f0d55239428"
51
+ "sha256": "e57f7cc5e98519a014cef7cef68a97bb7e2beb33eb381c38d1751a3967904aaa"
52
52
  },
53
53
  {
54
54
  "path": "core/hellolynk-release-retention.mjs",
55
- "sha256": "7cfd7e20c37ac1479f2472cc5fba8e830dcd1b83b91a86c340749cf145f0d661"
55
+ "sha256": "0ee150af5a9bcc1f636e3ff980ee90ce7d073ab5ed4a991920cd3472bc65529c"
56
56
  },
57
57
  {
58
58
  "path": "core/hellolynk-contact-request-e2ee.mjs",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  {
66
66
  "path": "core/hellolynk-darwin-host.mjs",
67
- "sha256": "0772d7a23280b18d5bc59383a6a14fd2ecfd9de1ec53b351be0250770e3e2e70"
67
+ "sha256": "ded97c3bc3dd65ca75305f5199a1340dffb987a5f283454d60586df8b6b55c09"
68
68
  },
69
69
  {
70
70
  "path": "core/hellolynk-darwin-vault.mjs",
@@ -196,7 +196,7 @@
196
196
  },
197
197
  {
198
198
  "path": "core/hellolynk-neutral-host-process.mjs",
199
- "sha256": "e744bacf65f7b86cbcf7d654189c0e07e09401083e2b6f9053b17c1c3bacd1b4"
199
+ "sha256": "7b237a1400c604da5decbb29ab3ce556fd1b744d0cfd7d28b5560b217da120b1"
200
200
  },
201
201
  {
202
202
  "path": "core/hellolynk-neutral-host-store-adapters.mjs",
@@ -292,7 +292,7 @@
292
292
  },
293
293
  {
294
294
  "path": "platform/windows/credential-store.ps1",
295
- "sha256": "d00dae4af08205fffa52c4956d1c87d5c3423e8f20982ccfc1b76e3424fe442e"
295
+ "sha256": "8f288c91a1f6ecaf2651baab9bdfc55014c11ea9e165f203ca7a69f4d47d7917"
296
296
  },
297
297
  {
298
298
  "path": "platform/windows/hellolynk-host-protected-store.ps1",
@@ -312,7 +312,7 @@
312
312
  },
313
313
  {
314
314
  "path": "bootstrap/core/hellolynk-darwin-host.mjs",
315
- "sha256": "0772d7a23280b18d5bc59383a6a14fd2ecfd9de1ec53b351be0250770e3e2e70"
315
+ "sha256": "ded97c3bc3dd65ca75305f5199a1340dffb987a5f283454d60586df8b6b55c09"
316
316
  },
317
317
  {
318
318
  "path": "bootstrap/core/hellolynk-darwin-vault.mjs",
@@ -358,25 +358,9 @@
358
358
  "path": "bootstrap/platform/windows/hellolynk-host-protected-store.ps1",
359
359
  "sha256": "24357e8ce54090b4ec68e457f02c9e834a80f3273ef3c03a8c0fa7258797fb49"
360
360
  },
361
- {
362
- "path": "platform/darwin/hellolynk-keychain-helper.swift",
363
- "sha256": "5d933589a1d739217bc164f73ab9afadc9e27cc512e1c00c0be4b38975fb6d5b"
364
- },
365
- {
366
- "path": "platform/darwin/hellolynk-keychain-helper.bin",
367
- "sha256": "f1a782c08229ebc0e8d1704b6390e91c5695e084b4df0a1a6dc043f6b23700ab"
368
- },
369
- {
370
- "path": "bootstrap/platform/darwin/hellolynk-keychain-helper.swift",
371
- "sha256": "5d933589a1d739217bc164f73ab9afadc9e27cc512e1c00c0be4b38975fb6d5b"
372
- },
373
- {
374
- "path": "bootstrap/platform/darwin/hellolynk-keychain-helper.bin",
375
- "sha256": "f1a782c08229ebc0e8d1704b6390e91c5695e084b4df0a1a6dc043f6b23700ab"
376
- },
377
361
  {
378
362
  "path": "release/server/codex-neutral-production.mjs",
379
- "sha256": "2e09f95b8c15ac307d3be0e5b29fecd00e5eae95fd816e885c51484990e703cb"
363
+ "sha256": "e1106264be25cd79fc12f1b774726089eead0f7e237aff231cd739f40e0c1f40"
380
364
  },
381
365
  {
382
366
  "path": "release/server/neutral-host-adapter.mjs",
@@ -384,7 +368,7 @@
384
368
  },
385
369
  {
386
370
  "path": "release/server/workbench-runtime.mjs",
387
- "sha256": "99e069a72a0be3e73a993b2092d55470fcb481eb381841a156b2756415a0a0a9"
371
+ "sha256": "1136c7ea50cc73065fe04094c5b5c19cb676c40a0a189979015766a9c4e908d8"
388
372
  }
389
373
  ]
390
374
  }
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "contract": "hellolynk-neutral-host-release-pin-v1",
3
- "manifestSha256": "2cc4af5084d836c44b30aa3f747ab0562980991edf78aba149aaded974d32b3c"
3
+ "manifestSha256": "7aff02867e6f806b2d20c060e4d6ba50c55ad9b23448e65e74a15aabcc99a4d5"
4
4
  }