@hellolynk-ai/codex-plugin 0.2.100 → 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.
- package/README.md +17 -17
- package/bin/hellolynk-client-setup.mjs +79 -11
- package/hellolynk-client-pack-manifest.json +111 -71
- package/hellolynk-client-pack-pin.json +1 -1
- package/package.json +8 -4
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/hellolynk-codex/.mcp.json +20 -19
- package/plugins/hellolynk-codex/package.json +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +25 -2
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-host.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-vault.mjs +153 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +20 -8
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +3 -7
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +68 -14
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.bin +0 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.swift +149 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +5 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +25 -2
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +7 -3
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +24 -8
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +23 -9
- package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-host.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-vault.mjs +153 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +20 -8
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +53 -6
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +22 -7
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +3 -7
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +9 -3
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +68 -14
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +116 -19
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +5 -3
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +59 -27
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
- package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.bin +0 -0
- package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.swift +149 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +17 -9
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -1193
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -33
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +42 -42
- package/tools/hellolynk-activate-neutral-host-clients.mjs +5 -1
package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.swift
ADDED
|
@@ -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(
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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,
|
|
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)
|
|
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
|
|
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
|
|
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 (
|
|
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,
|
|
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) || !
|
|
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
|
-
|
|
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
|
}
|