@hellolynk-ai/codex-plugin 0.2.102 → 0.2.103-macos.1

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 (42) hide show
  1. package/README.md +17 -17
  2. package/bin/hellolynk-client-setup.mjs +70 -10
  3. package/hellolynk-client-pack-manifest.json +101 -61
  4. package/hellolynk-client-pack-pin.json +1 -1
  5. package/package.json +8 -4
  6. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +1 -1
  7. package/plugins/hellolynk-codex/.mcp.json +20 -19
  8. package/plugins/hellolynk-codex/package.json +1 -1
  9. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +1 -1
  10. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-host.mjs +269 -0
  11. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-vault.mjs +153 -0
  12. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +20 -8
  13. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +1 -1
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +68 -14
  15. package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.bin +0 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.swift +149 -0
  17. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +5 -1
  18. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +1 -1
  19. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +7 -3
  20. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +62 -33
  21. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +62 -18
  22. package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-host.mjs +269 -0
  23. package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-vault.mjs +153 -0
  24. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +20 -8
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +53 -6
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +22 -7
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +1 -1
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +9 -3
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +68 -14
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +101 -2
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +5 -3
  34. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +1 -1
  35. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +1 -1
  36. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +56 -24
  37. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
  38. package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.bin +0 -0
  39. package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.swift +149 -0
  40. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +46 -10
  41. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +14 -8
  42. package/tools/hellolynk-activate-neutral-host-clients.mjs +5 -1
@@ -1,3 +1,4 @@
1
+ import { checkDarwinPath, DARWIN_HELPER, invokeDarwinHelper, withDarwinVault } from "./hellolynk-darwin-vault.mjs";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { spawn as nodeSpawn } from "node:child_process";
3
4
  import { randomBytes } from "node:crypto";
@@ -260,6 +261,14 @@ export async function runWindowsClientTrustCommand(command, options = {}) {
260
261
  }
261
262
 
262
263
  export async function prepareWindowsClientRoot(clientRoot, options = {}) {
264
+ if ((options.platform || process.platform) === "darwin") {
265
+ checkDarwinPath(clientRoot, { directory: true, create: true, privateMode: true });
266
+ for (const sub of ["profiles", "credentials", "e2ee-private-keys", "status", "host", "host/runtime", "releases"]) {
267
+ checkDarwinPath(join(clientRoot, sub), { directory: true, create: true, privateMode: true });
268
+ }
269
+ await invokeDarwinHelper(options.helperPath || DARWIN_HELPER, ["verify-acl", clientRoot]);
270
+ return "prepared";
271
+ }
263
272
  return runWindowsClientTrustCommand("PrepareRoot", {
264
273
  ...options,
265
274
  clientRoot,
@@ -272,6 +281,14 @@ export async function secureWindowsClientReleaseTree(clientRoot, releaseId, opti
272
281
  if (!/^(?!\.{1,2}$)[A-Za-z0-9.][A-Za-z0-9_.-]{2,191}$/.test(acceptedReleaseId)) {
273
282
  throw new HelloLynkCredentialStoreError("client_release_path_invalid");
274
283
  }
284
+ if ((options.platform || process.platform) === "darwin") {
285
+ checkDarwinPath(clientRoot, { directory: true, privateMode: true });
286
+ checkDarwinPath(join(clientRoot, "releases"), { directory: true, privateMode: true });
287
+ const release = join(clientRoot, "releases", acceptedReleaseId);
288
+ checkDarwinPath(release, { directory: true });
289
+ await invokeDarwinHelper(options.helperPath || DARWIN_HELPER, [options.verifyOnly ? "verify-tree" : "secure-tree", release]);
290
+ return options.verifyOnly ? "verified" : "secured";
291
+ }
275
292
  return runWindowsClientTrustCommand(options.verifyOnly === true ? "VerifyReleaseTree" : "SecureReleaseTree", {
276
293
  ...options,
277
294
  clientRoot,
@@ -460,24 +477,61 @@ export function createPlatformCredentialStore(options = {}) {
460
477
  }
461
478
 
462
479
  if (platform === "darwin") {
463
- const helper = options.helperPath || join(
464
- homedir(), "Library", "Application Support", "HelloLynk", "bin", "hellolynk-keychain-helper",
465
- );
466
- const invoke = (command, profileId, stdin) => run(
467
- helper,
468
- [command.toLowerCase(), profileId],
469
- { ...runnerOptions, stdin },
470
- );
480
+ const clientRoot = options.clientRoot || (options.storeRoot ? windowsClientRootForStore(options.storeRoot)
481
+ : join(homedir(), "Library", "Application Support", "HelloLynk"));
482
+ if (clientRoot !== resolve(clientRoot) || clientRoot === "/") throw new HelloLynkCredentialStoreError("client_root_path_invalid");
483
+ const credentials = join(clientRoot, "credentials");
484
+ const privateKeys = join(clientRoot, "e2ee-private-keys");
485
+ const root = options.storeRoot || credentials;
486
+ const e2eeRoot = options.e2eeStoreRoot || privateKeys;
487
+ if (root !== clientRoot && root !== credentials) throw new HelloLynkCredentialStoreError("credential_store_root_invalid");
488
+ if (e2eeRoot !== privateKeys) throw new HelloLynkCredentialStoreError("e2ee_private_key_store_root_invalid");
489
+ function entry(location, section, profile, operation, value) {
490
+ const slot = section + ":" + assertCredentialProfileId(profile);
491
+ return withDarwinVault({ root: location, helperPath: options.helperPath, readOnly: operation === "load", allowMissing:true }, data => {
492
+ if (operation === "save") data[slot] = value;
493
+ if (operation === "remove") delete data[slot];
494
+ return data[slot] ?? null;
495
+ });
496
+ }
471
497
  return {
472
498
  platform,
473
- async save(profile, credential) {
474
- await invoke("Store", assertCredentialProfileId(profile), assertCredential(credential));
475
- },
499
+ async save(profile, credential) { await entry(root, "device", profile, "save", assertCredential(credential)); },
476
500
  async load(profile) {
477
- return assertCredential(await invoke("Load", assertCredentialProfileId(profile), ""));
501
+ const value = await entry(root, "device", profile, "load");
502
+ if (value === null) throw new HelloLynkCredentialStoreError("device_credential_missing");
503
+ return assertCredential(value);
478
504
  },
479
- async remove(profile) {
480
- await invoke("Remove", assertCredentialProfileId(profile), "");
505
+ async remove(profile) { await entry(root, "device", profile, "remove"); },
506
+ runtimeSessions: {
507
+ async save(profile, session) { await entry(root, "session", profile, "save", assertRuntimeSession(session)); },
508
+ async load(profile) {
509
+ const value = await entry(root, "session", profile, "load");
510
+ return value === null ? null : assertRuntimeSession(value);
511
+ },
512
+ async remove(profile) { await entry(root, "session", profile, "remove"); },
513
+ async withLock(profile, operation) {
514
+ const id = assertCredentialProfileId(profile);
515
+ if (typeof operation !== "function") throw new HelloLynkCredentialStoreError("runtime_session_lock_operation_invalid");
516
+ checkDarwinPath(root, { directory:true, create:true, privateMode:true });
517
+ return withRuntimeSessionFileLock(root, id, operation, options.runtimeSessionLockDependencies, options.timeoutMs);
518
+ },
519
+ },
520
+ directTextPrivateKeys: {
521
+ async save(profile, material) { await entry(e2eeRoot, "keys", profile, "save", encodeDirectTextPrivateKeyMaterial(material)); },
522
+ async load(profile) {
523
+ const value = await entry(e2eeRoot, "keys", profile, "load");
524
+ return value === null ? null : decodeDirectTextPrivateKeyMaterial(value);
525
+ },
526
+ async remove(profile) { await entry(e2eeRoot, "keys", profile, "remove"); },
527
+ async preload(profiles) {
528
+ const ids = [...new Set((Array.isArray(profiles) ? profiles : []).map(assertCredentialProfileId))];
529
+ if (!ids.length) return;
530
+ if (ids.length > MAX_E2EE_BATCH_PROFILES) throw new HelloLynkCredentialStoreError("e2ee_private_key_batch_invalid");
531
+ await withDarwinVault({root:e2eeRoot, helperPath:options.helperPath, readOnly:true, allowMissing:true}, data => {
532
+ for (const id of ids) if (data["keys:" + id] !== undefined) decodeDirectTextPrivateKeyMaterial(data["keys:" + id]);
533
+ });
534
+ },
481
535
  },
482
536
  };
483
537
  }
@@ -0,0 +1,149 @@
1
+ // Rebuild in this directory: xcrun swiftc -O -target arm64-apple-macos13.0 -module-name HelloLynkKeychain hellolynk-keychain-helper.swift -o hellolynk-keychain-helper.bin
2
+ import AppKit
3
+ import CryptoKit
4
+ import Foundation
5
+ import Security
6
+
7
+ func fail(_ code: String) -> Never {
8
+ FileHandle.standardError.write(Data((code + "\n").utf8))
9
+ exit(1)
10
+ }
11
+
12
+ func requireNoGrantACL(_ path: String) {
13
+ guard let acl = acl_get_file(path, ACL_TYPE_EXTENDED) else {
14
+ if errno == ENOENT { return } // A first write creates the file with mode 0600.
15
+ fail("macos_vault_acl_unavailable")
16
+ }
17
+ defer { acl_free(UnsafeMutableRawPointer(acl)) }
18
+ var entry: acl_entry_t?
19
+ var selector = ACL_FIRST_ENTRY
20
+ while acl_get_entry(acl, Int32(selector.rawValue), &entry) == 0 {
21
+ var tag = ACL_UNDEFINED_TAG
22
+ guard acl_get_tag_type(entry, &tag) == 0, tag == ACL_EXTENDED_DENY else {
23
+ fail("macos_vault_acl_invalid")
24
+ }
25
+ selector = ACL_NEXT_ENTRY
26
+ }
27
+ }
28
+
29
+ // The caller verifies the release path's ancestors and package hashes. Walk
30
+ // with lstat so a symlink or hard link cannot redirect a permission change.
31
+ func secureRelease(_ path: String, verifyOnly: Bool) {
32
+ var pending = [path]
33
+ while let entry = pending.popLast() {
34
+ var st = stat()
35
+ guard lstat(entry, &st) == 0, st.st_uid == getuid() else { fail("macos_vault_release_invalid") }
36
+ let type = st.st_mode & S_IFMT
37
+ guard type == S_IFDIR || (type == S_IFREG && st.st_nlink == 1) else { fail("macos_vault_release_invalid") }
38
+ let mode: mode_t = type == S_IFDIR || entry.hasSuffix(".bin") ? 0o700 : 0o600
39
+ if (st.st_mode & 0o7777) != mode {
40
+ guard !verifyOnly, chmod(entry, mode) == 0 else { fail("macos_vault_release_permissions_invalid") }
41
+ }
42
+ requireNoGrantACL(entry)
43
+ if type == S_IFDIR {
44
+ guard let names = try? FileManager.default.contentsOfDirectory(atPath: entry) else { fail("macos_vault_release_invalid") }
45
+ pending.append(contentsOf: names.map { entry + "/" + $0 })
46
+ }
47
+ }
48
+ }
49
+
50
+ func masterKey(_ root: String, create: Bool) -> Data {
51
+ let account = SHA256.hash(data: Data(root.utf8)).map { String(format: "%02x", $0) }.joined()
52
+ let base: [String: Any] = [
53
+ kSecClass as String: kSecClassGenericPassword,
54
+ kSecAttrService as String: "com.hellolynk.codex.macos-vault.v1",
55
+ kSecAttrAccount as String: account
56
+ ]
57
+ var lookup = base
58
+ lookup[kSecReturnData as String] = true
59
+ lookup[kSecMatchLimit as String] = kSecMatchLimitOne
60
+ var result: CFTypeRef?
61
+ let status = SecItemCopyMatching(lookup as CFDictionary, &result)
62
+ if status == errSecSuccess {
63
+ guard let key = result as? Data, key.count == 32 else { fail("macos_vault_key_invalid") }
64
+ return key
65
+ }
66
+ guard status == errSecItemNotFound else { fail("macos_keychain_access_\(abs(Int(status)))") }
67
+ guard create else { fail("macos_vault_key_missing") }
68
+ let key = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
69
+ var add = base
70
+ add[kSecValueData as String] = key
71
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
72
+ let inserted = SecItemAdd(add as CFDictionary, nil)
73
+ if inserted == errSecDuplicateItem { return masterKey(root, create: false) }
74
+ guard inserted == errSecSuccess else { fail("macos_keychain_access_\(abs(Int(inserted)))") }
75
+ return key
76
+ }
77
+
78
+ func nativeConfirmation() {
79
+ let raw = FileHandle.standardInput.readDataToEndOfFile()
80
+ guard raw.count <= 20480 * 6 + 8192,
81
+ var v = (try? JSONSerialization.jsonObject(with: raw)) as? [String: Any],
82
+ let body = v["displayText"] as? String, !body.isEmpty, body.utf8.count <= 20480,
83
+ let encoded = v["hmacKey"] as? String, encoded.count == 43,
84
+ let key = Data(base64Encoded: encoded.replacingOccurrences(of: "-", with: "+")
85
+ .replacingOccurrences(of: "_", with: "/") + "="), key.count == 32,
86
+ let recipient = v["recipientHelloLynkId"] as? String,
87
+ v["contract"] as? String == "hellolynk-native-human-confirmation-v1",
88
+ v["textDigest"] as? String == SHA256.hash(data: Data(body.utf8)).map({ String(format: "%02x", $0) }).joined()
89
+ else { fail("human_confirmation_request_invalid") }
90
+
91
+ var fields = ["contract", "actionId", "decision", "principalId", "delegateId", "hostInstallationId",
92
+ "adapterId", "adapterInstanceId", "hostGeneration", "recipientHelloLynkId"]
93
+ var label = "收件人:" + recipient
94
+ if v["recipientDelegateId"] != nil || v["recipientInstanceDisplayName"] != nil {
95
+ guard let name = v["recipientInstanceDisplayName"] as? String, !name.isEmpty, name.utf8.count <= 512,
96
+ name.range(of: #"[\x00-\x1F\x7F-\x9F\u2028\u2029\u202A-\u202E\u2066-\u2069]"#, options: .regularExpression) == nil,
97
+ v["recipientDelegateId"] is String else { fail("human_confirmation_request_invalid") }
98
+ fields += ["recipientDelegateId", "recipientInstanceDisplayName"]
99
+ label += "\n目标 AI:" + name
100
+ }
101
+ fields += ["clientMessageId", "textDigest", "createdAtMs", "expiresAtMs", "challenge"]
102
+ var expected = Set(fields)
103
+ expected.remove("decision")
104
+ expected.formUnion(["displayText", "hmacKey"])
105
+ guard expected == Set(v.keys) else { fail("human_confirmation_request_invalid") }
106
+ let app = NSApplication.shared
107
+ app.setActivationPolicy(.accessory)
108
+ app.activate(ignoringOtherApps: true)
109
+ let dialog = NSAlert()
110
+ dialog.messageText = "是否允许 HelloLynk 发送以下文本?"
111
+ dialog.informativeText = label
112
+ dialog.addButton(withTitle: "取消")
113
+ dialog.addButton(withTitle: "批准发送")
114
+ let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 500, height: 230))
115
+ scroll.hasVerticalScroller = true
116
+ let view = NSTextView(frame: scroll.contentView.bounds)
117
+ view.isEditable = false
118
+ view.isRichText = false
119
+ view.isVerticallyResizable = true
120
+ view.autoresizingMask = [.width]
121
+ view.textContainer?.widthTracksTextView = true
122
+ view.string = body
123
+ scroll.documentView = view
124
+ dialog.accessoryView = scroll
125
+ v["decision"] = dialog.runModal() == .alertSecondButtonReturn ? "approve" : "cancel"
126
+ let message = fields.map { field -> String in
127
+ if let text = v[field] as? String { return text }
128
+ if ["createdAtMs","expiresAtMs"].contains(field), let n = v[field] as? NSNumber { return n.stringValue }
129
+ fail("human_confirmation_request_invalid")
130
+ }.joined(separator: "\n")
131
+ let proof = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: SymmetricKey(data: key))
132
+ v["proof"] = proof.map { String(format: "%02x", $0) }.joined()
133
+ for field in ["hmacKey", "challenge", "displayText"] { v.removeValue(forKey: field) }
134
+ guard let output = try? JSONSerialization.data(withJSONObject: v) else { fail("human_confirmation_response_invalid") }
135
+ FileHandle.standardOutput.write(output)
136
+ }
137
+
138
+ let args = Array(CommandLine.arguments.dropFirst())
139
+ if args == ["confirm"] { nativeConfirmation() }
140
+ else if args.count == 3, args[0] == "key", args[1].hasPrefix("/"), ["load","create"].contains(args[2]) {
141
+ requireNoGrantACL(args[1])
142
+ requireNoGrantACL(args[1] + "/protected-v1.sqlite")
143
+ let key = masterKey(args[1], create: args[2] == "create")
144
+ FileHandle(fileDescriptor: 3, closeOnDealloc: false).write(key)
145
+ } else if args.count == 2, ["verify-tree", "secure-tree"].contains(args[0]), args[1].hasPrefix("/") {
146
+ secureRelease(args[1], verifyOnly: args[0] == "verify-tree")
147
+ } else if args.count == 2, args[0] == "verify-acl", args[1].hasPrefix("/") {
148
+ requireNoGrantACL(args[1])
149
+ } else { fail("macos_vault_helper_arguments_invalid") }
@@ -5,6 +5,7 @@ import crypto from "node:crypto";
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
 
8
+ import { verifyDarwinUnclaimedAuthorization } from "./core/hellolynk-darwin-host.mjs";
8
9
  import { readClientProfile } from "./core/hellolynk-client-profile.mjs";
9
10
  import { createNeutralHostInstallationStore } from "./core/hellolynk-neutral-host-installation-store.mjs";
10
11
  import { clientAccountBaseRoot } from "./core/hellolynk-client-account.mjs";
@@ -164,7 +165,7 @@ assert.equal(releaseVerification.verifiedFileCount >= 8, true);
164
165
  let transport = null;
165
166
  let bootstrapTransportRequired = false;
166
167
  let protectedStorePresent = true;
167
- try { await fs.lstat(path.join(root, "capabilities-v1.dpapi.clixml")); }
168
+ try { await fs.lstat(path.join(root, process.platform === "darwin" ? "protected-v1.sqlite" : "capabilities-v1.dpapi.clixml")); }
168
169
  catch (error) {
169
170
  if (error?.code !== "ENOENT") throw error;
170
171
  protectedStorePresent = false;
@@ -173,6 +174,9 @@ if (!protectedStorePresent) {
173
174
  if (!(freshPreBind || recoveryPreBind)) await store.status();
174
175
  await verifyHostProtectedStorePath({ root, path: root, directory: true });
175
176
  bootstrapTransportRequired = true;
177
+ } else if (process.platform === "darwin" && freshPreBind) {
178
+ await verifyDarwinUnclaimedAuthorization(root);
179
+ bootstrapTransportRequired = true;
176
180
  } else {
177
181
  assert.equal((await store.status()).protected, true);
178
182
  try { transport = preflightOnly ? await store.verifyTransport() : await store.resolveTransport(); }
@@ -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("win32", options?.env || process.env);
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
- if (process.platform !== "win32" || !path.isAbsolute(String(packageRoot || ""))) return Promise.resolve(null);
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
- if (manifest.hellolynkClient?.client !== client
25
- || !/^\d+\.\d+\.\d+$/.test(manifest.version || "")) return null;
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, CLIENT_UPDATE_CLIENTS } from "./hellolynk-client-update.mjs";
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) !== "win32") return disabled();
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 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; });
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(path.join(directory, "worker.json"), { client,
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(path.join(directory, "worker.json"), { client, pid });
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, "releases", `${record.version}-${record.sha256}`, record.filename);
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.json.
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
- let packageRoot;
414
- if (config.client === "dsh") {
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 same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
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
  }