@hellolynk-ai/codex-plugin 0.2.101 → 0.2.102-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 (46) hide show
  1. package/README.md +17 -17
  2. package/bin/hellolynk-client-setup.mjs +72 -11
  3. package/hellolynk-client-pack-manifest.json +109 -69
  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-client-profile.mjs +25 -2
  11. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-host.mjs +256 -0
  12. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-vault.mjs +153 -0
  13. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +20 -8
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +3 -7
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +68 -14
  16. package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.bin +0 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.swift +149 -0
  18. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +5 -1
  19. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +1 -1
  20. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +25 -2
  21. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +7 -3
  22. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +24 -8
  23. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +23 -9
  24. package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-host.mjs +256 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-vault.mjs +153 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +20 -8
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +53 -6
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +22 -7
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +3 -7
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +9 -3
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +68 -14
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +78 -2
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +5 -3
  36. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +1 -1
  37. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +1 -1
  38. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +58 -26
  39. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
  40. package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.bin +0 -0
  41. package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.swift +149 -0
  42. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +15 -9
  43. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -1193
  44. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -33
  45. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +42 -42
  46. package/tools/hellolynk-activate-neutral-host-clients.mjs +5 -1
@@ -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);
@@ -1,4 +1,5 @@
1
1
  import { lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
2
+ import { readdirSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
5
  import { randomUUID } from "node:crypto";
@@ -19,12 +20,34 @@ const SAFE_SCOPE = /^[a-z][a-z0-9_.:-]{0,127}$/;
19
20
  const MAX_PROFILE_BYTES = 64 * 1024;
20
21
 
21
22
  export function defaultHelloLynkClientRoot(platform = process.platform, env = process.env) {
22
- const home = resolve(String(env.HOME || env.USERPROFILE || homedir()));
23
- if (platform === "win32") return resolve(String(env.LOCALAPPDATA || join(home, "AppData", "Local")), "HelloLynk");
23
+ const home = resolve(String((platform === "win32" ? env.USERPROFILE || env.HOME : env.HOME || env.USERPROFILE) || homedir()));
24
+ if (platform === "win32") {
25
+ const current = join(home, ".hellolynk", "hellolynk-v2");
26
+ // Once selected, another client's older installer must not move us back.
27
+ if (hasWindowsClientInstallation(current)) return current;
28
+ const existing = resolve(String(env.LOCALAPPDATA || join(home, "AppData", "Local")), "HelloLynk");
29
+ // AppData created from a packaged desktop app can be private to that app.
30
+ // Keep an existing installation; an empty failed setup is not an account.
31
+ if (hasWindowsClientInstallation(existing)) return existing;
32
+ return current;
33
+ }
24
34
  if (platform === "darwin") return join(home, "Library", "Application Support", "HelloLynk");
25
35
  return resolve(String(env.XDG_DATA_HOME || join(home, ".local", "share")), "hellolynk");
26
36
  }
27
37
 
38
+ function hasWindowsClientInstallation(root) {
39
+ for (const directory of ["profiles", "releases", "host", "credentials", "e2ee-private-keys"]) {
40
+ try {
41
+ const entries = readdirSync(join(root, directory), { withFileTypes: true });
42
+ if (entries.some((entry) => directory !== "host" || entry.name !== "runtime")) return true;
43
+ } catch (error) {
44
+ // An unreadable existing location must not silently become a fresh account.
45
+ if (error.code !== "ENOENT") return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+
28
51
  function safeId(value, code) {
29
52
  const normalized = String(value || "").trim();
30
53
  if (!SAFE_ID.test(normalized)) throw new Error(code);
@@ -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_CODEX_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 = catalogId === "codex-macos";
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_CODEX_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, MAC_CODEX_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(); }
@@ -95,7 +96,7 @@ function createRuntime(config, options) {
95
96
  const { root, client, currentVersion, installContext } = config;
96
97
  const directory = path.join(root, "client-updates", client);
97
98
  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 });
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);
@@ -409,7 +410,7 @@ async function prepareCandidate(config, record) {
409
410
  // extractedRoot or an executable path supplied by state.json.
410
411
  const sealed = path.join(temporaryRoot, record.filename);
411
412
  await fs.writeFile(sealed, archive, { flag: "wx", mode: 0o600 });
412
- await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot });
413
+ await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot, platform: config.platform });
413
414
  let packageRoot;
414
415
  if (config.client === "dsh") {
415
416
  const descriptor = await readJson(path.join(extractedRoot, "trusted-release.json"));
@@ -421,7 +422,8 @@ async function prepareCandidate(config, record) {
421
422
  packageRoot = path.join(extractedRoot, "dist", "package", "runtime", "connect");
422
423
  await safeFile(path.join(extractedRoot, "scripts", "install.mjs"));
423
424
  } else {
424
- const candidates = [extractedRoot, path.join(extractedRoot, "package"), path.join(extractedRoot, `hellolynk-${config.client}-${record.version}`, "package")];
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")];
425
427
  const matches = [];
426
428
  for (const candidate of candidates) if (await exists(path.join(candidate, "package.json"))) matches.push(candidate);
427
429
  if (matches.length !== 1) throw failure("package_identity_invalid");
@@ -438,11 +440,18 @@ async function verifyPackage(root, client, version) {
438
440
  const pin = await readJson(path.join(root, "hellolynk-client-pack-pin.json"));
439
441
  const manifest = JSON.parse(manifestBytes.toString("utf8"));
440
442
  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"))
441
444
  || manifest.contract !== "hellolynk-client-npm-pack-manifest-v1" || manifest.client !== client
442
445
  || manifest.packageName !== pkg.name || manifest.version !== version
443
446
  || pin.contract !== "hellolynk-client-npm-pack-pin-v1" || pin.manifestSha256 !== sha(manifestBytes)) throw failure("package_identity_invalid");
444
447
  await verifyRecords(root, manifest.files, ["hellolynk-client-pack-manifest.json", "hellolynk-client-pack-pin.json"]);
445
448
  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");
453
+ }
454
+ }
446
455
  }
447
456
  async function verifyRecords(root, records, allowedExtra) {
448
457
  if (!Array.isArray(records) || records.length < 1 || records.length > 5000) throw failure("package_pin_invalid");
@@ -471,7 +480,9 @@ async function verifyInstalledContext(config) {
471
480
  await verifyPackage(context.packageRoot, config.client, config.currentVersion);
472
481
  }
473
482
  function normalize(value) {
483
+ const platform = value.platform || process.platform;
474
484
  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)))
475
486
  || !path.isAbsolute(String(value.root || "")) || !path.isAbsolute(String(value.installContext?.packageRoot || ""))) throw failure("configuration_invalid");
476
487
  const context = { packageRoot: path.resolve(value.installContext.packageRoot) };
477
488
  for (const name of ["nodePath", "dshProfile", "dshRoot"]) {
@@ -484,7 +495,7 @@ function normalize(value) {
484
495
  if (!HASH.test(value.installContext.expectedInstalledManifestSha256)) throw failure("configuration_invalid");
485
496
  context.expectedInstalledManifestSha256 = value.installContext.expectedInstalledManifestSha256;
486
497
  }
487
- return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, installContext: context };
498
+ return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, platform, installContext: context };
488
499
  }
489
500
  async function launchWorker(config) {
490
501
  // A host may execute this module from an ephemeral captured import graph.
@@ -510,8 +521,9 @@ async function launchWorker(config) {
510
521
  async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
511
522
  executable = await runtimeSafety.resolveHelloLynkStandaloneNode({ preferredExecutable: executable });
512
523
  const env = { ...runtimeSafety.standaloneHelloLynkNodeEnvironment(process.env), HELLOLYNK_NEUTRAL_STORE_ROOT: root };
524
+ const mac = process.platform === "darwin";
513
525
  return new Promise((resolve, reject) => {
514
- const child = spawn(executable, args, { cwd, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
526
+ const child = spawn(executable, args, { cwd, env, detached: mac, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
515
527
  let stdout = "", stderr = "", finished = false, terminalError = null;
516
528
  const timer = setTimeout(() => terminate(failure("installer_timeout")), timeoutMs);
517
529
  function finish(error, value) { if (finished) return; finished = true; clearTimeout(timer); error ? reject(error) : resolve(value); }
@@ -529,6 +541,9 @@ async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
529
541
  killer.on("error", () => { clearTimeout(killTimer); if (!finished) killChild(); });
530
542
  killer.once("close", () => { clearTimeout(killTimer); if (!finished) killChild(); });
531
543
  } catch { killChild(); }
544
+ } else if (mac && child.pid) {
545
+ // The installer owns this process group, including a synchronous Codex CLI.
546
+ try { process.kill(-child.pid, "SIGKILL"); } catch { killChild(); }
532
547
  } else killChild();
533
548
  }
534
549
  child.on("error", () => {
@@ -580,7 +595,8 @@ function damaged(error) { return error instanceof SyntaxError || error?.code ===
580
595
  // Windows paths compare case-insensitively and may differ by a link or short name.
581
596
  async function samePath(left, right) {
582
597
  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();
598
+ const canonical = (value) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
599
+ const same = (a, b) => canonical(a) === canonical(b);
584
600
  if (same(left, right)) return true;
585
601
  try { return same(await fs.realpath(left), await fs.realpath(right)); } catch { return false; }
586
602
  }
@@ -6,6 +6,7 @@ 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
10
  const HOUR = 3_600_000;
10
11
  const MAX_ARTIFACT = 64 * 1024 * 1024;
11
12
  const HASH = /^[a-f0-9]{64}$/;
@@ -13,11 +14,20 @@ const CONTRACT = "hellolynk-client-update-v1";
13
14
  // A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
14
15
  const BLOCKING = /^client_update_(?:same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
15
16
 
17
+ export function clientUpdateCatalogId(client, platform) {
18
+ if (platform === "win32") return client;
19
+ if (platform === "darwin" && client === "codex") return "codex-macos";
20
+ return null;
21
+ }
22
+
16
23
  // This component only stages verified files. Host lifecycle and client-native
17
24
  // installation remain the responsibility of the caller.
18
25
  export function createClientUpdateManager(options = {}) {
19
26
  const { client, currentVersion, platform = process.platform } = options;
20
27
  if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
28
+ const catalogId = clientUpdateCatalogId(client, platform);
29
+ const mac = catalogId === "codex-macos";
30
+ if (mac && !MAC_CODEX_VERSION.test(currentVersion)) fail("configuration_invalid");
21
31
  const root = path.join(path.resolve(options.root), "client-updates", client);
22
32
  const stateFile = path.join(root, "state.json");
23
33
  const fetcher = options.fetchImpl || globalThis.fetch;
@@ -80,12 +90,12 @@ export function createClientUpdateManager(options = {}) {
80
90
  let old = await readState();
81
91
  const now = clock();
82
92
  if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
83
- if (platform !== "win32") return save({ ...old, stage: "unsupported", checkedAt: now });
93
+ if (!catalogId) return save({ ...old, stage: "unsupported", checkedAt: now });
84
94
  try {
85
95
  const metadata = await request(CATALOG, { limit: 256 * 1024, timeout: timeoutMs, kind: "catalog" });
86
96
  let catalog;
87
97
  try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
88
- const artifact = validateCatalog(catalog, client);
98
+ const artifact = validateCatalog(catalog, catalogId, mac);
89
99
  // The catalog has no build for this client: there is nothing to install.
90
100
  if (!artifact) {
91
101
  // A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
@@ -139,7 +149,7 @@ export function createClientUpdateManager(options = {}) {
139
149
  try {
140
150
  const archivePath = path.join(stage, artifact.filename);
141
151
  await fs.writeFile(archivePath, bytes, { flag: "wx", mode: 0o600 });
142
- await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted") });
152
+ await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted"), platform });
143
153
  await fs.rename(stage, release).catch(async (error) => {
144
154
  if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error;
145
155
  try {
@@ -267,15 +277,16 @@ export function createClientUpdateManager(options = {}) {
267
277
  });
268
278
  }
269
279
 
270
- function validateCatalog(catalog, client) {
280
+ function validateCatalog(catalog, client, mac) {
271
281
  if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
272
282
  const matches = catalog.clients.filter((item) => item?.client === client);
273
283
  if (matches.length === 0) return null;
274
284
  if (matches.length > 1) fail("catalog_invalid");
275
285
  const item = matches[0];
276
- if (!semver(item.version) || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
286
+ if (!semver(item.version) || (mac && !MAC_CODEX_VERSION.test(item.version))
287
+ || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
277
288
  || !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
278
- || !Array.isArray(item.platforms) || !item.platforms.includes("Windows")) fail("catalog_invalid");
289
+ || !Array.isArray(item.platforms) || !item.platforms.includes(mac ? "macOS" : "Windows")) fail("catalog_invalid");
279
290
  const official = new URL(String(item.url || ""), CATALOG);
280
291
  if (official.origin !== "https://hellolynk.com" || !official.pathname.startsWith("/downloads/")) fail("catalog_invalid");
281
292
  validateUrl(official, "artifact");
@@ -309,7 +320,7 @@ function validateUrl(url, kind, redirected = false, initial = url) {
309
320
  if (!direct && !githubCdn) fail("url_invalid");
310
321
  }
311
322
 
312
- export async function extractClientUpdateArchive({ archivePath, destination, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
323
+ export async function extractClientUpdateArchive({ archivePath, destination, platform = process.platform, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
313
324
  if (!path.isAbsolute(archivePath) || !path.isAbsolute(destination)) fail("archive_path_invalid");
314
325
  await safeAncestors(archivePath, false);
315
326
  const stat = await fs.lstat(archivePath);
@@ -360,7 +371,7 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
360
371
  total += size;
361
372
  if (total > maxTotalBytes) fail("archive_too_large");
362
373
  spans.push([local, start + packed]);
363
- entries.push({ segments, directory, start, packed, size, method, crc });
374
+ entries.push({ segments, directory, start, packed, size, method, crc, mode });
364
375
  cursor += 46 + nameLength + extraLength + commentLength;
365
376
  }
366
377
  if (cursor !== eocd) fail("archive_invalid");
@@ -377,7 +388,10 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
377
388
  try { body = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.size) }); }
378
389
  catch { fail("archive_integrity_invalid"); }
379
390
  if (body.length !== entry.size || crc32(body) !== entry.crc) fail("archive_integrity_invalid");
380
- await fs.writeFile(target, body, { flag: "wx", mode: 0o600 });
391
+ // A Mac update runs a bundled Keychain helper. Keep its execute bit without
392
+ // inheriting group/other access or special permission bits from the ZIP.
393
+ const ownerExecute = platform === "darwin" ? entry.mode & 0o100 : 0;
394
+ await fs.writeFile(target, body, { flag: "wx", mode: 0o600 | ownerExecute });
381
395
  }
382
396
  return Object.freeze({ files: entries.filter((item) => !item.directory).length, bytes: total });
383
397
  }