@hellolynk-ai/codex-plugin 0.2.102-macos.1 → 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.
@@ -6,17 +6,24 @@ import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-
6
6
 
7
7
  const CATALOG = "https://hellolynk.com/downloads/clients.json";
8
8
  export const CLIENT_UPDATE_CLIENTS = Object.freeze(["codex", "openclaw", "trae-work", "workbuddy", "dsh", "claude-code"]);
9
- export const MAC_CODEX_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
9
+ export const MAC_CLIENT_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
10
10
  const HOUR = 3_600_000;
11
11
  const MAX_ARTIFACT = 64 * 1024 * 1024;
12
12
  const HASH = /^[a-f0-9]{64}$/;
13
- const CONTRACT = "hellolynk-client-update-v1";
13
+ const CONTRACT = "hellolynk-client-update-v2";
14
+ export const CLIENT_UPDATE_RELEASES = "releases-v2";
15
+ // The release freeze pins only public Ed25519 keys here. An empty set refuses all updates.
16
+ export const CLIENT_UPDATE_CATALOG_KEYS = Object.freeze({
17
+ "ed25519-20261109-primary-d4fd7f46475dac48": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAucQohi+be+ksjQhRzYD8ZiyZMUz6Vi/DSphQ3iVOWDM=\n-----END PUBLIC KEY-----\n",
18
+ "ed25519-20261109-backup-5e3d08a775320304": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA2vxHpcgLZQc1cXqxq3dcmZRmE0fAFyeRC3umMVGAosA=\n-----END PUBLIC KEY-----\n"
19
+ });
20
+ export const CLIENT_UPDATE_CATALOG_LIMIT = 256 * 1024;
14
21
  // A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
15
- const BLOCKING = /^client_update_(?:same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
22
+ const BLOCKING = /^client_update_(?:catalog_signature_invalid|same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
16
23
 
17
24
  export function clientUpdateCatalogId(client, platform) {
18
25
  if (platform === "win32") return client;
19
- if (platform === "darwin" && client === "codex") return "codex-macos";
26
+ if (platform === "darwin" && (client === "codex" || client === "claude-code")) return `${client}-macos`;
20
27
  return null;
21
28
  }
22
29
 
@@ -26,10 +33,12 @@ export function createClientUpdateManager(options = {}) {
26
33
  const { client, currentVersion, platform = process.platform } = options;
27
34
  if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
28
35
  const catalogId = clientUpdateCatalogId(client, platform);
29
- const mac = catalogId === "codex-macos";
30
- if (mac && !MAC_CODEX_VERSION.test(currentVersion)) fail("configuration_invalid");
36
+ const mac = platform === "darwin";
37
+ if (mac && !MAC_CLIENT_VERSION.test(currentVersion)) fail("configuration_invalid");
31
38
  const root = path.join(path.resolve(options.root), "client-updates", client);
32
- const stateFile = path.join(root, "state.json");
39
+ // A still-running unsigned updater may read, rewrite and prune its own cache.
40
+ const stateFile = path.join(root, "state-v2.json");
41
+ const releases = path.join(root, CLIENT_UPDATE_RELEASES);
33
42
  const fetcher = options.fetchImpl || globalThis.fetch;
34
43
  const clock = options.clock || Date.now;
35
44
  const timeoutMs = bounded(options.timeoutMs, 30_000, 50, 120_000);
@@ -70,7 +79,7 @@ export function createClientUpdateManager(options = {}) {
70
79
  }
71
80
  }
72
81
 
73
- function releasePath(record) { return path.join(root, "releases", `${record.version}-${record.sha256}`); }
82
+ function releasePath(record) { return path.join(releases, `${record.version}-${record.sha256}`); }
74
83
  async function save(record) {
75
84
  await safeDirectory(root);
76
85
  const temporary = path.join(root, `.state-${crypto.randomUUID()}.tmp`);
@@ -92,10 +101,11 @@ export function createClientUpdateManager(options = {}) {
92
101
  if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
93
102
  if (!catalogId) return save({ ...old, stage: "unsupported", checkedAt: now });
94
103
  try {
95
- const metadata = await request(CATALOG, { limit: 256 * 1024, timeout: timeoutMs, kind: "catalog" });
104
+ const metadata = await request(CATALOG, { limit: CLIENT_UPDATE_CATALOG_LIMIT, timeout: timeoutMs, kind: "catalog" });
96
105
  let catalog;
97
106
  try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
98
- const artifact = validateCatalog(catalog, catalogId, mac);
107
+ const verified = verifyClientUpdateCatalog(catalog, options.catalogKeys);
108
+ const artifact = validateClientUpdateCatalogEntry(verified, catalogId, mac);
99
109
  // The catalog has no build for this client: there is nothing to install.
100
110
  if (!artifact) {
101
111
  // A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
@@ -144,7 +154,7 @@ export function createClientUpdateManager(options = {}) {
144
154
  if (!bytes) fail("source_unavailable");
145
155
  const release = releasePath(artifact);
146
156
  await safeDirectory(path.dirname(release));
147
- const stage = path.join(root, "releases", `.stage-${crypto.randomUUID()}`);
157
+ const stage = path.join(releases, `.stage-${crypto.randomUUID()}`);
148
158
  await fs.mkdir(stage, { mode: 0o700 });
149
159
  try {
150
160
  const archivePath = path.join(stage, artifact.filename);
@@ -157,7 +167,7 @@ export function createClientUpdateManager(options = {}) {
157
167
  return;
158
168
  } catch { /* Preserve damaged evidence without reusing it. */ }
159
169
  await safeAncestors(release, true);
160
- const quarantine = path.join(root, "releases", `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
170
+ const quarantine = path.join(releases, `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
161
171
  await fs.rename(release, quarantine);
162
172
  try { await fs.rename(stage, release); }
163
173
  catch (replacement) { await fs.rename(quarantine, release).catch(() => {}); throw replacement; }
@@ -182,7 +192,7 @@ export function createClientUpdateManager(options = {}) {
182
192
  });
183
193
  }
184
194
 
185
- // After an install only the package state.json names stays cached; older
195
+ // After an install only the package the signed state names stays cached; older
186
196
  // versions and quarantined copies go. Holding the check lock means no
187
197
  // download is in flight to be mistaken for leftovers.
188
198
  async function prune() {
@@ -190,7 +200,7 @@ export function createClientUpdateManager(options = {}) {
190
200
  return withNeutralHostStoreLock(root, "client-update", async () => {
191
201
  const state = await readState();
192
202
  if (!semver(state.version) || !HASH.test(String(state.sha256 || ""))) return [];
193
- const keep = path.basename(releasePath(state)), releases = path.join(root, "releases"), removed = [];
203
+ const keep = path.basename(releasePath(state)), removed = [];
194
204
  await safeAncestors(releases, true);
195
205
  let entries;
196
206
  try { entries = await fs.readdir(releases, { withFileTypes: true }); }
@@ -277,13 +287,33 @@ export function createClientUpdateManager(options = {}) {
277
287
  });
278
288
  }
279
289
 
280
- function validateCatalog(catalog, client, mac) {
290
+ export function verifyClientUpdateCatalog(catalog, keys = CLIENT_UPDATE_CATALOG_KEYS) {
291
+ const signed = catalog?.signed;
292
+ if (!signed || typeof signed.keyId !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(signed.keyId)
293
+ || !Object.hasOwn(keys, signed.keyId) || typeof signed.payload !== "string" || !signed.payload
294
+ || signed.payload.length > CLIENT_UPDATE_CATALOG_LIMIT || typeof signed.signature !== "string"
295
+ || !/^[A-Za-z0-9_-]{86}$/.test(signed.signature)) fail("catalog_signature_invalid");
296
+ const payload = Buffer.from(signed.payload, "base64");
297
+ const signature = Buffer.from(signed.signature, "base64url");
298
+ if (payload.toString("base64") !== signed.payload || signature.toString("base64url") !== signed.signature) fail("catalog_signature_invalid");
299
+ let valid = false;
300
+ try {
301
+ const key = crypto.createPublicKey(keys[signed.keyId]);
302
+ valid = key.asymmetricKeyType === "ed25519" && crypto.verify(null, payload, key, signature);
303
+ } catch { /* Invalid pins and invalid signatures both fail closed. */ }
304
+ if (!valid) fail("catalog_signature_invalid");
305
+ const json = payload.toString("utf8");
306
+ if (!Buffer.from(json, "utf8").equals(payload)) fail("catalog_invalid");
307
+ try { return JSON.parse(json); } catch { fail("catalog_invalid"); }
308
+ }
309
+
310
+ export function validateClientUpdateCatalogEntry(catalog, client, mac) {
281
311
  if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
282
312
  const matches = catalog.clients.filter((item) => item?.client === client);
283
313
  if (matches.length === 0) return null;
284
314
  if (matches.length > 1) fail("catalog_invalid");
285
315
  const item = matches[0];
286
- if (!semver(item.version) || (mac && !MAC_CODEX_VERSION.test(item.version))
316
+ if (!semver(item.version) || (mac && !MAC_CLIENT_VERSION.test(item.version))
287
317
  || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
288
318
  || !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
289
319
  || !Array.isArray(item.platforms) || !item.platforms.includes(mac ? "macOS" : "Windows")) fail("catalog_invalid");
@@ -12,13 +12,13 @@ function unique(records, missing, ambiguous) {
12
12
  if (records.length !== 1) fail(records.length ? ambiguous : missing);
13
13
  return records[0];
14
14
  }
15
- function checkBindingPaths(b) {
15
+ function checkBindingPaths(b, code) {
16
16
  try {
17
17
  checkDarwinPath(b.runtimeRoot, { directory: true });
18
18
  checkDarwinPath(b.releaseRoot, { directory: true });
19
19
  if (checkDarwinPath(b.profileManifestPath).size > 65536 ||
20
- checkDarwinPath(join(b.releaseRoot, b.runnerRelativePath)).size > 1048576) fail("neutral_codex_launch_manifest_invalid");
21
- } catch { fail("neutral_codex_launch_manifest_invalid"); }
20
+ checkDarwinPath(join(b.releaseRoot, b.runnerRelativePath)).size > 1048576) fail(code);
21
+ } catch { fail(code); }
22
22
  }
23
23
  function capMatches(cap, b) {
24
24
  return cap.state === "active" && fieldsMatch(cap, b,
@@ -38,51 +38,60 @@ function revokeCapability(s, r) {
38
38
  }
39
39
  return { capabilityId: r.capabilityId, revokedAt: r.revokedAt, state: "revoked" };
40
40
  }
41
- function codexBinding(s, command, input, oldId) {
42
- const b = s.codex;
43
- if (command === "ResolveCodexNeutralBinding" || command === "VerifyCodexNeutralBinding") {
44
- if (!b) fail("neutral_codex_binding_configuration_required");
45
- checkBindingPaths(b);
46
- const active = s.caps.filter(c => c.adapterId === "codex" && c.state === "active");
41
+ function neutralBinding(s, adapter, action, input, oldId) {
42
+ const codex = adapter === "codex";
43
+ const key = codex ? "codex" : "claudeCode";
44
+ const error = suffix => `neutral_${adapter.replace("-", "_")}_${suffix}`;
45
+ const check = binding => checkBindingPaths(binding, error("launch_manifest_invalid"));
46
+ const b = s[key];
47
+ if (action === "Resolve" || action === "Verify") {
48
+ if (!b) fail(error("binding_configuration_required"));
49
+ // Claude retains its identity across uninstall; Verify can inspect a
50
+ // retained binding whose old release is gone. Resolve always needs it.
51
+ if (codex || action === "Resolve") check(b);
52
+ const active = s.caps.filter(c => c.adapterId === adapter && c.state === "active");
47
53
  const cap = unique(active.filter(c => capMatches(c, b)),
48
- active.length ? "neutral_codex_capability_binding_mismatch" : "neutral_codex_capability_missing", "neutral_codex_capability_ambiguous");
49
- if (command.startsWith("Resolve")) return { binding: b, record: cap };
54
+ error(active.length ? "capability_binding_mismatch" : "capability_missing"), error("capability_ambiguous"));
55
+ if (action === "Resolve") return { binding: b, record: cap };
50
56
  const {secret, ...record} = cap;
51
57
  return { binding: b, record };
52
58
  }
53
- if (command === "SaveCodexNeutralBinding" || command === "RemoveCodexNeutralBinding") {
54
- checkBindingPaths(input);
55
- if (command.startsWith("Remove")) {
56
- if (!equal(b, input)) fail("neutral_codex_binding_invalid");
57
- s.codex = null;
59
+ if (action === "Save" || action === "Remove") {
60
+ if (input.adapterId !== adapter) fail(error("binding_invalid"));
61
+ if (action === "Remove") {
62
+ if (codex) check(input);
63
+ if (!equal(b, input)) fail(error("binding_invalid"));
64
+ if (codex) s.codex = null;
65
+ else delete s.claudeCode;
58
66
  return { removed: true };
59
67
  }
60
- if (b && !equal(b, input)) fail("neutral_codex_binding_invalid");
61
- s.codex = input;
68
+ check(input);
69
+ if (b && !equal(b, input)) fail(error("binding_invalid"));
70
+ s[key] = input;
62
71
  return { binding: input };
63
72
  }
64
73
  const { previousBinding: previous, replacementBinding: next, replacementRecord } = input;
65
- checkBindingPaths(previous);
66
- checkBindingPaths(next);
67
- if (!fieldsMatch(previous, next, ["contract","region","principalId","delegateId","hostInstallationId","profileId","adapterId","version","runtimeRoot"])) {
68
- fail("neutral_codex_capability_binding_mismatch");
74
+ if (codex) check(previous);
75
+ check(next);
76
+ if (next.adapterId !== adapter || !fieldsMatch(previous, next, ["contract","region","principalId","delegateId","hostInstallationId","profileId","adapterId","version","runtimeRoot"])) {
77
+ fail(error("capability_binding_mismatch"));
69
78
  }
70
- if (!equal(b, previous)) fail("neutral_codex_binding_invalid");
79
+ if (!equal(b, previous)) fail(error("binding_invalid"));
71
80
  const active = s.caps.filter(c => capMatches(c, next));
72
- if (command === "ReplaceCodexNeutralBinding") {
73
- if (!equal(previous.scopes, next.scopes)) fail("neutral_codex_capability_binding_mismatch");
74
- if (active.length !== 1) fail("neutral_codex_capability_ambiguous");
75
- s.codex = next;
81
+ if (codex && action === "Replace") {
82
+ if (!equal(previous.scopes, next.scopes)) fail(error("capability_binding_mismatch"));
83
+ if (active.length !== 1) fail(error("capability_ambiguous"));
84
+ s[key] = next;
76
85
  return { binding: next };
77
86
  }
78
- if (command !== "MigrateCodexNeutralBinding") fail("host_protected_store_command_invalid");
87
+ if (action !== "Migrate") fail("host_protected_store_command_invalid");
79
88
  const old = s.caps.find(c => c.capabilityId === oldId);
80
- if (!old || !capMatches(old, previous) || !capMatches(replacementRecord, next)) fail("neutral_codex_capability_binding_mismatch");
89
+ if (!old || !capMatches(old, previous) || !capMatches(replacementRecord, next)) fail(error("capability_binding_mismatch"));
81
90
  const other = active.filter(c => c.capabilityId !== oldId);
82
- if (other.length > 1) fail("neutral_codex_capability_ambiguous");
91
+ if (other.length > 1) fail(error("capability_ambiguous"));
83
92
  if (!other.length) addCapability(s, replacementRecord);
84
93
  revokeCapability(s, old);
85
- s.codex = next;
94
+ s[key] = next;
86
95
  return { binding: next, record: other[0] || replacementRecord, replacedCapabilityId: oldId };
87
96
  }
88
97
 
@@ -247,9 +256,13 @@ export function invokeDarwinHost(q) {
247
256
  case "DisconnectLocalClient":
248
257
  for (const cap of s.caps.filter(c => c.adapterId === input.client && c.state === "active")) revokeCapability(s,cap);
249
258
  if (input.client === "codex") s.codex = null;
259
+ if (input.client === "claude-code") delete s.claudeCode;
250
260
  return {disconnected:true,client:input.client};
251
261
  }
252
- if (q.command.endsWith("CodexNeutralBinding")) return codexBinding(s,q.command,input,q.capabilityId);
262
+ const bindingCommand = /^(Resolve|Verify|Save|Remove|Replace|Migrate)(Codex|ClaudeCode)NeutralBinding$/.exec(q.command);
263
+ if (bindingCommand) {
264
+ return neutralBinding(s, bindingCommand[2] === "Codex" ? "codex" : "claude-code", bindingCommand[1], input, q.capabilityId);
265
+ }
253
266
  if (q.command.includes("Transport")) return transport(s,q,input);
254
267
  fail("host_protected_store_command_unsupported");
255
268
  });
@@ -34,7 +34,7 @@ export const HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT = "hellolynk-neutral-host-p
34
34
  export const HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT = "hellolynk-neutral-host-discovery-v1";
35
35
  // Raised with every release batch, together with the six client versions. A Host reports it
36
36
  // in its health; the higher release stays and the lower one makes room.
37
- export const HELLOLYNK_HOST_RELEASE_ORDINAL = 15;
37
+ export const HELLOLYNK_HOST_RELEASE_ORDINAL = 16;
38
38
 
39
39
  const INSTALLATION_LOCK_CONTRACT = "hellolynk-neutral-host-installation-lock-v2";
40
40
  const INSTALLATION_LOCK_MAX_BYTES = 4 * 1024;
@@ -95,9 +95,18 @@ export async function runningReleaseNames(releasesRoot) {
95
95
  export async function entryProcessList(client, releasesRoot, pids = []) {
96
96
  if (!ENTRY[client] && client !== 'codex') return [];
97
97
  if (!Array.isArray(pids) || pids.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw failure('release_retention_process_check_invalid');
98
- if (process.platform === 'darwin' && client === 'codex') {
99
- await rootPath(releasesRoot);
100
- return parseDarwinCodexEntryProcesses(await darwinPs('pid=,ppid=,lstart=,command='), pids);
98
+ if (process.platform === 'darwin') {
99
+ const root = await rootPath(releasesRoot);
100
+ const stdout = await darwinPs('pid=,ppid=,lstart=,command=');
101
+ if (client === 'codex') return parseDarwinCodexEntryProcesses(stdout, pids);
102
+ const entries = [...new Set([root, path.resolve(releasesRoot)])]
103
+ .map(value => path.join(path.dirname(value), ...ENTRY[client]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
104
+ const pattern = new RegExp(`(?:^|["\\s])(?:${entries.join('|')})(?=["\\s]|$)`, 'u');
105
+ const wanted = new Set(pids);
106
+ return [...parseDarwinProcesses(stdout).values()].flatMap(({ pid, startedAt, command }) => {
107
+ const entry = pattern.test(command);
108
+ return entry || wanted.has(pid) ? [{ pid, startedAt, entry }] : [];
109
+ });
101
110
  }
102
111
  if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
103
112
  const root = await rootPath(releasesRoot);
@@ -130,7 +139,7 @@ async function darwinPs(fields) {
130
139
 
131
140
  // Keep command lines inside the process probe. Only a PID, its start time and
132
141
  // whether it might still be serving the Codex cache reach the retention logic.
133
- export function parseDarwinCodexEntryProcesses(stdout, pids = []) {
142
+ function parseDarwinProcesses(stdout) {
134
143
  const processes = new Map();
135
144
  for (const line of stdout.trim().split('\n')) {
136
145
  const fields = /^\s*(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/u.exec(line);
@@ -139,6 +148,11 @@ export function parseDarwinCodexEntryProcesses(stdout, pids = []) {
139
148
  const startedAt = Date.parse(`${started} UTC`);
140
149
  processes.set(Number(pid), { pid: Number(pid), parent: Number(parent), startedAt: Number.isFinite(startedAt) ? startedAt : null, command });
141
150
  }
151
+ return processes;
152
+ }
153
+
154
+ export function parseDarwinCodexEntryProcesses(stdout, pids = []) {
155
+ const processes = parseDarwinProcesses(stdout);
142
156
  const wanted = new Set(pids);
143
157
  const proxy = /(?:^|["\s/])(?:\.\/)?server\/mcp-proxy\.mjs(?=["\s]|$)/u;
144
158
  const codexApp = /(?:^|["/])(?:Codex|ChatGPT)\.app\/Contents\//iu;
@@ -198,19 +212,28 @@ async function entryReleases(releasesRoot, root, client, pattern, dependencies)
198
212
  // live processes can delete its executable before the next client startup.
199
213
  export async function configuredReleaseNames(releasesRoot) {
200
214
  if (process.platform === 'darwin') {
201
- const pattern = darwinReleasePattern(await rootPath(releasesRoot), releasesRoot);
215
+ const root = await rootPath(releasesRoot);
216
+ const pattern = darwinReleasePattern(root, releasesRoot);
202
217
  const names = new Set();
218
+ const readAgent = async file => {
219
+ const { stdout } = await execute('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file],
220
+ { timeout: 15000, maxBuffer: 256 * 1024 });
221
+ const { Program, WorkingDirectory, ProgramArguments } = JSON.parse(stdout);
222
+ for (const value of [Program, WorkingDirectory, ...(ProgramArguments || [])]) {
223
+ if (typeof value === 'string') for (const name of darwinReleaseNames(value, pattern)) names.add(name);
224
+ }
225
+ };
226
+ // A receiver set to start on demand keeps its definition outside LaunchAgents.
227
+ const definition = path.join(path.dirname(root), 'host', 'claude-native-service.plist');
228
+ if (await fs.lstat(definition).catch(error => { if (error.code === 'ENOENT') return null; throw error; })) {
229
+ await readAgent(definition);
230
+ }
203
231
  for (const directory of [path.join(homedir(), 'Library', 'LaunchAgents'), '/Library/LaunchAgents', '/Library/LaunchDaemons']) {
204
232
  let entries;
205
233
  try { entries = await fs.readdir(directory); }
206
234
  catch (error) { if (error.code === 'ENOENT') continue; throw error; }
207
235
  for (const entry of entries.filter(name => /hellolynk/i.test(name) && name.endsWith('.plist'))) {
208
- const { stdout } = await execute('/usr/bin/plutil', ['-convert', 'json', '-o', '-', path.join(directory, entry)],
209
- { timeout: 15000, maxBuffer: 256 * 1024 });
210
- const { Program, WorkingDirectory, ProgramArguments } = JSON.parse(stdout);
211
- for (const value of [Program, WorkingDirectory, ...(ProgramArguments || [])]) {
212
- if (typeof value === 'string') for (const name of darwinReleaseNames(value, pattern)) names.add(name);
213
- }
236
+ await readAgent(path.join(directory, entry));
214
237
  }
215
238
  }
216
239
  return [...names].sort();
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  {
6
6
  "path": "release/package.json",
7
- "sha256": "befbca064334e8ff7f9d192f095ac1bd786a3882385a450fc2c2d2a5c9b2b7dd"
7
+ "sha256": "f786baab2e9f35836813c6b77e1cbc0fb8c0a3c0a2055b2e6ea777f2428cd232"
8
8
  },
9
9
  {
10
10
  "path": "neutral-host-bootstrap.mjs",
@@ -40,19 +40,19 @@
40
40
  },
41
41
  {
42
42
  "path": "core/hellolynk-client-update-entry.mjs",
43
- "sha256": "1e513a4a98437dc60b49f9f39fc3958808c3acfaeb6c5bb638efea2e61d4b1ee"
43
+ "sha256": "d9548eacda8632976e42aafc65f0cef2ec9c5233d2d53b31b02aec0a1702b5eb"
44
44
  },
45
45
  {
46
46
  "path": "core/hellolynk-client-update-runtime.mjs",
47
- "sha256": "96aa12ba21619a0cd78ef61c63be6f43fdb61f892201ce923ee35e0912ae2e52"
47
+ "sha256": "aa233e2cdbdfadb7da6aa489591bc12c4121101b94696f93f6e6cb5d3e7c7870"
48
48
  },
49
49
  {
50
50
  "path": "core/hellolynk-client-update.mjs",
51
- "sha256": "93bf5f2ecc72731d70367306989fa18c82fa7bf9e4b5f785a1376f0d55239428"
51
+ "sha256": "e57f7cc5e98519a014cef7cef68a97bb7e2beb33eb381c38d1751a3967904aaa"
52
52
  },
53
53
  {
54
54
  "path": "core/hellolynk-release-retention.mjs",
55
- "sha256": "7cfd7e20c37ac1479f2472cc5fba8e830dcd1b83b91a86c340749cf145f0d661"
55
+ "sha256": "0ee150af5a9bcc1f636e3ff980ee90ce7d073ab5ed4a991920cd3472bc65529c"
56
56
  },
57
57
  {
58
58
  "path": "core/hellolynk-contact-request-e2ee.mjs",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  {
66
66
  "path": "core/hellolynk-darwin-host.mjs",
67
- "sha256": "0772d7a23280b18d5bc59383a6a14fd2ecfd9de1ec53b351be0250770e3e2e70"
67
+ "sha256": "ded97c3bc3dd65ca75305f5199a1340dffb987a5f283454d60586df8b6b55c09"
68
68
  },
69
69
  {
70
70
  "path": "core/hellolynk-darwin-vault.mjs",
@@ -196,7 +196,7 @@
196
196
  },
197
197
  {
198
198
  "path": "core/hellolynk-neutral-host-process.mjs",
199
- "sha256": "e744bacf65f7b86cbcf7d654189c0e07e09401083e2b6f9053b17c1c3bacd1b4"
199
+ "sha256": "7b237a1400c604da5decbb29ab3ce556fd1b744d0cfd7d28b5560b217da120b1"
200
200
  },
201
201
  {
202
202
  "path": "core/hellolynk-neutral-host-store-adapters.mjs",
@@ -292,7 +292,7 @@
292
292
  },
293
293
  {
294
294
  "path": "platform/windows/credential-store.ps1",
295
- "sha256": "d00dae4af08205fffa52c4956d1c87d5c3423e8f20982ccfc1b76e3424fe442e"
295
+ "sha256": "8f288c91a1f6ecaf2651baab9bdfc55014c11ea9e165f203ca7a69f4d47d7917"
296
296
  },
297
297
  {
298
298
  "path": "platform/windows/hellolynk-host-protected-store.ps1",
@@ -312,7 +312,7 @@
312
312
  },
313
313
  {
314
314
  "path": "bootstrap/core/hellolynk-darwin-host.mjs",
315
- "sha256": "0772d7a23280b18d5bc59383a6a14fd2ecfd9de1ec53b351be0250770e3e2e70"
315
+ "sha256": "ded97c3bc3dd65ca75305f5199a1340dffb987a5f283454d60586df8b6b55c09"
316
316
  },
317
317
  {
318
318
  "path": "bootstrap/core/hellolynk-darwin-vault.mjs",
@@ -376,7 +376,7 @@
376
376
  },
377
377
  {
378
378
  "path": "release/server/codex-neutral-production.mjs",
379
- "sha256": "2e09f95b8c15ac307d3be0e5b29fecd00e5eae95fd816e885c51484990e703cb"
379
+ "sha256": "2c2f209339385cc14f27fe985f4deb75f4bd48e689035311847cc3b08e36ac52"
380
380
  },
381
381
  {
382
382
  "path": "release/server/neutral-host-adapter.mjs",
@@ -384,7 +384,7 @@
384
384
  },
385
385
  {
386
386
  "path": "release/server/workbench-runtime.mjs",
387
- "sha256": "99e069a72a0be3e73a993b2092d55470fcb481eb381841a156b2756415a0a0a9"
387
+ "sha256": "1136c7ea50cc73065fe04094c5b5c19cb676c40a0a189979015766a9c4e908d8"
388
388
  }
389
389
  ]
390
390
  }
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "contract": "hellolynk-neutral-host-release-pin-v1",
3
- "manifestSha256": "2cc4af5084d836c44b30aa3f747ab0562980991edf78aba149aaded974d32b3c"
3
+ "manifestSha256": "f054f0e73accc788fb24c437eea03a6e4cd5872f39dc4d65cbc7ff0e17e6bcae"
4
4
  }
@@ -85,8 +85,27 @@ public static class HelloLynkWindowsFileNative {
85
85
  [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
86
86
  public static extern bool MoveFileExW(string existingName, string newName, uint flags);
87
87
 
88
+ [StructLayout(LayoutKind.Sequential)]
89
+ private struct SECURITY_ATTRIBUTES {
90
+ public int Length;
91
+ public IntPtr SecurityDescriptor;
92
+ public int InheritHandle;
93
+ }
94
+
88
95
  [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
89
- public static extern bool CreateDirectoryW(string path, IntPtr securityAttributes);
96
+ private static extern bool CreateDirectoryW(string path, ref SECURITY_ATTRIBUTES attributes);
97
+
98
+ public static bool CreateProtectedDirectory(string path, byte[] descriptor) {
99
+ var pinned = GCHandle.Alloc(descriptor, GCHandleType.Pinned);
100
+ try {
101
+ var attributes = new SECURITY_ATTRIBUTES {
102
+ Length = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
103
+ SecurityDescriptor = pinned.AddrOfPinnedObject(),
104
+ InheritHandle = 0
105
+ };
106
+ return CreateDirectoryW(path, ref attributes);
107
+ } finally { pinned.Free(); }
108
+ }
90
109
  }
91
110
  '@
92
111
 
@@ -162,7 +181,7 @@ function Get-HelloLynkExistingAncestorDirectories([string]$RootPath) {
162
181
  return $ordered
163
182
  }
164
183
 
165
- function Assert-HelloLynkTrustedAncestorAcl([string]$RootPath) {
184
+ function Assert-HelloLynkTrustedAncestorAcl([string]$RootPath, [bool]$Create = $false) {
166
185
  $rootParent = [IO.Path]::GetDirectoryName($RootPath.TrimEnd([char[]]@('\', '/')))
167
186
  $directories = @(Get-HelloLynkExistingAncestorDirectories $RootPath)
168
187
  $dangerousRights = (
@@ -175,6 +194,8 @@ function Assert-HelloLynkTrustedAncestorAcl([string]$RootPath) {
175
194
  [Security.AccessControl.FileSystemRights]::ChangePermissions -bor
176
195
  [Security.AccessControl.FileSystemRights]::TakeOwnership
177
196
  )
197
+ $createRights = [Security.AccessControl.FileSystemRights]::CreateFiles -bor
198
+ [Security.AccessControl.FileSystemRights]::CreateDirectories
178
199
  foreach ($directory in $directories) {
179
200
  Assert-HelloLynkNoReparseAncestors $directory
180
201
  $identity = Open-HelloLynkIdentity $directory $true
@@ -204,6 +225,7 @@ function Assert-HelloLynkTrustedAncestorAcl([string]$RootPath) {
204
225
  )
205
226
  $isRootParent = $directory.Equals($rootParent, [StringComparison]::OrdinalIgnoreCase)
206
227
  $directoryIndex = [Array]::IndexOf($directories, $directory)
228
+ $willCreateProtectedChild = $Create -and -not $isRootParent -and $directoryIndex -eq $directories.Count - 1
207
229
  $nextDirectoryProtected = $false
208
230
  if ($directoryIndex -ge 0 -and $directoryIndex + 1 -lt $directories.Count) {
209
231
  try {
@@ -220,15 +242,20 @@ function Assert-HelloLynkTrustedAncestorAcl([string]$RootPath) {
220
242
  ) { continue }
221
243
  $rights = $rule.FileSystemRights
222
244
  $hasDangerousRights = (($rights -band $dangerousRights) -ne 0)
245
+ # A competing child created by a different user fails our owner check.
246
+ # A grant to alter the ancestor itself still prevents first creation.
247
+ $canCreateProtectedChild = $willCreateProtectedChild -and
248
+ (($rights -band $dangerousRights -band (-bnot $createRights)) -eq 0)
223
249
  $hasDeleteChild = (
224
250
  ($rights -band [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles) -ne 0
225
251
  )
226
252
  if (
227
253
  $hasDeleteChild -or
228
- ($hasDangerousRights -and ($isRootParent -or -not $nextDirectoryProtected))
254
+ ($hasDangerousRights -and ($isRootParent -or (-not $nextDirectoryProtected -and -not $canCreateProtectedChild)))
229
255
  ) { Fail "client_root_ancestor_dacl_untrusted" }
230
256
  }
231
257
  }
258
+ return $directories[-1]
232
259
  }
233
260
 
234
261
  function New-HelloLynkAcl([bool]$Directory) {
@@ -361,7 +388,7 @@ function Exit-HelloLynkRootMutex($Mutex) {
361
388
  try { $Mutex.ReleaseMutex() } finally { $Mutex.Dispose() }
362
389
  }
363
390
 
364
- function Ensure-HelloLynkSecureDirectory([string]$PathValue) {
391
+ function Ensure-HelloLynkSecureDirectory([string]$PathValue, [string]$ExistingAncestor = "") {
365
392
  if ([IO.Directory]::Exists($PathValue)) {
366
393
  Assert-HelloLynkNoReparseAncestors $PathValue
367
394
  Assert-HelloLynkSecureAcl $PathValue $true
@@ -370,12 +397,21 @@ function Ensure-HelloLynkSecureDirectory([string]$PathValue) {
370
397
  return
371
398
  }
372
399
  $parent = [IO.Path]::GetDirectoryName($PathValue)
373
- if (-not [IO.Directory]::Exists($parent)) { Ensure-HelloLynkSecureDirectory $parent }
374
- if (-not [HelloLynkWindowsFileNative]::CreateDirectoryW($PathValue, [IntPtr]::Zero)) {
400
+ if ($ExistingAncestor) {
401
+ if (-not $parent.Equals($ExistingAncestor, [StringComparison]::OrdinalIgnoreCase)) {
402
+ Ensure-HelloLynkSecureDirectory $parent $ExistingAncestor
403
+ }
404
+ } elseif (-not [IO.Directory]::Exists($parent)) {
405
+ Ensure-HelloLynkSecureDirectory $parent
406
+ }
407
+ Assert-HelloLynkNoReparseAncestors $PathValue
408
+ $descriptor = (New-HelloLynkAcl $true).GetSecurityDescriptorBinaryForm()
409
+ if (-not [HelloLynkWindowsFileNative]::CreateProtectedDirectory($PathValue, $descriptor)) {
375
410
  Fail "client_root_create_race"
376
411
  }
377
- Set-HelloLynkSecureAcl $PathValue $true
378
412
  Assert-HelloLynkNoReparseAncestors $PathValue
413
+ # Keep the Windows auto-inheritance marker used by child file ACL checks.
414
+ Set-HelloLynkSecureAcl $PathValue $true
379
415
  Assert-HelloLynkSecureAcl $PathValue $true
380
416
  $identity = Open-HelloLynkIdentity $PathValue $true
381
417
  $identity.Handle.Dispose()
@@ -386,14 +422,14 @@ function Resolve-HelloLynkClientRoot([string]$RootValue, [bool]$Create) {
386
422
  Assert-HelloLynkNoReparseAncestors $root
387
423
  $mutex = Enter-HelloLynkRootMutex $root
388
424
  try {
389
- Assert-HelloLynkTrustedAncestorAcl $root
390
- if ($Create) { Ensure-HelloLynkSecureDirectory $root }
425
+ $existingAncestor = Assert-HelloLynkTrustedAncestorAcl $root $Create
426
+ if ($Create) { Ensure-HelloLynkSecureDirectory $root $existingAncestor }
391
427
  if (-not [IO.Directory]::Exists($root)) { Fail "client_root_missing" }
392
428
  Assert-HelloLynkNoReparseAncestors $root
393
429
  Assert-HelloLynkSecureAcl $root $true
394
430
  $identity = Open-HelloLynkIdentity $root $true
395
431
  try {
396
- Assert-HelloLynkTrustedAncestorAcl $root
432
+ $null = Assert-HelloLynkTrustedAncestorAcl $root
397
433
  $canonical = $root
398
434
  } finally { $identity.Handle.Dispose() }
399
435
  return [pscustomobject]@{ Root = $canonical; Mutex = $mutex }
@@ -24,7 +24,7 @@ const TRUSTED_BOOTSTRAP_FILES = Object.freeze(new Map([
24
24
  ["runtime/neutral-host-release-loader.mjs", "283a20c6a9b90481bc03c143ab51d5289abf7b7a86fbe51fba7ea2ae1e6d2c60"],
25
25
  ["runtime/neutral-host-bootstrap.mjs", "53513deed7f0155a6603a1489d1609fae37776442358181ff7650d0cd9351f0a"],
26
26
  ["runtime/bootstrap/core/hellolynk-runtime-safety.cjs", "43d576d9f774409f4a735793c1503a6f397e787b10c59a4ac9cc9257299f5efe"],
27
- ["runtime/bootstrap/core/hellolynk-darwin-host.mjs", "0772d7a23280b18d5bc59383a6a14fd2ecfd9de1ec53b351be0250770e3e2e70"],
27
+ ["runtime/bootstrap/core/hellolynk-darwin-host.mjs", "ded97c3bc3dd65ca75305f5199a1340dffb987a5f283454d60586df8b6b55c09"],
28
28
  ["runtime/bootstrap/core/hellolynk-darwin-vault.mjs", "b6094d856af016e674e4a0183a4e474397bbe92e66638743fa62fe085b554d30"],
29
29
  ["runtime/bootstrap/core/hellolynk-host-protected-store.mjs", "10f81f4f6a5dd2fe9a742e847b2eb47dbf20f802f4414f46a05ccbc3505148e2"],
30
30
  ["runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs", "42ae1e7deb876a8308b7bfed6a7b891d1834117b62504fc5befe3f91a0b4c740"],