@byok-sdk/client 0.2.0 → 0.3.0

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.
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, spawn, spawnSync } from 'child_process';
3
- import { randomUUID, createHash, randomBytes, timingSafeEqual, createPrivateKey, generateKeyPairSync, sign, createHmac } from 'crypto';
3
+ import { randomUUID, createHash, randomBytes, timingSafeEqual, createHmac, createPrivateKey, generateKeyPairSync, sign } from 'crypto';
4
4
  import { readFileSync, promises, linkSync, fstatSync, lstatSync, unlinkSync, constants, readSync, openSync, writeFileSync, fchmodSync, fsyncSync, closeSync, opendirSync, existsSync, realpathSync, mkdirSync, renameSync, chmodSync, statSync, readdirSync } from 'fs';
5
- import path20, { join, isAbsolute } from 'path';
5
+ import path20, { isAbsolute, join } from 'path';
6
6
  import os from 'os';
7
- import { TASK_STATES, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, createEnvelope, MAX_MESSAGES_PER_BATCH, RuntimeIdSchema, PROTOCOL_VERSION, decodeEnvelope, MessagesSendResponseSchema, parseMessage, UnknownMessageTypeError } from '@byok-sdk/protocol';
7
+ import { DEVICE_ASSERTION_AUDIENCE_MAX_BYTES, DEVICE_ASSERTION_DEFAULT_TTL_MS, DEVICE_ASSERTION_MAX_TTL_MS, nonceSigningBytes, CapabilityDeclarationSchema, hasCapability, DeviceAssertionClaimsSchema, deviceAssertionSigningInput, DEVICE_ASSERTION_SCHEMA_ID } from '@byok-sdk/core';
8
+ import { TASK_STATES, ToolsetIdSchema, BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, createEnvelope, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, RuntimeIdSchema, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, decodeEnvelope, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, BYOK_EVENTS_PATH, parseMessage, UnknownMessageTypeError, BYOK_WS_PATH } from '@byok-sdk/protocol';
8
9
  import { promisify } from 'util';
9
10
  import { fileURLToPath } from 'url';
10
11
  import 'readline';
@@ -1118,13 +1119,8 @@ var PiRpcClient = class {
1118
1119
  }
1119
1120
  };
1120
1121
 
1121
- // src/adapters/pi/pi-adapter.ts
1122
- var execFileAsync = promisify(execFile);
1123
- var DETECT_TIMEOUT_MS = 5e3;
1124
- function errorMessage(err) {
1125
- return err instanceof Error ? err.message : String(err);
1126
- }
1127
- var KNOWN_PROVIDER_ENV_VARS = [
1122
+ // src/adapters/provider-credential-environment.ts
1123
+ var PROVIDER_CREDENTIAL_ENV_NAMES = [
1128
1124
  "ANTHROPIC_API_KEY",
1129
1125
  "ANTHROPIC_OAUTH_TOKEN",
1130
1126
  "OPENAI_API_KEY",
@@ -1135,24 +1131,66 @@ var KNOWN_PROVIDER_ENV_VARS = [
1135
1131
  "MISTRAL_API_KEY",
1136
1132
  "OPENROUTER_API_KEY",
1137
1133
  "XAI_API_KEY",
1138
- // Confirmed against the installed pi's own docs/providers.md ("ZAI |
1139
- // `ZAI_API_KEY` | `zai`") and exercised live against real GLM traffic
1140
- // during this task's acceptance run — omitting it made `authPresent`
1141
- // silently false for a perfectly valid, working z.ai/GLM setup.
1142
1134
  "ZAI_API_KEY"
1143
1135
  ];
1136
+ var PROVIDER_CREDENTIAL_ENV_DENY_NAMES = [
1137
+ ...PROVIDER_CREDENTIAL_ENV_NAMES,
1138
+ "ANT_LING_API_KEY",
1139
+ "NVIDIA_API_KEY",
1140
+ "CEREBRAS_API_KEY",
1141
+ "CLOUDFLARE_API_KEY",
1142
+ "AI_GATEWAY_API_KEY",
1143
+ "ZAI_CODING_CN_API_KEY",
1144
+ "OPENCODE_API_KEY",
1145
+ "RADIUS_API_KEY",
1146
+ "FIREWORKS_API_KEY",
1147
+ "TOGETHER_API_KEY",
1148
+ "BASETEN_API_KEY",
1149
+ "KIMI_API_KEY",
1150
+ "MINIMAX_API_KEY",
1151
+ "MINIMAX_CN_API_KEY",
1152
+ "QWEN_TOKEN_PLAN_API_KEY",
1153
+ "QWEN_TOKEN_PLAN_CN_API_KEY",
1154
+ "XIAOMI_API_KEY",
1155
+ "XIAOMI_TOKEN_PLAN_CN_API_KEY",
1156
+ "XIAOMI_TOKEN_PLAN_AMS_API_KEY",
1157
+ "XIAOMI_TOKEN_PLAN_SGP_API_KEY",
1158
+ "AWS_ACCESS_KEY_ID",
1159
+ "AWS_SECRET_ACCESS_KEY",
1160
+ "AWS_SESSION_TOKEN",
1161
+ "GOOGLE_APPLICATION_CREDENTIALS",
1162
+ // Reserved by the keys-owned Pi projection. It must never be inherited
1163
+ // from the daemon; the launcher deletes any ambient copy and injects only
1164
+ // the exact credential it just resolved from OS custody.
1165
+ "PI_PROVIDER_API_KEY"
1166
+ ];
1167
+ function withoutProviderCredentials(env) {
1168
+ const sanitized = { ...env };
1169
+ for (const name of PROVIDER_CREDENTIAL_ENV_DENY_NAMES) {
1170
+ delete sanitized[name];
1171
+ }
1172
+ return sanitized;
1173
+ }
1174
+
1175
+ // src/adapters/pi/pi-adapter.ts
1176
+ var execFileAsync = promisify(execFile);
1177
+ var DETECT_TIMEOUT_MS = 5e3;
1178
+ function errorMessage(err) {
1179
+ return err instanceof Error ? err.message : String(err);
1180
+ }
1144
1181
  var PiAdapter = class {
1145
1182
  constructor(options = {}) {
1146
1183
  this.options = options;
1147
1184
  }
1148
1185
  options;
1149
1186
  id = "pi";
1187
+ supportsDispatchSelection = true;
1150
1188
  async detect() {
1151
1189
  try {
1152
1190
  const bin = this.resolveBin();
1153
1191
  const { stdout, stderr } = await execFileAsync(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS });
1154
1192
  const version = stdout.trim() || stderr.trim();
1155
- const authPresent = KNOWN_PROVIDER_ENV_VARS.some((name) => process.env[name] !== void 0);
1193
+ const authPresent = PROVIDER_CREDENTIAL_ENV_NAMES.some((name) => process.env[name] !== void 0);
1156
1194
  return { present: true, version, authPresent };
1157
1195
  } catch {
1158
1196
  return { present: false };
@@ -1171,7 +1209,7 @@ var PiAdapter = class {
1171
1209
  * variable beyond the platform baseline (`daemon/environment.ts`).
1172
1210
  */
1173
1211
  environmentRequirements() {
1174
- return { credentialNames: KNOWN_PROVIDER_ENV_VARS };
1212
+ return { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES };
1175
1213
  }
1176
1214
  async start(task, ctx) {
1177
1215
  if (typeof task.instruction !== "string") {
@@ -1183,12 +1221,45 @@ var PiAdapter = class {
1183
1221
  }
1184
1222
  const bin = this.resolveBin();
1185
1223
  const resumeSessionId = task.sessionRef;
1186
- const args = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1224
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1225
+ const selection = task.dispatchSelection;
1226
+ let command = bin.command;
1227
+ let args = piArgs;
1228
+ if (selection !== void 0) {
1229
+ if (selection.lane !== "byok" || selection.runtimeId !== "pi") {
1230
+ throw new PolicyUnsupportedError(
1231
+ `pi adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
1232
+ );
1233
+ }
1234
+ const launcher = this.options.byokLauncher;
1235
+ if (launcher === void 0) {
1236
+ throw new PolicyUnsupportedError(
1237
+ "pi BYOK selection requires a configured credential-custody launcher"
1238
+ );
1239
+ }
1240
+ command = launcher.command;
1241
+ args = [
1242
+ ...launcher.args ?? [],
1243
+ "--pi-bin",
1244
+ bin.command,
1245
+ "--profile-db",
1246
+ launcher.profileDbPath,
1247
+ "--session-dir",
1248
+ launcher.sessionDir,
1249
+ ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
1250
+ "--provider",
1251
+ selection.providerId,
1252
+ "--model",
1253
+ selection.modelId,
1254
+ "--",
1255
+ ...piArgs
1256
+ ];
1257
+ }
1187
1258
  const rpc = new PiRpcClient({
1188
- command: bin.command,
1259
+ command,
1189
1260
  args,
1190
1261
  cwd: ctx.workspaceDir,
1191
- env: ctx.env,
1262
+ env: selection === void 0 ? ctx.env : withoutProviderCredentials(ctx.env),
1192
1263
  spawnFn: this.options.spawnFn
1193
1264
  });
1194
1265
  const response = await rpc.send({ type: "prompt", message: task.instruction });
@@ -1207,7 +1278,7 @@ var PiAdapter = class {
1207
1278
  throw err;
1208
1279
  }
1209
1280
  }
1210
- return new PiSession(sessionRef, rpc);
1281
+ return new PiSession(sessionRef, rpc, selection);
1211
1282
  }
1212
1283
  resolveBin() {
1213
1284
  return (this.options.resolveBin ?? resolvePiBin)();
@@ -1235,12 +1306,14 @@ async function resolveFreshSessionId(rpc) {
1235
1306
  );
1236
1307
  }
1237
1308
  var PiSession = class {
1238
- constructor(sessionRef, rpc) {
1309
+ constructor(sessionRef, rpc, selection) {
1239
1310
  this.sessionRef = sessionRef;
1240
1311
  this.rpc = rpc;
1312
+ this.selection = selection;
1241
1313
  }
1242
1314
  sessionRef;
1243
1315
  rpc;
1316
+ selection;
1244
1317
  get events() {
1245
1318
  const rpc = this.rpc;
1246
1319
  return {
@@ -1269,6 +1342,12 @@ var PiSession = class {
1269
1342
  if (typeof task.instruction !== "string") {
1270
1343
  throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1271
1344
  }
1345
+ const requestedSelection = task.dispatchSelection;
1346
+ if (requestedSelection !== void 0 && (this.selection?.lane !== "byok" || requestedSelection.lane !== "byok" || requestedSelection.runtimeId !== "pi" || requestedSelection.providerId !== this.selection.providerId || requestedSelection.modelId !== this.selection.modelId)) {
1347
+ throw new PolicyUnsupportedError(
1348
+ "pi persistent session cannot change its authoritative BYOK provider/model selection"
1349
+ );
1350
+ }
1272
1351
  await this.rpc.send({ type: "prompt", message: task.instruction, streamingBehavior: "followUp" });
1273
1352
  }
1274
1353
  async interrupt() {
@@ -1693,7 +1772,7 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1693
1772
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1694
1773
  var execFileAsync2 = promisify(execFile);
1695
1774
  var DETECT_TIMEOUT_MS2 = 5e3;
1696
- async function cleanupApprovalMcpConfigDir(dir) {
1775
+ async function cleanupMcpConfigDir(dir) {
1697
1776
  if (!dir) return;
1698
1777
  await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1699
1778
  });
@@ -1703,6 +1782,7 @@ var ClaudeAdapter = class {
1703
1782
  this.options = options;
1704
1783
  }
1705
1784
  options;
1785
+ supportsDispatchSelection = true;
1706
1786
  id = "claude";
1707
1787
  async detect() {
1708
1788
  const bin = this.resolveBin();
@@ -1716,7 +1796,13 @@ var ClaudeAdapter = class {
1716
1796
  }
1717
1797
  }
1718
1798
  capabilities() {
1719
- return { steer: false, resume: true, approvalInteractive: true, permissionModes: ["auto", "readonly", "plan", "confirm"] };
1799
+ return {
1800
+ steer: false,
1801
+ resume: true,
1802
+ approvalInteractive: true,
1803
+ mcpToolsets: true,
1804
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
1805
+ };
1720
1806
  }
1721
1807
  /**
1722
1808
  * M5: deliberate product-boundary decision, not an oversight — byok's
@@ -1742,42 +1828,51 @@ var ClaudeAdapter = class {
1742
1828
  if (!mapping.ok) {
1743
1829
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
1744
1830
  }
1745
- let approvalMcpConfigDir;
1831
+ const modelId = subscriptionModel(task, "claude");
1832
+ let mcpConfigDir;
1833
+ const taskMcpServers = ctx.mcpServers ?? {};
1834
+ const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1746
1835
  if (mapping.needsApprovalMcp) {
1747
1836
  if (!ctx.approvalChannel) {
1748
1837
  throw new PolicyUnsupportedError(
1749
1838
  'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1750
1839
  );
1751
1840
  }
1752
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1753
- approvalMcpConfigDir = await promises.mkdtemp(path20.join(os.tmpdir(), "byok-approval-mcp-"));
1754
- await promises.chmod(approvalMcpConfigDir, 448).catch(() => {
1841
+ if (Object.prototype.hasOwnProperty.call(taskMcpServers, APPROVAL_MCP_SERVER_NAME)) {
1842
+ throw new PolicyUnsupportedError(
1843
+ `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`
1844
+ );
1845
+ }
1846
+ }
1847
+ if (needsMcpConfig) {
1848
+ mcpConfigDir = await promises.mkdtemp(path20.join(os.tmpdir(), "byok-mcp-"));
1849
+ await promises.chmod(mcpConfigDir, 448).catch(() => {
1755
1850
  });
1756
- const mcpConfigPath = path20.join(approvalMcpConfigDir, "mcp-config.json");
1757
- const mcpConfig = {
1758
- mcpServers: {
1759
- [APPROVAL_MCP_SERVER_NAME]: {
1760
- command: approvalMcpBin.command,
1761
- args: approvalMcpBin.args,
1762
- env: {
1763
- BYOK_STORE_DIR: ctx.approvalChannel.storeDir,
1764
- BYOK_PRODUCT_ID: ctx.approvalChannel.productId,
1765
- BYOK_TASK_ID: ctx.approvalChannel.taskId,
1766
- BYOK_APPROVAL_TIMEOUT_MS: String(ctx.approvalChannel.timeoutMs)
1767
- }
1851
+ const mcpConfigPath = path20.join(mcpConfigDir, "mcp-config.json");
1852
+ const mcpServers = { ...taskMcpServers };
1853
+ if (mapping.needsApprovalMcp) {
1854
+ const approvalChannel = ctx.approvalChannel;
1855
+ if (!approvalChannel) throw new Error("unreachable: approval channel checked above");
1856
+ const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1857
+ mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1858
+ command: approvalMcpBin.command,
1859
+ args: approvalMcpBin.args,
1860
+ env: {
1861
+ BYOK_STORE_DIR: approvalChannel.storeDir,
1862
+ BYOK_PRODUCT_ID: approvalChannel.productId,
1863
+ BYOK_TASK_ID: approvalChannel.taskId,
1864
+ BYOK_APPROVAL_TIMEOUT_MS: String(approvalChannel.timeoutMs)
1768
1865
  }
1769
- }
1770
- };
1771
- await promises.writeFile(mcpConfigPath, JSON.stringify(mcpConfig), { mode: 384 });
1866
+ };
1867
+ }
1868
+ await promises.writeFile(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 384 });
1772
1869
  mapping.args = [
1773
1870
  ...mapping.args,
1774
- "--permission-prompt-tool",
1775
- `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`,
1871
+ ...mapping.needsApprovalMcp ? ["--permission-prompt-tool", `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`] : [],
1776
1872
  "--mcp-config",
1777
1873
  mcpConfigPath,
1778
- // Never let this task's confirm-mode run pick up some OTHER MCP
1779
- // server from ambient project/user config the approval channel is
1780
- // the only MCP server this invocation should ever see.
1874
+ // The generated file is the complete task-scoped MCP authority.
1875
+ // Never merge ambient user/project MCP configuration into it.
1781
1876
  "--strict-mcp-config"
1782
1877
  ];
1783
1878
  }
@@ -1794,6 +1889,7 @@ var ClaudeAdapter = class {
1794
1889
  // "Error: When using --print, --output-format=stream-json requires
1795
1890
  // --verbose", before spawning any model call.
1796
1891
  "--verbose",
1892
+ ...modelId ? ["--model", modelId] : [],
1797
1893
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1798
1894
  ...mapping.args
1799
1895
  ];
@@ -1801,7 +1897,7 @@ var ClaudeAdapter = class {
1801
1897
  command: bin.command,
1802
1898
  args,
1803
1899
  cwd: ctx.workspaceDir,
1804
- env: ctx.env,
1900
+ env: withoutProviderCredentials(ctx.env),
1805
1901
  spawnFn: this.options.spawnFn
1806
1902
  });
1807
1903
  client.writeUserMessage(task.instruction);
@@ -1810,17 +1906,24 @@ var ClaudeAdapter = class {
1810
1906
  sessionRef = await client.waitForInit();
1811
1907
  } catch (err) {
1812
1908
  client.kill();
1813
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1909
+ await cleanupMcpConfigDir(mcpConfigDir);
1814
1910
  throw err;
1815
1911
  }
1816
1912
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1817
1913
  client.kill();
1818
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1914
+ await cleanupMcpConfigDir(mcpConfigDir);
1819
1915
  throw new Error(
1820
1916
  `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1821
1917
  );
1822
1918
  }
1823
- return new ClaudeSession(sessionRef, client, ctx.workspaceDir, ctx.approvalChannel, approvalMcpConfigDir);
1919
+ return new ClaudeSession(
1920
+ sessionRef,
1921
+ client,
1922
+ ctx.workspaceDir,
1923
+ ctx.approvalChannel,
1924
+ mcpConfigDir,
1925
+ modelId
1926
+ );
1824
1927
  }
1825
1928
  /**
1826
1929
  * `claude auth status --json` is claude's OWN non-secret login-state
@@ -1854,19 +1957,31 @@ var ClaudeAdapter = class {
1854
1957
  return (this.options.resolveBin ?? resolveClaudeBin)();
1855
1958
  }
1856
1959
  };
1960
+ function subscriptionModel(task, runtimeId) {
1961
+ const selection = task.dispatchSelection;
1962
+ if (selection === void 0) return void 0;
1963
+ if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1964
+ throw new PolicyUnsupportedError(
1965
+ `claude adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
1966
+ );
1967
+ }
1968
+ return selection.modelId;
1969
+ }
1857
1970
  var ClaudeSession = class {
1858
- constructor(sessionRef, client, workspaceDir, approvalChannel, approvalMcpConfigDir) {
1971
+ constructor(sessionRef, client, workspaceDir, approvalChannel, mcpConfigDir, modelId) {
1859
1972
  this.sessionRef = sessionRef;
1860
1973
  this.client = client;
1861
1974
  this.workspaceDir = workspaceDir;
1862
1975
  this.approvalChannel = approvalChannel;
1863
- this.approvalMcpConfigDir = approvalMcpConfigDir;
1976
+ this.mcpConfigDir = mcpConfigDir;
1977
+ this.modelId = modelId;
1864
1978
  }
1865
1979
  sessionRef;
1866
1980
  client;
1867
1981
  workspaceDir;
1868
1982
  approvalChannel;
1869
- approvalMcpConfigDir;
1983
+ mcpConfigDir;
1984
+ modelId;
1870
1985
  correlation = createToolUseCorrelation();
1871
1986
  get events() {
1872
1987
  const client = this.client;
@@ -1917,6 +2032,12 @@ var ClaudeSession = class {
1917
2032
  if (typeof task.instruction !== "string") {
1918
2033
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1919
2034
  }
2035
+ const requestedModel = subscriptionModel(task, "claude");
2036
+ if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2037
+ throw new PolicyUnsupportedError(
2038
+ `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
2039
+ );
2040
+ }
1920
2041
  this.client.writeUserMessage(task.instruction);
1921
2042
  }
1922
2043
  /**
@@ -1936,7 +2057,7 @@ var ClaudeSession = class {
1936
2057
  }
1937
2058
  async close() {
1938
2059
  this.client.kill();
1939
- await cleanupApprovalMcpConfigDir(this.approvalMcpConfigDir);
2060
+ await cleanupMcpConfigDir(this.mcpConfigDir);
1940
2061
  }
1941
2062
  /**
1942
2063
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -2260,6 +2381,7 @@ var CodexAdapter = class {
2260
2381
  this.options = options;
2261
2382
  }
2262
2383
  options;
2384
+ supportsDispatchSelection = true;
2263
2385
  id = "codex";
2264
2386
  async detect() {
2265
2387
  const bin = this.resolveBin();
@@ -2332,17 +2454,20 @@ ${withStreams.stderr ?? ""}`);
2332
2454
  if (!mapping.ok) {
2333
2455
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2334
2456
  }
2457
+ const modelId = subscriptionModel2(task);
2335
2458
  const bin = this.resolveBin();
2336
2459
  const queue = new AsyncQueue();
2337
2460
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
2338
2461
  const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
2462
+ const runtimeEnv = withoutProviderCredentials(ctx.env);
2339
2463
  const { sessionRef, runner } = await runCodexTurn({
2340
2464
  command: bin.command,
2341
2465
  resumeRef: task.sessionRef,
2342
2466
  instruction: task.instruction,
2467
+ modelId,
2343
2468
  policyArgs: mapping.args,
2344
2469
  cwd: ctx.workspaceDir,
2345
- env: ctx.env,
2470
+ env: runtimeEnv,
2346
2471
  spawnFn: this.options.spawnFn,
2347
2472
  workspaceDir,
2348
2473
  queue,
@@ -2359,7 +2484,8 @@ ${withStreams.stderr ?? ""}`);
2359
2484
  queue,
2360
2485
  recordUnmapped,
2361
2486
  initialRunner: runner,
2362
- preparedGit: ctx.gitWorkspace !== void 0
2487
+ preparedGit: ctx.gitWorkspace !== void 0,
2488
+ modelId
2363
2489
  });
2364
2490
  }
2365
2491
  resolveBin() {
@@ -2380,12 +2506,25 @@ function makeUnmappedFrameRecorder(counts) {
2380
2506
  }
2381
2507
  };
2382
2508
  }
2383
- function buildArgv(resumeRef, policyArgs, instruction, preparedGit = false) {
2509
+ function buildArgv(resumeRef, policyArgs, instruction, modelId, preparedGit = false) {
2384
2510
  const base = resumeRef !== void 0 ? ["exec", "resume", resumeRef] : ["exec"];
2385
- return [...base, "--json", ...preparedGit ? [] : ["--skip-git-repo-check"], ...policyArgs, instruction];
2511
+ return [
2512
+ ...base,
2513
+ "--json",
2514
+ ...modelId ? ["--model", modelId] : [],
2515
+ ...preparedGit ? [] : ["--skip-git-repo-check"],
2516
+ ...policyArgs,
2517
+ instruction
2518
+ ];
2386
2519
  }
2387
2520
  async function runCodexTurn(params) {
2388
- const argv = buildArgv(params.resumeRef, params.policyArgs, params.instruction, params.preparedGit);
2521
+ const argv = buildArgv(
2522
+ params.resumeRef,
2523
+ params.policyArgs,
2524
+ params.instruction,
2525
+ params.modelId,
2526
+ params.preparedGit
2527
+ );
2389
2528
  let firstLineSettled = false;
2390
2529
  let resolveFirstLine;
2391
2530
  let rejectFirstLine;
@@ -2486,6 +2625,7 @@ var CodexSession = class {
2486
2625
  queue;
2487
2626
  recordUnmapped;
2488
2627
  preparedGit;
2628
+ modelId;
2489
2629
  currentRunner;
2490
2630
  closed = false;
2491
2631
  constructor(options) {
@@ -2497,6 +2637,7 @@ var CodexSession = class {
2497
2637
  this.queue = options.queue;
2498
2638
  this.recordUnmapped = options.recordUnmapped;
2499
2639
  this.preparedGit = options.preparedGit;
2640
+ this.modelId = options.modelId;
2500
2641
  this.currentRunner = options.initialRunner;
2501
2642
  void this.forgetRunnerOnceClosed(options.initialRunner);
2502
2643
  }
@@ -2557,6 +2698,13 @@ var CodexSession = class {
2557
2698
  if (!mapping.ok) {
2558
2699
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2559
2700
  }
2701
+ const requestedModel = subscriptionModel2(task);
2702
+ if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2703
+ throw new PolicyUnsupportedError(
2704
+ `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
2705
+ );
2706
+ }
2707
+ const modelId = this.modelId;
2560
2708
  const resumeRef = this.sessionRef;
2561
2709
  let sessionRef;
2562
2710
  let runner;
@@ -2565,9 +2713,10 @@ var CodexSession = class {
2565
2713
  command: this.command,
2566
2714
  resumeRef,
2567
2715
  instruction: task.instruction,
2716
+ modelId,
2568
2717
  policyArgs: mapping.args,
2569
2718
  cwd: this.workspaceDir,
2570
- env: this.env,
2719
+ env: withoutProviderCredentials(this.env),
2571
2720
  spawnFn: this.spawnFn,
2572
2721
  workspaceDir: this.workspaceDir,
2573
2722
  queue: this.queue,
@@ -2627,6 +2776,16 @@ var CodexSession = class {
2627
2776
  );
2628
2777
  }
2629
2778
  };
2779
+ function subscriptionModel2(task) {
2780
+ const selection = task.dispatchSelection;
2781
+ if (selection === void 0) return void 0;
2782
+ if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2783
+ throw new PolicyUnsupportedError(
2784
+ `codex adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
2785
+ );
2786
+ }
2787
+ return selection.modelId;
2788
+ }
2630
2789
 
2631
2790
  // src/daemon/approvals.ts
2632
2791
  var ApprovalNotFoundError = class extends Error {
@@ -2851,13 +3010,10 @@ function exportPrivateKeyPem(privateKey) {
2851
3010
  function importPrivateKeyPem(pem) {
2852
3011
  return createPrivateKey(pem);
2853
3012
  }
2854
- var NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
2855
3013
  function signNonce(privateKey, nonce) {
2856
- const signature = sign(null, Buffer.from(NONCE_SIGNING_DOMAIN + nonce, "utf8"), privateKey);
3014
+ const signature = sign(null, nonceSigningBytes(nonce), privateKey);
2857
3015
  return signature.toString("base64url");
2858
3016
  }
2859
-
2860
- // src/daemon/url.ts
2861
3017
  function toHttpBase(serverUrl) {
2862
3018
  const url = new URL(serverUrl);
2863
3019
  if (url.protocol === "ws:") url.protocol = "http:";
@@ -2867,7 +3023,7 @@ function toHttpBase(serverUrl) {
2867
3023
  return url.toString();
2868
3024
  }
2869
3025
  function toWsUrl(serverUrl) {
2870
- const url = new URL("/byok/ws", toHttpBase(serverUrl));
3026
+ const url = new URL(BYOK_WS_PATH, toHttpBase(serverUrl));
2871
3027
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2872
3028
  return url.toString();
2873
3029
  }
@@ -2955,7 +3111,7 @@ var AuthManager = class {
2955
3111
  return await this.runCredentialMutation(async () => {
2956
3112
  const existing = this.record ?? await this.opts.store.load();
2957
3113
  const keyPair = existing ? { privateKey: importPrivateKeyPem(existing.devicePrivateKeyPem), publicKeyBase64Url: existing.devicePublicKey } : generateDeviceKeyPair();
2958
- const url = new URL("/byok/pair", toHttpBase(this.opts.serverUrl));
3114
+ const url = new URL(BYOK_PAIR_PATH, toHttpBase(this.opts.serverUrl));
2959
3115
  const res = await fetch(url, {
2960
3116
  method: "POST",
2961
3117
  headers: { "content-type": "application/json" },
@@ -2989,6 +3145,7 @@ var AuthManager = class {
2989
3145
  async getValidAccessToken() {
2990
3146
  if (!this.record) throw new Error("device is not paired yet; call pair(pairingCode) first");
2991
3147
  if (this.revoked) throw new DeviceRevokedError();
3148
+ if (this.renewing) return this.renewing;
2992
3149
  if (msUntilExpiry(this.record.expiresAt) > RENEW_MARGIN_MS) return this.record.accessToken;
2993
3150
  return this.renew();
2994
3151
  }
@@ -3017,7 +3174,7 @@ var AuthManager = class {
3017
3174
  const record = this.record;
3018
3175
  const base = toHttpBase(this.opts.serverUrl);
3019
3176
  const privateKey = importPrivateKeyPem(record.devicePrivateKeyPem);
3020
- const challengeRes = await fetch(new URL("/byok/challenge", base), {
3177
+ const challengeRes = await fetch(new URL(BYOK_CHALLENGE_PATH, base), {
3021
3178
  method: "POST",
3022
3179
  headers: { "content-type": "application/json" },
3023
3180
  body: JSON.stringify({ deviceId: record.deviceId })
@@ -3030,7 +3187,7 @@ var AuthManager = class {
3030
3187
  }
3031
3188
  const { nonce } = await challengeRes.json();
3032
3189
  const signature = signNonce(privateKey, nonce);
3033
- const tokenRes = await fetch(new URL("/byok/token", base), {
3190
+ const tokenRes = await fetch(new URL(BYOK_TOKEN_PATH, base), {
3034
3191
  method: "POST",
3035
3192
  headers: { "content-type": "application/json" },
3036
3193
  body: JSON.stringify({ deviceId: record.deviceId, nonce, signature })
@@ -3128,7 +3285,7 @@ var BlobClient = class {
3128
3285
  async resolveInstruction(blobRef) {
3129
3286
  const base = toHttpBase(this.serverUrl);
3130
3287
  const urlRes = await authedFetch(
3131
- new URL(`/byok/blobs/${encodeURIComponent(blobRef.blobId)}/url`, base),
3288
+ new URL(byokBlobUrlPath(blobRef.blobId), base),
3132
3289
  { method: "GET" },
3133
3290
  this.auth
3134
3291
  );
@@ -3156,7 +3313,7 @@ var BlobClient = class {
3156
3313
  const base = toHttpBase(this.serverUrl);
3157
3314
  const reservationId = `blob_${randomUUID()}`;
3158
3315
  const createRes = await authedFetch(
3159
- new URL("/byok/blobs", base),
3316
+ new URL(BYOK_BLOBS_PATH, base),
3160
3317
  {
3161
3318
  method: "POST",
3162
3319
  headers: {
@@ -3188,7 +3345,7 @@ var BlobClient = class {
3188
3345
  let response;
3189
3346
  try {
3190
3347
  response = await authedFetch(
3191
- new URL(`/byok/blobs/${encodeURIComponent(blobId)}/finalize`, base),
3348
+ new URL(byokBlobFinalizePath(blobId), base),
3192
3349
  {
3193
3350
  method: "POST",
3194
3351
  headers: { "idempotency-key": reservationId }
@@ -3211,16 +3368,177 @@ var BlobClient = class {
3211
3368
  throw lastFailure;
3212
3369
  }
3213
3370
  };
3371
+ var PRESENCE_HINTS_CAPABILITY = "presence.hints";
3372
+ var CapabilityDiscoveryError = class extends Error {
3373
+ constructor(message, options) {
3374
+ super(message, options);
3375
+ this.name = "CapabilityDiscoveryError";
3376
+ }
3377
+ };
3378
+ async function fetchCapabilityDeclaration(serverUrl, options = {}) {
3379
+ const url = new URL(BYOK_CAPABILITIES_PATH, toHttpBase(serverUrl));
3380
+ let response;
3381
+ try {
3382
+ response = await fetch(url, {
3383
+ method: "GET",
3384
+ ...options.signal === void 0 ? {} : { signal: options.signal }
3385
+ });
3386
+ } catch (err) {
3387
+ throw new CapabilityDiscoveryError(
3388
+ `failed to read the capability declaration from ${url.toString()}: ${err instanceof Error ? err.message : String(err)}`,
3389
+ { cause: err }
3390
+ );
3391
+ }
3392
+ if (!response.ok) {
3393
+ throw new CapabilityDiscoveryError(
3394
+ `failed to read the capability declaration from ${url.toString()}: HTTP ${response.status}`
3395
+ );
3396
+ }
3397
+ let body;
3398
+ try {
3399
+ body = await response.json();
3400
+ } catch (err) {
3401
+ throw new CapabilityDiscoveryError(
3402
+ `the capability declaration at ${url.toString()} is not JSON: ${err instanceof Error ? err.message : String(err)}`,
3403
+ { cause: err }
3404
+ );
3405
+ }
3406
+ const parsed = CapabilityDeclarationSchema.safeParse(body);
3407
+ if (!parsed.success) {
3408
+ throw new CapabilityDiscoveryError(
3409
+ `the capability declaration at ${url.toString()} is not a valid ADR-010 declaration: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ")}`,
3410
+ { cause: parsed.error }
3411
+ );
3412
+ }
3413
+ return parsed.data;
3414
+ }
3415
+ function declares(declaration, capability) {
3416
+ return hasCapability(declaration, capability);
3417
+ }
3418
+ var DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS = 3e4;
3419
+ var DEFAULT_PRESENCE_TTL_MS = 9e4;
3420
+ var DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS = 5e3;
3421
+ function assertPresenceHeartbeatCadence(cadence) {
3422
+ const { intervalMs, ttlMs, minimumIntervalMs } = cadence;
3423
+ if (!(minimumIntervalMs < intervalMs && intervalMs < ttlMs)) {
3424
+ throw new Error(
3425
+ `presence heartbeat interval must satisfy minimumIntervalMs < intervalMs < ttlMs \u2014 got ${minimumIntervalMs} < ${intervalMs} < ${ttlMs}`
3426
+ );
3427
+ }
3428
+ }
3429
+ var PresencePublisher = class {
3430
+ constructor(opts) {
3431
+ this.opts = opts;
3432
+ const intervalMs = opts.intervalMs ?? DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS;
3433
+ const ttlMs = opts.ttlMs ?? DEFAULT_PRESENCE_TTL_MS;
3434
+ const minimumIntervalMs = opts.minimumIntervalMs ?? DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS;
3435
+ assertPresenceHeartbeatCadence({ intervalMs, ttlMs, minimumIntervalMs });
3436
+ this.intervalMs = intervalMs;
3437
+ this.url = new URL(BYOK_PRESENCE_PATH, toHttpBase(opts.serverUrl));
3438
+ }
3439
+ opts;
3440
+ url;
3441
+ intervalMs;
3442
+ timer;
3443
+ running = false;
3444
+ /** Set once a revoked device is observed. Terminal: `start()` will not restart this instance. */
3445
+ stoppedPermanently = false;
3446
+ /** Publishes immediately, then every `intervalMs`. Idempotent; a no-op after a permanent stop. */
3447
+ start() {
3448
+ if (this.running || this.stoppedPermanently) return;
3449
+ this.running = true;
3450
+ void this.beat();
3451
+ }
3452
+ /** Stops the cadence. Idempotent, and the only "offline" signal this producer emits — the hint's TTL does the rest. */
3453
+ stop() {
3454
+ this.running = false;
3455
+ if (this.timer !== void 0) {
3456
+ clearTimeout(this.timer);
3457
+ this.timer = void 0;
3458
+ }
3459
+ }
3460
+ schedule() {
3461
+ if (!this.running) return;
3462
+ this.timer = setTimeout(() => {
3463
+ this.timer = void 0;
3464
+ void this.beat();
3465
+ }, this.intervalMs);
3466
+ this.timer.unref?.();
3467
+ }
3468
+ async beat() {
3469
+ if (!this.running) return;
3470
+ try {
3471
+ const response = await authedFetch(
3472
+ this.url,
3473
+ {
3474
+ method: "PUT",
3475
+ headers: { "content-type": "application/json" },
3476
+ body: JSON.stringify({ level: "online" })
3477
+ },
3478
+ this.opts.auth
3479
+ );
3480
+ if (!response.ok) {
3481
+ if (response.status === 401) {
3482
+ this.stopPermanently(`presence heartbeat unauthorized after token renewal (HTTP 401)`);
3483
+ return;
3484
+ }
3485
+ this.opts.onDegraded?.(`presence heartbeat failed: HTTP ${response.status}`);
3486
+ }
3487
+ } catch (err) {
3488
+ if (err instanceof DeviceRevokedError) {
3489
+ this.stopPermanently("presence heartbeat stopped: device has been revoked; re-pair required");
3490
+ return;
3491
+ }
3492
+ this.opts.onDegraded?.(`presence heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
3493
+ }
3494
+ this.schedule();
3495
+ }
3496
+ stopPermanently(reason) {
3497
+ this.stoppedPermanently = true;
3498
+ this.stop();
3499
+ this.opts.onDegraded?.(reason);
3500
+ }
3501
+ };
3502
+ function freshJti() {
3503
+ return randomBytes(16).toString("base64url");
3504
+ }
3505
+ function mintDeviceAssertion(input) {
3506
+ const issuedAtMs = input.now.getTime();
3507
+ const expiresAt = new Date(issuedAtMs + input.ttlMs).toISOString();
3508
+ const claims = DeviceAssertionClaimsSchema.parse({
3509
+ version: 1,
3510
+ issuer: input.issuer,
3511
+ productId: input.productId,
3512
+ deviceId: input.record.deviceId,
3513
+ audience: input.audience,
3514
+ jti: freshJti(),
3515
+ issuedAt: new Date(issuedAtMs).toISOString(),
3516
+ expiresAt
3517
+ });
3518
+ const privateKey = importPrivateKeyPem(input.record.devicePrivateKeyPem);
3519
+ const signature = sign(null, deviceAssertionSigningInput(claims), privateKey).toString("base64url");
3520
+ return {
3521
+ envelope: {
3522
+ schema: DEVICE_ASSERTION_SCHEMA_ID,
3523
+ algorithm: "ed25519",
3524
+ protected: claims,
3525
+ signature
3526
+ },
3527
+ claims,
3528
+ expiresAt
3529
+ };
3530
+ }
3214
3531
  var CONTROL_PROTOCOL_VERSION = 1;
3215
3532
  var HANDSHAKE_TIMEOUT_MS = 3e3;
3216
3533
  var UNIX_SOCKET_PATH_SOFT_LIMIT = 100;
3534
+ var CONTROL_SOCKET_FALLBACK_ROOT = "/tmp";
3217
3535
  function shortHash(input) {
3218
3536
  return createHash("sha256").update(input, "utf8").digest("hex").slice(0, 16);
3219
3537
  }
3220
3538
  function controlSocketPath(storeDir) {
3221
3539
  const candidate = path20.join(storeDir, "control.sock");
3222
3540
  if (Buffer.byteLength(candidate, "utf8") <= UNIX_SOCKET_PATH_SOFT_LIMIT) return candidate;
3223
- return path20.join(os.tmpdir(), `byok-${shortHash(storeDir)}`, "sock");
3541
+ return path20.join(CONTROL_SOCKET_FALLBACK_ROOT, `byok-${shortHash(storeDir)}`, "sock");
3224
3542
  }
3225
3543
  function controlPipeName(productId, storeDir) {
3226
3544
  const id = shortHash(`${productId}|${path20.resolve(storeDir)}`);
@@ -3326,6 +3644,16 @@ function parseApprovalsRequestParams(value) {
3326
3644
  if (typeof value.summary !== "string") return void 0;
3327
3645
  return { taskId: value.taskId, summary: value.summary };
3328
3646
  }
3647
+ var ASSERTION_AUDIENCE_MAX_BYTES = 256;
3648
+ function parseAssertionIssueParams(value) {
3649
+ if (!isRecord2(value)) return void 0;
3650
+ const keys = Object.keys(value);
3651
+ if (keys.length !== 1 || keys[0] !== "audience") return void 0;
3652
+ const { audience } = value;
3653
+ if (typeof audience !== "string" || audience.length === 0) return void 0;
3654
+ if (Buffer.byteLength(audience, "utf8") > ASSERTION_AUDIENCE_MAX_BYTES) return void 0;
3655
+ return { audience };
3656
+ }
3329
3657
  function parseShutdownParams(value) {
3330
3658
  if (!isRecord2(value)) return {};
3331
3659
  return value.reason === "unpair" || value.reason === "operator" ? { reason: value.reason } : {};
@@ -3658,7 +3986,7 @@ var LongPollClient = class {
3658
3986
  try {
3659
3987
  const base = toHttpBase(this.opts.serverUrl);
3660
3988
  const res = await authedFetch(
3661
- new URL("/byok/messages", base),
3989
+ new URL(BYOK_MESSAGES_PATH, base),
3662
3990
  {
3663
3991
  method: "POST",
3664
3992
  headers: { "content-type": "application/json" },
@@ -3682,7 +4010,7 @@ var LongPollClient = class {
3682
4010
  while (this.running) {
3683
4011
  try {
3684
4012
  const base = toHttpBase(this.opts.serverUrl);
3685
- const url = new URL("/byok/events", base);
4013
+ const url = new URL(BYOK_EVENTS_PATH, base);
3686
4014
  const cursor = this.opts.getCursor();
3687
4015
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
3688
4016
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
@@ -4945,19 +5273,19 @@ var RECLAIM_FILENAME = `${DAEMON_OWNER_FILENAME}.reclaim`;
4945
5273
  var MAX_OWNER_BYTES = 4096;
4946
5274
  var RECLAIM_MALFORMED_GRACE_MS = 3e4;
4947
5275
  var SELF_PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1e3).toISOString();
4948
- var STORE_MUTEX_PORT_BASE = 1e4;
4949
- var STORE_MUTEX_PORT_COUNT = 2e4;
4950
- var STORE_MUTEX_PORT_CANDIDATES = 32;
4951
5276
  var STORE_MUTEX_ID_PREFIX = "byok-store-mutex-v1:";
4952
5277
  var STORE_MUTEX_PROBE_TIMEOUT_MS = 1e3;
5278
+ var STORE_MUTEX_SOCKET_FILENAME = "mutex.sock";
5279
+ var UNIX_SOCKET_PATH_SOFT_LIMIT2 = 100;
5280
+ var STORE_MUTEX_FALLBACK_ROOT = "/tmp";
4953
5281
  function storeMutexIdentity(canonicalStoreDir) {
4954
5282
  return createHash("sha256").update(canonicalStoreDir).digest("hex");
4955
5283
  }
4956
- function storeMutexPort(identity, attempt) {
4957
- const digest = Buffer.from(identity, "hex");
4958
- const start = digest.readUInt32BE(0) % STORE_MUTEX_PORT_COUNT;
4959
- const step = 1 + digest.readUInt32BE(4) % (STORE_MUTEX_PORT_COUNT - 1);
4960
- return STORE_MUTEX_PORT_BASE + (start + attempt * step) % STORE_MUTEX_PORT_COUNT;
5284
+ function storeMutexEndpoint(canonicalStoreDir, identity, platform = process.platform) {
5285
+ if (platform === "win32") return `\\\\.\\pipe\\byok-store-mutex-${identity.slice(0, 16)}`;
5286
+ const candidate = path20.join(canonicalStoreDir, STORE_MUTEX_SOCKET_FILENAME);
5287
+ if (Buffer.byteLength(candidate, "utf8") <= UNIX_SOCKET_PATH_SOFT_LIMIT2) return candidate;
5288
+ return path20.join(STORE_MUTEX_FALLBACK_ROOT, `byok-store-mutex-${identity.slice(0, 16)}`, "sock");
4961
5289
  }
4962
5290
  var DaemonOwnerActiveError = class extends Error {
4963
5291
  constructor(role) {
@@ -5078,70 +5406,90 @@ async function createLivenessListener() {
5078
5406
  })
5079
5407
  };
5080
5408
  }
5081
- async function probeStoreMutex(port) {
5409
+ async function probeStoreMutex(endpoint, identity) {
5082
5410
  return new Promise((resolve) => {
5083
- const socket = createConnection({ host: "127.0.0.1", port });
5411
+ const socket = createConnection(endpoint);
5084
5412
  let settled = false;
5085
5413
  let raw = "";
5086
5414
  const finish = (result) => {
5087
5415
  if (settled) return;
5088
5416
  settled = true;
5417
+ clearTimeout(timer);
5418
+ socket.removeAllListeners();
5089
5419
  socket.destroy();
5090
5420
  resolve(result);
5091
5421
  };
5422
+ const timer = setTimeout(() => finish({ kind: "occupied" }), STORE_MUTEX_PROBE_TIMEOUT_MS);
5092
5423
  socket.setEncoding("utf8");
5093
- socket.setTimeout(STORE_MUTEX_PROBE_TIMEOUT_MS, () => finish({ kind: "uncertain" }));
5094
5424
  socket.on("data", (chunk) => {
5095
5425
  raw += chunk;
5096
- if (raw.length > STORE_MUTEX_ID_PREFIX.length + 64 + 1) finish({ kind: "foreign-or-gone" });
5426
+ if (raw.length > STORE_MUTEX_ID_PREFIX.length + 64 + 1) finish({ kind: "occupied" });
5097
5427
  });
5098
- socket.once("end", () => {
5099
- const line = raw.trimEnd();
5100
- const identity = line.startsWith(STORE_MUTEX_ID_PREFIX) ? line.slice(STORE_MUTEX_ID_PREFIX.length) : void 0;
5101
- finish(
5102
- identity && /^[a-f0-9]{64}$/.test(identity) ? { kind: "identity", identity } : { kind: "foreign-or-gone" }
5103
- );
5104
- });
5105
- socket.once("error", () => finish({ kind: "foreign-or-gone" }));
5428
+ socket.once("end", () => finish(raw.trimEnd() === `${STORE_MUTEX_ID_PREFIX}${identity}` ? { kind: "holder" } : { kind: "occupied" }));
5429
+ socket.once(
5430
+ "error",
5431
+ (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT" ? { kind: "unbound" } : { kind: "occupied" })
5432
+ );
5106
5433
  });
5107
5434
  }
5435
+ async function clearStaleStoreMutexSocket(endpoint, identity) {
5436
+ let stat;
5437
+ try {
5438
+ stat = await promises.lstat(endpoint);
5439
+ } catch (err) {
5440
+ if (err.code === "ENOENT") return;
5441
+ throw err;
5442
+ }
5443
+ if (!stat.isSocket()) throw new Error("store mutation lock path exists but is not a socket");
5444
+ if ((await probeStoreMutex(endpoint, identity)).kind !== "unbound") throw new DaemonOwnerActiveError("unknown");
5445
+ await promises.rm(endpoint, { force: true });
5446
+ }
5447
+ async function assertOwnedPrivateDir2(dir) {
5448
+ const uid = process.getuid?.();
5449
+ if (uid === void 0) return;
5450
+ const stat = await promises.lstat(dir);
5451
+ if (stat.isSymbolicLink() || stat.uid !== uid) {
5452
+ throw new Error(`refusing to bind the store mutation lock under "${dir}": not a real directory owned by this process's own uid`);
5453
+ }
5454
+ }
5108
5455
  async function acquireStoreMutex(canonicalStoreDir) {
5109
5456
  const identity = storeMutexIdentity(canonicalStoreDir);
5110
- for (let attempt = 0; attempt < STORE_MUTEX_PORT_CANDIDATES; attempt += 1) {
5111
- const server = createServer((socket) => socket.end(`${STORE_MUTEX_ID_PREFIX}${identity}
5457
+ const endpoint = storeMutexEndpoint(canonicalStoreDir, identity);
5458
+ const isPipe = process.platform === "win32";
5459
+ if (!isPipe) {
5460
+ const endpointDir = path20.dirname(endpoint);
5461
+ if (endpointDir !== canonicalStoreDir) {
5462
+ await ensureSecureDir(endpointDir);
5463
+ await assertOwnedPrivateDir2(endpointDir);
5464
+ }
5465
+ await clearStaleStoreMutexSocket(endpoint, identity);
5466
+ }
5467
+ const server = createServer((socket) => socket.end(`${STORE_MUTEX_ID_PREFIX}${identity}
5112
5468
  `));
5113
- const port = storeMutexPort(identity, attempt);
5114
- try {
5115
- await new Promise((resolve, reject) => {
5116
- server.once("error", reject);
5117
- server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
5118
- server.removeListener("error", reject);
5119
- resolve();
5120
- });
5469
+ try {
5470
+ await new Promise((resolve, reject) => {
5471
+ server.once("error", reject);
5472
+ server.listen(endpoint, () => {
5473
+ server.removeListener("error", reject);
5474
+ resolve();
5121
5475
  });
5122
- } catch (err) {
5123
- if (err.code !== "EADDRINUSE") throw err;
5124
- const probe = await probeStoreMutex(port);
5125
- if (probe.kind === "uncertain" || probe.kind === "identity" && probe.identity === identity) {
5126
- throw new DaemonOwnerActiveError("unknown");
5127
- }
5128
- continue;
5129
- }
5130
- server.unref();
5131
- let closed = false;
5132
- return {
5133
- port,
5134
- close: () => new Promise((resolve, reject) => {
5135
- if (closed) {
5136
- resolve();
5137
- return;
5138
- }
5139
- closed = true;
5140
- server.close((err) => err ? reject(err) : resolve());
5141
- })
5142
- };
5476
+ });
5477
+ } catch (err) {
5478
+ if (err.code === "EADDRINUSE") throw new DaemonOwnerActiveError("unknown");
5479
+ throw err;
5143
5480
  }
5144
- throw new DaemonOwnerActiveError("unknown");
5481
+ if (!isPipe) await promises.chmod(endpoint, 384).catch(() => void 0);
5482
+ server.unref();
5483
+ let closed = false;
5484
+ return {
5485
+ endpoint,
5486
+ close: async () => {
5487
+ if (closed) return;
5488
+ closed = true;
5489
+ await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
5490
+ if (!isPipe) await promises.rm(endpoint, { force: true }).catch(() => void 0);
5491
+ }
5492
+ };
5145
5493
  }
5146
5494
  async function reclaimExistsAndIsActive(reclaimPath) {
5147
5495
  let stat;
@@ -5257,6 +5605,7 @@ function toRuntimeInfoCapabilities(caps) {
5257
5605
  steer: caps.steer,
5258
5606
  resume: caps.resume,
5259
5607
  approvalInteractive: caps.approvalInteractive,
5608
+ ...caps.mcpToolsets === void 0 ? {} : { mcpToolsets: caps.mcpToolsets },
5260
5609
  permissionModes: caps.permissionModes
5261
5610
  };
5262
5611
  }
@@ -5330,7 +5679,7 @@ var DaemonObserver = class {
5330
5679
  }
5331
5680
  /**
5332
5681
  * Feed a raw INBOUND (server -> daemon) envelope. Deliberately narrow: only
5333
- * `task.offer` produces a local event here — every other inbound type
5682
+ * either offer variant produces a local event here — every other inbound type
5334
5683
  * (`task.cancel`/`task.steer`/`task.approve`/`task.reject`) is a
5335
5684
  * best-effort notification whose OWN observable effect already surfaces
5336
5685
  * through the daemon's outbound envelopes (`task.cancelled`, `task.progress`
@@ -5338,7 +5687,7 @@ var DaemonObserver = class {
5338
5687
  * where those are actually reported from.
5339
5688
  */
5340
5689
  handleInboundEnvelope(envelope) {
5341
- if (envelope.type !== "task.offer") return;
5690
+ if (envelope.type !== "task.offer" && envelope.type !== "task.offer_with_toolsets") return;
5342
5691
  const taskId = envelope.task_id;
5343
5692
  if (this.taskInfo.has(taskId)) return;
5344
5693
  this.upsertTask(taskId, { state: "Offered", runtime: envelope.payload.runtime });
@@ -5459,6 +5808,37 @@ var DaemonObserver = class {
5459
5808
  noteShutdownComplete(reason, undeliveredOutboxCount) {
5460
5809
  this.emit({ kind: "shutdown-complete", ts: nowIso(), reason, undeliveredOutboxCount });
5461
5810
  }
5811
+ /**
5812
+ * Plan `device-assertion-broker`: see the `device-assertion` `DaemonEvent`
5813
+ * variant's own doc comment. The parameter type is what keeps the signature
5814
+ * out — there is no field to pass one through.
5815
+ *
5816
+ * codex round-2 F4: the DENIED caller can pass its raw `audience` here, but
5817
+ * it is converted to a byte SIZE the instant the event is constructed and the
5818
+ * raw string is dropped — it is never placed on the emitted `DaemonEvent`, so
5819
+ * it cannot reach a subscriber, `format.ts`, stdout, or the audit file. The
5820
+ * ISSUED `audience` came from the allowlist and is kept verbatim.
5821
+ */
5822
+ noteDeviceAssertion(event) {
5823
+ if (event.result === "issued") {
5824
+ this.emit({
5825
+ kind: "device-assertion",
5826
+ ts: nowIso(),
5827
+ result: "issued",
5828
+ audience: event.audience,
5829
+ jti: event.jti,
5830
+ expiresAt: event.expiresAt
5831
+ });
5832
+ return;
5833
+ }
5834
+ this.emit({
5835
+ kind: "device-assertion",
5836
+ ts: nowIso(),
5837
+ result: "denied",
5838
+ reason: event.reason,
5839
+ audienceSize: event.audience === void 0 ? void 0 : Buffer.byteLength(event.audience, "utf8")
5840
+ });
5841
+ }
5462
5842
  /** M4 Phase 3 hardening: see the `stale-approval-decision` `DaemonEvent` variant's own doc comment. */
5463
5843
  noteStaleApprovalDecision(taskId, decision, reason) {
5464
5844
  this.emit({ kind: "stale-approval-decision", ts: nowIso(), taskId, decision, reason });
@@ -7097,6 +7477,21 @@ var MAX_INLINE_ARTIFACT_BYTES = 64 * 1024;
7097
7477
  var MAX_TRACKED_TASK_IDS = 2e3;
7098
7478
  var MAX_DURATION_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxDurationMs";
7099
7479
  var MAX_OUTPUT_BYTES_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxTaskOutputBytes";
7480
+ var RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX = "result document undeliverable";
7481
+ function resultDocumentRejectionDetail(check) {
7482
+ switch (check.reason) {
7483
+ case "over-cap":
7484
+ return `${check.bytes} bytes as canonical JSON, over the ${RESULT_DOCUMENT_MAX_BYTES}-byte limit (it is never truncated \u2014 a truncated JSON document is not valid JSON; use artifactRefs for a result this size)`;
7485
+ case "not-serializable":
7486
+ return "not JSON-serializable (JSON.stringify threw, or produced no output at all)";
7487
+ case "not-plain-json":
7488
+ return "not plain JSON data: it does not equal its own JSON round trip, so serializing it would silently change it (an undefined-valued key, NaN, a function or symbol value, a Date, a toJSON that rewrites the value, or a getter that answers differently on a second read)";
7489
+ default: {
7490
+ const exhaustive = check;
7491
+ throw new Error(`unhandled result document rejection: ${JSON.stringify(exhaustive)}`);
7492
+ }
7493
+ }
7494
+ }
7100
7495
  var DEFAULT_MAX_TASK_OUTPUT_BYTES = 64 * 1024 * 1024;
7101
7496
  function isKnownRuntimeId(id) {
7102
7497
  return RuntimeIdSchema.safeParse(id).success;
@@ -7109,6 +7504,14 @@ function orderByPreference(candidates, preference) {
7109
7504
  function adapterSupportsMode(adapter, mode) {
7110
7505
  return adapter.capabilities().permissionModes.includes(mode);
7111
7506
  }
7507
+ function adapterSupportsMcpToolsets(adapter) {
7508
+ return adapter.capabilities().mcpToolsets === true;
7509
+ }
7510
+ function withoutRequiredToolsets(payload) {
7511
+ if (!("requiredToolsets" in payload)) return payload;
7512
+ const { requiredToolsets, ...offer } = payload;
7513
+ return offer;
7514
+ }
7112
7515
  function errorMessage3(err) {
7113
7516
  return err instanceof Error ? err.message : String(err);
7114
7517
  }
@@ -7464,6 +7867,9 @@ var TaskRunner = class {
7464
7867
  case "task.offer":
7465
7868
  await this.handleOffer(envelope.task_id, envelope.payload);
7466
7869
  return;
7870
+ case "task.offer_with_toolsets":
7871
+ await this.handleOffer(envelope.task_id, envelope.payload);
7872
+ return;
7467
7873
  case "task.cancel":
7468
7874
  await this.handleCancel(envelope.task_id, envelope.payload.reason);
7469
7875
  return;
@@ -7517,7 +7923,22 @@ var TaskRunner = class {
7517
7923
  );
7518
7924
  return;
7519
7925
  }
7520
- const pick = await this.pickAdapter(payload.runtime, payload.policy.mode);
7926
+ if (payload.dispatchSelection !== void 0 && payload.runtime !== void 0 && payload.runtime !== payload.dispatchSelection.runtimeId) {
7927
+ this.decline(
7928
+ taskId,
7929
+ `offer runtime ${payload.runtime} does not match dispatchSelection.runtimeId ${payload.dispatchSelection.runtimeId}`,
7930
+ false
7931
+ );
7932
+ return;
7933
+ }
7934
+ const requiredToolsets = "requiredToolsets" in payload ? payload.requiredToolsets : void 0;
7935
+ const resolvedMcp = requiredToolsets ? this.resolveMcpServers(requiredToolsets) : void 0;
7936
+ if (resolvedMcp && !resolvedMcp.ok) {
7937
+ this.decline(taskId, resolvedMcp.reason, true);
7938
+ return;
7939
+ }
7940
+ const requestedRuntime = payload.dispatchSelection?.runtimeId ?? payload.runtime;
7941
+ const pick = await this.pickAdapter(requestedRuntime, payload.policy.mode, requiredToolsets !== void 0);
7521
7942
  if (!pick.ok) {
7522
7943
  this.decline(taskId, pick.reason, pick.retryable);
7523
7944
  return;
@@ -7680,6 +8101,7 @@ var TaskRunner = class {
7680
8101
  const ctx = {
7681
8102
  workspaceDir,
7682
8103
  policy: decision.policy,
8104
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {},
7683
8105
  ...gitWorkspaceId ? { gitWorkspace: { workspaceId: gitWorkspaceId, baseline: gitBaseline } } : {},
7684
8106
  // M5: no longer `process.env` verbatim (see `environment.ts`'s own
7685
8107
  // module doc comment for the credential-leak gap that closed) —
@@ -7716,7 +8138,7 @@ var TaskRunner = class {
7716
8138
  }
7717
8139
  };
7718
8140
  const effectiveOffer = {
7719
- ...payload,
8141
+ ...withoutRequiredToolsets(payload),
7720
8142
  instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
7721
8143
  // Never forward a sessionRef this device has no recorded workspace
7722
8144
  // for (stale, from another device, or simply made up) — an adapter
@@ -7794,6 +8216,36 @@ var TaskRunner = class {
7794
8216
  if (typeof instruction === "string") return instruction;
7795
8217
  return this.deps.blobClient.resolveInstruction(instruction.blobRef);
7796
8218
  }
8219
+ /** Resolve every requested logical id locally and reject missing/colliding server authority before claim. */
8220
+ resolveMcpServers(requiredToolsets) {
8221
+ const registry = this.deps.mcpToolsets;
8222
+ if (!registry) {
8223
+ return { ok: false, reason: "offer requires MCP toolsets, but this device has no local mcpToolsets registry" };
8224
+ }
8225
+ const servers = /* @__PURE__ */ Object.create(null);
8226
+ for (const toolsetId of requiredToolsets) {
8227
+ const toolset = registry.get(toolsetId);
8228
+ if (!toolset) {
8229
+ return { ok: false, reason: `required MCP toolset "${toolsetId}" is not configured on this device` };
8230
+ }
8231
+ for (const [serverName, server] of Object.entries(toolset.mcpServers)) {
8232
+ if (Object.prototype.hasOwnProperty.call(servers, serverName)) {
8233
+ return {
8234
+ ok: false,
8235
+ reason: `required MCP toolsets collide on server name "${serverName}"; refusing ambiguous projection`
8236
+ };
8237
+ }
8238
+ servers[serverName] = Object.freeze({
8239
+ command: server.command,
8240
+ ...server.args ? { args: Object.freeze([...server.args]) } : {}
8241
+ });
8242
+ }
8243
+ }
8244
+ if (Object.keys(servers).length === 0) {
8245
+ return { ok: false, reason: "required MCP toolsets resolved to no servers; refusing to run without tools" };
8246
+ }
8247
+ return { ok: true, servers: Object.freeze(servers) };
8248
+ }
7797
8249
  async pump(active) {
7798
8250
  try {
7799
8251
  for await (const event of active.session.events) {
@@ -7827,11 +8279,38 @@ var TaskRunner = class {
7827
8279
  if (event.type === "turn_end") {
7828
8280
  active.batcher.push(event);
7829
8281
  active.batcher.flush();
8282
+ const finalOutput = active.summaryParts.join("");
8283
+ const outcome = await this.resolveResultDocument(active, finalOutput);
8284
+ if (!outcome.deliver) return;
7830
8285
  await this.observeGit(active, "completed");
8286
+ if (outcome.document !== void 0 && !this.hasResultDocumentCapability()) {
8287
+ await this.fail(
8288
+ active.taskId,
8289
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the connected server stopped advertising the result-document capability before this completion could be sent (a reconnect to an older server), so it would silently discard this document`,
8290
+ false
8291
+ );
8292
+ return;
8293
+ }
7831
8294
  this.deps.send(
7832
8295
  createEnvelope(
7833
8296
  "task.complete",
7834
- { summary: active.summaryParts.join(""), sessionRef: active.session.sessionRef },
8297
+ {
8298
+ summary: finalOutput,
8299
+ sessionRef: active.session.sessionRef,
8300
+ // Spread rather than `document: outcome.document`, so a
8301
+ // completion with no document is the exact same payload it
8302
+ // was before this field existed — not one carrying an
8303
+ // explicit `document: undefined` key.
8304
+ //
8305
+ // `outcome.document` is the protocol's CANONICAL SNAPSHOT
8306
+ // (`checkResultDocument`), never the object the extractor
8307
+ // returned: pure data serializes identically at the root
8308
+ // (where it was measured) and nested inside this payload
8309
+ // (where the codec actually serializes it), so a contextual
8310
+ // `toJSON(key)` or an unstable getter cannot make the wire
8311
+ // bytes differ from what the cap gate approved.
8312
+ ...outcome.document !== void 0 ? { document: outcome.document } : {}
8313
+ },
7835
8314
  { taskId: active.taskId, sessionRef: active.session.sessionRef }
7836
8315
  )
7837
8316
  );
@@ -8369,6 +8848,102 @@ var TaskRunner = class {
8369
8848
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId }));
8370
8849
  await this.finish(taskId);
8371
8850
  }
8851
+ /**
8852
+ * additive-minor (`task.complete.document`): the whole daemon-side gate
8853
+ * between a configured {@link ResultDocumentExtractor} and the wire —
8854
+ * called once, from the `turn_end` completion path, immediately before
8855
+ * `task.complete` is built.
8856
+ *
8857
+ * `{deliver: true}` means "go on and send `task.complete`", carrying the
8858
+ * document when there is one. `{deliver: false}` means this method has
8859
+ * ALREADY reported `task.fail` and finished the task; the caller must
8860
+ * return without sending anything further.
8861
+ *
8862
+ * Four fail-closed branches, all `retryable: false` (see
8863
+ * {@link RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX} for why none of them
8864
+ * can succeed on a retry):
8865
+ *
8866
+ * 1. The extractor threw — its error is surfaced, never swallowed.
8867
+ * 2. The extractor returned a thenable, violating the synchronous
8868
+ * contract in the one way that would otherwise ship a wrong answer.
8869
+ * 3. The document is over the cap, not JSON-serializable, or not plain
8870
+ * JSON data, per `checkResultDocument` — the protocol's OWN check,
8871
+ * imported rather than reimplemented, so this gate and the server's
8872
+ * schema validation can never disagree about what is legal.
8873
+ * 4. The connected server never advertised `result-document`. Its
8874
+ * tolerant `z.object()` would silently strip the field on arrival
8875
+ * (`version.ts`'s own flag doc comment), so "send anyway" is not a
8876
+ * degraded-but-working path — it is the task's primary structured
8877
+ * result being deleted in transit with nothing reported anywhere.
8878
+ *
8879
+ * The capability is checked LAST, deliberately: a document that is itself
8880
+ * invalid is the host's own bug and is worth reporting as such even when
8881
+ * the connected server could not have accepted any document at all. It is
8882
+ * then re-checked once more by the caller after its own last await, since
8883
+ * a reconnect can invalidate this answer in between (F3).
8884
+ *
8885
+ * **Residual window (bounded, deliberately not hacked around).** Even the
8886
+ * caller's re-check happens before `ConnectionManager.send` hands the
8887
+ * envelope to a transport, and a queued envelope can outlive the
8888
+ * connection it was queued for: a reconnect between `send()` and the
8889
+ * outbox actually draining could still deliver this `task.complete` to a
8890
+ * rolled-back N-1 server that strips the document. Closing that would
8891
+ * mean teaching the transport outbox to inspect payload semantics and
8892
+ * mint a substitute `task.fail` for a task this runner already finished —
8893
+ * a second authority over terminal outcomes living in the queue, which is
8894
+ * worse than the window it closes. Documented instead, here and in
8895
+ * docs/protocol.md §7.2.
8896
+ */
8897
+ async resolveResultDocument(active, finalOutput) {
8898
+ const extract = this.deps.resultDocument?.extract;
8899
+ if (!extract) return { deliver: true };
8900
+ let document;
8901
+ try {
8902
+ document = extract(finalOutput, { taskId: active.taskId, sessionRef: active.session.sessionRef });
8903
+ } catch (err) {
8904
+ await this.fail(
8905
+ active.taskId,
8906
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage3(err)}`,
8907
+ false
8908
+ );
8909
+ return { deliver: false };
8910
+ }
8911
+ if (typeof document?.then === "function") {
8912
+ await this.fail(
8913
+ active.taskId,
8914
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract returned a promise; the contract is synchronous (an awaited value is never read, and a promise encodes to an empty document)`,
8915
+ false
8916
+ );
8917
+ return { deliver: false };
8918
+ }
8919
+ if (document === void 0) return { deliver: true };
8920
+ const check = checkResultDocument(document);
8921
+ if (!check.ok) {
8922
+ const detail = resultDocumentRejectionDetail(check);
8923
+ await this.fail(active.taskId, `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: ${detail}`, false);
8924
+ return { deliver: false };
8925
+ }
8926
+ if (!this.hasResultDocumentCapability()) {
8927
+ await this.fail(
8928
+ active.taskId,
8929
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the connected server did not advertise the result-document capability, so it would silently discard this ${check.bytes}-byte document`,
8930
+ false
8931
+ );
8932
+ return { deliver: false };
8933
+ }
8934
+ return { deliver: true, document: check.canonical };
8935
+ }
8936
+ /**
8937
+ * Whether the CURRENTLY connected server advertised `result-document` —
8938
+ * read fresh on every call, never captured, because the answer changes
8939
+ * across a reconnect (`ConnectionManager.getServerCapabilities` returns
8940
+ * `[]` from the moment an acked connection closes until a fresh
8941
+ * `conn.ack` repopulates it). An absent `getServerCapabilities` seam is
8942
+ * "no capabilities", the fail-closed reading.
8943
+ */
8944
+ hasResultDocumentCapability() {
8945
+ return (this.deps.getServerCapabilities?.() ?? []).includes("result-document");
8946
+ }
8372
8947
  async observeGit(active, phase) {
8373
8948
  if (!active.gitWorkspaceId || !this.deps.gitWorkspaceManager || !this.deps.gitWorkspaceStore) return;
8374
8949
  try {
@@ -8471,7 +9046,7 @@ var TaskRunner = class {
8471
9046
  * is device-specific (which runtimes happen to be installed here), so a
8472
9047
  * different device's installed runtime set might satisfy it.
8473
9048
  */
8474
- async pickAdapter(requestedRuntime, policyMode) {
9049
+ async pickAdapter(requestedRuntime, policyMode, requiresMcpToolsets) {
8475
9050
  const allowlist = this.deps.runtimeAllowlist;
8476
9051
  if (requestedRuntime) {
8477
9052
  if (allowlist && !allowlist.includes(requestedRuntime)) {
@@ -8492,6 +9067,13 @@ var TaskRunner = class {
8492
9067
  retryable: false
8493
9068
  };
8494
9069
  }
9070
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) {
9071
+ return {
9072
+ ok: false,
9073
+ reason: `runtime "${requestedRuntime}" cannot project required MCP toolsets`,
9074
+ retryable: false
9075
+ };
9076
+ }
8495
9077
  const detected = await adapter.detect();
8496
9078
  if (!detected.present) {
8497
9079
  return {
@@ -8506,12 +9088,13 @@ var TaskRunner = class {
8506
9088
  const candidates = orderByPreference(eligible, this.deps.runtimePreference ?? DEFAULT_RUNTIME_PREFERENCE);
8507
9089
  for (const adapter of candidates) {
8508
9090
  if (!adapterSupportsMode(adapter, policyMode)) continue;
9091
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) continue;
8509
9092
  const detected = await adapter.detect();
8510
9093
  if (detected.present) return { ok: true, adapter };
8511
9094
  }
8512
9095
  return {
8513
9096
  ok: false,
8514
- reason: `no available runtime on this device can express permission mode "${policyMode}"`,
9097
+ reason: requiresMcpToolsets ? `no available runtime on this device can express permission mode "${policyMode}" with required MCP toolsets` : `no available runtime on this device can express permission mode "${policyMode}"`,
8515
9098
  retryable: true
8516
9099
  };
8517
9100
  }
@@ -8542,7 +9125,7 @@ function toJournalEnvelopeRecord(envelope, identity) {
8542
9125
  bytes,
8543
9126
  bytesHash: journalHash(bytes),
8544
9127
  receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
8545
- opensTask: envelope.type === "task.offer"
9128
+ opensTask: envelope.type === "task.offer" || envelope.type === "task.offer_with_toolsets"
8546
9129
  };
8547
9130
  }
8548
9131
  function isRuntimeId(id) {
@@ -8566,53 +9149,240 @@ function computeCapabilities(adapters) {
8566
9149
  if (adapters.some((adapter) => adapter.capabilities().steer)) flags.push("steer");
8567
9150
  flags.push("blob-upload");
8568
9151
  flags.push("approval-targeting");
9152
+ const selectionAdapters = adapters.filter(
9153
+ (adapter) => ALL_RUNTIME_IDS.includes(adapter.id)
9154
+ );
9155
+ if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.supportsDispatchSelection === true)) {
9156
+ flags.push("dispatch-selection");
9157
+ }
9158
+ if (adapters.some((adapter) => adapter.capabilities().mcpToolsets === true)) {
9159
+ flags.push("toolset-selection");
9160
+ }
8569
9161
  return flags;
8570
9162
  }
8571
9163
  var ALL_RUNTIME_IDS = ["pi", "claude", "codex"];
8572
- function buildAdapter(id) {
9164
+ function buildAdapter(id, config) {
8573
9165
  switch (id) {
8574
9166
  case "pi":
8575
- return new PiAdapter();
9167
+ return new PiAdapter({ byokLauncher: config.piByokLauncher });
8576
9168
  case "claude":
8577
9169
  return new ClaudeAdapter();
8578
9170
  case "codex":
8579
9171
  return new CodexAdapter();
8580
9172
  }
8581
9173
  }
8582
- function buildDefaultAdapters(runtimeAllowlist) {
8583
- const ids = runtimeAllowlist ? ALL_RUNTIME_IDS.filter((id) => runtimeAllowlist.includes(id)) : ALL_RUNTIME_IDS;
8584
- return ids.map(buildAdapter);
9174
+ function buildDefaultAdapters(config) {
9175
+ const ids = config.runtimeAllowlist ? ALL_RUNTIME_IDS.filter((id) => config.runtimeAllowlist?.includes(id)) : ALL_RUNTIME_IDS;
9176
+ return ids.map((id) => buildAdapter(id, config));
8585
9177
  }
8586
- function createDaemonWithAdapters(config, adapters, overrides = {}) {
8587
- if (config.maxTaskOutputBytes !== void 0 && !(config.maxTaskOutputBytes > 0)) {
9178
+ function validatePiByokLauncherConfig(launcher) {
9179
+ for (const [field, value] of [
9180
+ ["command", launcher.command],
9181
+ ["profileDbPath", launcher.profileDbPath],
9182
+ ["sessionDir", launcher.sessionDir]
9183
+ ]) {
9184
+ if (value.trim().length === 0 || /[\u0000\r\n]/u.test(value)) {
9185
+ throw new Error(`DaemonConfig.piByokLauncher.${field} must be a non-empty single-line string`);
9186
+ }
9187
+ }
9188
+ if (!isAbsolute(launcher.profileDbPath) || !isAbsolute(launcher.sessionDir)) {
8588
9189
  throw new Error(
8589
- `DaemonConfig.maxTaskOutputBytes must be a positive number (or omitted to use the ${DEFAULT_MAX_TASK_OUTPUT_BYTES}-byte default) \u2014 got ${config.maxTaskOutputBytes}. Pass Number.POSITIVE_INFINITY to explicitly disable the cap; 0 or a negative number is rejected rather than silently treated as "disabled".`
9190
+ "DaemonConfig.piByokLauncher profileDbPath and sessionDir must be absolute paths"
8590
9191
  );
8591
9192
  }
8592
- const storeDir = DeviceStore.resolveDir(config.productId, config.storeDir);
8593
- const store = new DeviceStore(storeDir);
8594
- const operationalHealth = new OperationalHealthTracker(storeDir);
8595
- let fleetJitter;
8596
- let maintenanceSequence = 0;
8597
- const cursorStore = new CursorStore(storeDir);
8598
- const sessionWorkspaces = new SessionWorkspaceStore(storeDir);
8599
- const gitWorkspaceManager = config.gitWorkspace ? overrides.gitWorkspace?.manager ?? new GitWorkspaceManager(config.workspaceRoot, { ownerId: stableGitWorkspaceOwnerId(storeDir, config.productId) }) : void 0;
8600
- const gitWorkspaceStore = config.gitWorkspace ? overrides.gitWorkspace?.store ?? new GitWorkspaceStore(storeDir) : void 0;
8601
- let resolvedStoragePolicy;
8602
- if (config.hostedJournal) {
8603
- if (config.hostedJournal.mode !== "sqlite") {
8604
- throw new Error(`DaemonConfig.hostedJournal.mode must be "sqlite" \u2014 got ${JSON.stringify(config.hostedJournal.mode)}`);
8605
- }
8606
- if (typeof config.hostedJournal.tenantId !== "string" || config.hostedJournal.tenantId.trim() === "") {
8607
- throw new Error(
8608
- "DaemonConfig.hostedJournal.tenantId must be a non-empty tenant id \u2014 a hosted journal row with no tenant is durable evidence nobody can act on"
8609
- );
8610
- }
8611
- if (config.hostedJournal.storagePolicy) {
8612
- resolvedStoragePolicy = resolveLocalStoragePolicy(config.hostedJournal.storagePolicy);
8613
- }
9193
+ if (launcher.secretServicePrefix !== void 0 && (launcher.secretServicePrefix.trim().length === 0 || /[\u0000\r\n]/u.test(launcher.secretServicePrefix))) {
9194
+ throw new Error(
9195
+ "DaemonConfig.piByokLauncher.secretServicePrefix must be a non-empty single-line string"
9196
+ );
8614
9197
  }
8615
- if (config.hostedJournal && overrides.hostedJournal?.journal === void 0 && !isSqliteAvailable()) {
9198
+ const reserved = /* @__PURE__ */ new Set([
9199
+ "--",
9200
+ "--pi-bin",
9201
+ "--profile-db",
9202
+ "--session-dir",
9203
+ "--secret-service-prefix",
9204
+ "--provider",
9205
+ "--model"
9206
+ ]);
9207
+ const conflicting = launcher.args?.find((arg) => reserved.has(arg));
9208
+ if (conflicting !== void 0) {
9209
+ throw new Error(
9210
+ `DaemonConfig.piByokLauncher.args must not override reserved launcher argument ${conflicting}`
9211
+ );
9212
+ }
9213
+ const invalidArg = launcher.args?.find((arg) => arg.length === 0 || /[\u0000\r\n]/u.test(arg));
9214
+ if (invalidArg !== void 0) {
9215
+ throw new Error("DaemonConfig.piByokLauncher.args must contain only non-empty single-line strings");
9216
+ }
9217
+ }
9218
+ var MAX_LOCAL_MCP_TOOLSETS = 64;
9219
+ var MAX_LOCAL_MCP_SERVERS_PER_TOOLSET = 16;
9220
+ var MAX_LOCAL_MCP_ARGS = 64;
9221
+ var MAX_LOCAL_MCP_TOKEN_CHARS = 4096;
9222
+ function isNonEmptySingleLine(value) {
9223
+ return typeof value === "string" && value.trim().length > 0 && value.length <= MAX_LOCAL_MCP_TOKEN_CHARS && !/[\u0000\r\n]/u.test(value);
9224
+ }
9225
+ function resolveMcpToolsets(configured) {
9226
+ if (configured === void 0) return void 0;
9227
+ if (configured === null || typeof configured !== "object" || Array.isArray(configured)) {
9228
+ throw new Error("DaemonConfig.mcpToolsets must be an object keyed by logical toolset id");
9229
+ }
9230
+ const toolsetEntries = Object.entries(configured);
9231
+ if (toolsetEntries.length > MAX_LOCAL_MCP_TOOLSETS) {
9232
+ throw new Error(`DaemonConfig.mcpToolsets may contain at most ${MAX_LOCAL_MCP_TOOLSETS} toolsets`);
9233
+ }
9234
+ const resolved = /* @__PURE__ */ new Map();
9235
+ for (const [toolsetId, rawToolset] of toolsetEntries) {
9236
+ const parsedId = ToolsetIdSchema.safeParse(toolsetId);
9237
+ if (!parsedId.success) {
9238
+ throw new Error(`DaemonConfig.mcpToolsets contains invalid toolset id ${JSON.stringify(toolsetId)}`);
9239
+ }
9240
+ if (rawToolset === null || typeof rawToolset !== "object" || Array.isArray(rawToolset)) {
9241
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId} must be an object`);
9242
+ }
9243
+ const toolsetKeys = Object.keys(rawToolset);
9244
+ if (toolsetKeys.some((key) => key !== "mcpServers")) {
9245
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId} accepts only the mcpServers field`);
9246
+ }
9247
+ const rawServers = rawToolset.mcpServers;
9248
+ if (rawServers === null || typeof rawServers !== "object" || Array.isArray(rawServers)) {
9249
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId}.mcpServers must be an object`);
9250
+ }
9251
+ const serverEntries = Object.entries(rawServers);
9252
+ if (serverEntries.length === 0 || serverEntries.length > MAX_LOCAL_MCP_SERVERS_PER_TOOLSET) {
9253
+ throw new Error(
9254
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers must contain 1-${MAX_LOCAL_MCP_SERVERS_PER_TOOLSET} servers`
9255
+ );
9256
+ }
9257
+ const servers = {};
9258
+ for (const [serverName, rawServer] of serverEntries) {
9259
+ if (!ToolsetIdSchema.safeParse(serverName).success) {
9260
+ throw new Error(
9261
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers contains invalid server name ${JSON.stringify(serverName)}`
9262
+ );
9263
+ }
9264
+ if (serverName === APPROVAL_MCP_SERVER_NAME) {
9265
+ throw new Error(
9266
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName} uses a server name reserved by the daemon`
9267
+ );
9268
+ }
9269
+ if (rawServer === null || typeof rawServer !== "object" || Array.isArray(rawServer)) {
9270
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName} must be an object`);
9271
+ }
9272
+ const serverKeys = Object.keys(rawServer);
9273
+ if (serverKeys.some((key) => key !== "command" && key !== "args")) {
9274
+ throw new Error(
9275
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName} accepts only command and args; env, headers, and remote task data are not supported`
9276
+ );
9277
+ }
9278
+ const server = rawServer;
9279
+ if (!isNonEmptySingleLine(server.command)) {
9280
+ throw new Error(
9281
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName}.command must be a non-empty single-line string no longer than ${MAX_LOCAL_MCP_TOKEN_CHARS} characters`
9282
+ );
9283
+ }
9284
+ if (server.args !== void 0 && !Array.isArray(server.args)) {
9285
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName}.args must be an array`);
9286
+ }
9287
+ const args = server.args ?? [];
9288
+ if (args.length > MAX_LOCAL_MCP_ARGS || args.some((arg) => !isNonEmptySingleLine(arg))) {
9289
+ throw new Error(
9290
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName}.args must contain at most ${MAX_LOCAL_MCP_ARGS} non-empty single-line strings`
9291
+ );
9292
+ }
9293
+ servers[serverName] = Object.freeze({
9294
+ command: server.command,
9295
+ ...args.length > 0 ? { args: Object.freeze([...args]) } : {}
9296
+ });
9297
+ }
9298
+ resolved.set(toolsetId, Object.freeze({ mcpServers: Object.freeze(servers) }));
9299
+ }
9300
+ return resolved;
9301
+ }
9302
+ function resolveDeviceAssertionAudiences(config) {
9303
+ if (config === void 0) return void 0;
9304
+ if (!Array.isArray(config.audiences)) {
9305
+ throw new Error(
9306
+ `DaemonConfig.deviceAssertion.audiences must be an array of exact audience strings \u2014 got ${JSON.stringify(config.audiences)}. Omit the deviceAssertion section (or pass an empty array) to leave the assertion broker disabled.`
9307
+ );
9308
+ }
9309
+ if (config.audiences.length === 0) return void 0;
9310
+ const audiences = /* @__PURE__ */ new Set();
9311
+ for (const audience of config.audiences) {
9312
+ if (typeof audience !== "string" || audience.length === 0) {
9313
+ throw new Error(
9314
+ `DaemonConfig.deviceAssertion.audiences entries must be non-empty strings \u2014 got ${JSON.stringify(audience)}`
9315
+ );
9316
+ }
9317
+ if (Buffer.byteLength(audience, "utf8") > DEVICE_ASSERTION_AUDIENCE_MAX_BYTES) {
9318
+ throw new Error(
9319
+ `DaemonConfig.deviceAssertion.audiences entry ${JSON.stringify(audience)} exceeds ${DEVICE_ASSERTION_AUDIENCE_MAX_BYTES} UTF-8 bytes`
9320
+ );
9321
+ }
9322
+ if (audiences.has(audience)) {
9323
+ throw new Error(
9324
+ `DaemonConfig.deviceAssertion.audiences contains ${JSON.stringify(audience)} twice \u2014 rejected rather than de-duplicated, because a duplicate is usually a copy-paste that hid a typo in the entry that was meant to be different`
9325
+ );
9326
+ }
9327
+ audiences.add(audience);
9328
+ }
9329
+ return audiences;
9330
+ }
9331
+ function resolveDeviceAssertionTtlMs(config) {
9332
+ const ttlMs = config?.ttlMs ?? DEVICE_ASSERTION_DEFAULT_TTL_MS;
9333
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > DEVICE_ASSERTION_MAX_TTL_MS) {
9334
+ throw new Error(
9335
+ `DaemonConfig.deviceAssertion.ttlMs must be a positive integer no greater than ${DEVICE_ASSERTION_MAX_TTL_MS} ms \u2014 got ${JSON.stringify(config?.ttlMs)}`
9336
+ );
9337
+ }
9338
+ return ttlMs;
9339
+ }
9340
+ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9341
+ return buildDaemonWithAdapters(config, adapters, overrides);
9342
+ }
9343
+ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProbe) {
9344
+ const mcpToolsets = resolveMcpToolsets(config.mcpToolsets);
9345
+ if (config.piByokLauncher !== void 0) {
9346
+ validatePiByokLauncherConfig(config.piByokLauncher);
9347
+ }
9348
+ if (config.maxTaskOutputBytes !== void 0 && !(config.maxTaskOutputBytes > 0)) {
9349
+ throw new Error(
9350
+ `DaemonConfig.maxTaskOutputBytes must be a positive number (or omitted to use the ${DEFAULT_MAX_TASK_OUTPUT_BYTES}-byte default) \u2014 got ${config.maxTaskOutputBytes}. Pass Number.POSITIVE_INFINITY to explicitly disable the cap; 0 or a negative number is rejected rather than silently treated as "disabled".`
9351
+ );
9352
+ }
9353
+ const presenceCadence = {
9354
+ intervalMs: config.presence?.intervalMs ?? DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS,
9355
+ ttlMs: config.presence?.ttlMs ?? DEFAULT_PRESENCE_TTL_MS,
9356
+ minimumIntervalMs: config.presence?.minimumIntervalMs ?? DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS
9357
+ };
9358
+ assertPresenceHeartbeatCadence(presenceCadence);
9359
+ const deviceAssertionAudiences = resolveDeviceAssertionAudiences(config.deviceAssertion);
9360
+ const deviceAssertionTtlMs = resolveDeviceAssertionTtlMs(config.deviceAssertion);
9361
+ let shuttingDown = false;
9362
+ const storeDir = DeviceStore.resolveDir(config.productId, config.storeDir);
9363
+ const store = new DeviceStore(storeDir);
9364
+ const operationalHealth = new OperationalHealthTracker(storeDir);
9365
+ let fleetJitter;
9366
+ let maintenanceSequence = 0;
9367
+ const cursorStore = new CursorStore(storeDir);
9368
+ const sessionWorkspaces = new SessionWorkspaceStore(storeDir);
9369
+ const gitWorkspaceManager = config.gitWorkspace ? overrides.gitWorkspace?.manager ?? new GitWorkspaceManager(config.workspaceRoot, { ownerId: stableGitWorkspaceOwnerId(storeDir, config.productId) }) : void 0;
9370
+ const gitWorkspaceStore = config.gitWorkspace ? overrides.gitWorkspace?.store ?? new GitWorkspaceStore(storeDir) : void 0;
9371
+ let resolvedStoragePolicy;
9372
+ if (config.hostedJournal) {
9373
+ if (config.hostedJournal.mode !== "sqlite") {
9374
+ throw new Error(`DaemonConfig.hostedJournal.mode must be "sqlite" \u2014 got ${JSON.stringify(config.hostedJournal.mode)}`);
9375
+ }
9376
+ if (typeof config.hostedJournal.tenantId !== "string" || config.hostedJournal.tenantId.trim() === "") {
9377
+ throw new Error(
9378
+ "DaemonConfig.hostedJournal.tenantId must be a non-empty tenant id \u2014 a hosted journal row with no tenant is durable evidence nobody can act on"
9379
+ );
9380
+ }
9381
+ if (config.hostedJournal.storagePolicy) {
9382
+ resolvedStoragePolicy = resolveLocalStoragePolicy(config.hostedJournal.storagePolicy);
9383
+ }
9384
+ }
9385
+ if (config.hostedJournal && overrides.hostedJournal?.journal === void 0 && !isSqliteAvailable()) {
8616
9386
  throw new JournalUnavailableError(`node:sqlite could not be loaded on Node ${process.versions.node}`);
8617
9387
  }
8618
9388
  let ownedJournal;
@@ -8679,6 +9449,9 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8679
9449
  let runner;
8680
9450
  let controlServerHandle;
8681
9451
  let daemonOwnerLease;
9452
+ let presencePublisher;
9453
+ let presenceDiscovery;
9454
+ let presenceDiscoveryInFlight = false;
8682
9455
  let shutdownPromise;
8683
9456
  const pendingLateMutationBarriers = /* @__PURE__ */ new Set();
8684
9457
  let startedAt;
@@ -8851,6 +9624,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8851
9624
  deviceId: record.deviceId,
8852
9625
  // M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment above.
8853
9626
  runtimeEnvironment: config.runtimeEnvironment,
9627
+ ...mcpToolsets ? { mcpToolsets } : {},
8854
9628
  // M3-2a: `send` is already this file's OWN closure (not something
8855
9629
  // `TaskRunner` builds) — every `task.claim`/`task.started`/
8856
9630
  // `task.progress`/`task.artifact`/`task.await_approval`/
@@ -8890,6 +9664,11 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8890
9664
  shutdownInterruptTimeoutMs: overrides.shutdown?.taskInterruptTimeoutMs,
8891
9665
  // M5 batch-3 (workstream 2): see DaemonConfig.maxTaskOutputBytes's own doc comment — already validated above.
8892
9666
  maxTaskOutputBytes: config.maxTaskOutputBytes,
9667
+ // additive-minor (`task.complete.document`): passed through verbatim,
9668
+ // absent when unconfigured — see `DaemonConfig.resultDocument`'s own
9669
+ // doc comment. Spread rather than assigned so an unconfigured daemon
9670
+ // builds the exact `deps` object it did before this seam existed.
9671
+ ...config.resultDocument ? { resultDocument: config.resultDocument } : {},
8893
9672
  // M4 Phase 3 hardening: bridges TaskRunner's stale-approval-race
8894
9673
  // finding out to the SAME local observability seam every other
8895
9674
  // daemon-local event already uses (see observer.ts's own module doc
@@ -8965,8 +9744,10 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8965
9744
  return runner?.handleEnvelope(envelope) ?? Promise.resolve();
8966
9745
  },
8967
9746
  onStateChange: (state) => {
9747
+ const wasSettled = connectionState === "open" || connectionState === "degraded";
8968
9748
  connectionState = state;
8969
9749
  observer.noteConnectionState(state);
9750
+ if (!wasSettled && (state === "open" || state === "degraded")) runPresenceDiscovery();
8970
9751
  },
8971
9752
  backoff: overrides.backoff,
8972
9753
  liveness: overrides.liveness,
@@ -8984,6 +9765,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8984
9765
  });
8985
9766
  await connection.start();
8986
9767
  await connection.waitForAck();
9768
+ startPresenceProducer();
8987
9769
  } catch (err) {
8988
9770
  try {
8989
9771
  await runShutdownSequence("startup failed", { drainTimeoutMs: 0 });
@@ -8993,7 +9775,41 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8993
9775
  throw err;
8994
9776
  }
8995
9777
  }
9778
+ function startPresenceProducer() {
9779
+ presenceDiscovery = new AbortController();
9780
+ runPresenceDiscovery();
9781
+ }
9782
+ function runPresenceDiscovery() {
9783
+ const discovery = presenceDiscovery;
9784
+ if (!discovery || presenceDiscoveryInFlight) return;
9785
+ presenceDiscoveryInFlight = true;
9786
+ void (async () => {
9787
+ try {
9788
+ const declaration = await fetchCapabilityDeclaration(config.serverUrl, { signal: discovery.signal });
9789
+ if (discovery.signal.aborted) return;
9790
+ if (declares(declaration, PRESENCE_HINTS_CAPABILITY)) {
9791
+ presencePublisher ??= new PresencePublisher({
9792
+ serverUrl: config.serverUrl,
9793
+ auth,
9794
+ ...presenceCadence,
9795
+ onDegraded: (reason) => console.warn(`[byok/client] ${reason}`)
9796
+ });
9797
+ presencePublisher.start();
9798
+ } else {
9799
+ presencePublisher?.stop();
9800
+ }
9801
+ } catch (err) {
9802
+ if (discovery.signal.aborted) return;
9803
+ console.warn(
9804
+ `[byok/client] capability discovery failed; presence publishing stays off until the next reconnect: ${err instanceof Error ? err.message : String(err)}`
9805
+ );
9806
+ } finally {
9807
+ presenceDiscoveryInFlight = false;
9808
+ }
9809
+ })();
9810
+ }
8996
9811
  async function runShutdownSequence(reason, opts = {}) {
9812
+ shuttingDown = true;
8997
9813
  const errors = [];
8998
9814
  let mutationBarrierComplete = hostedStorageInitializationBarrierComplete;
8999
9815
  if (!hostedStorageInitializationBarrierComplete) {
@@ -9022,6 +9838,11 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9022
9838
  errors.push(new Error("a prior active task teardown remains unsettled; ownership lease retained"));
9023
9839
  }
9024
9840
  }
9841
+ presenceDiscovery?.abort();
9842
+ presenceDiscovery = void 0;
9843
+ presenceDiscoveryInFlight = false;
9844
+ presencePublisher?.stop();
9845
+ presencePublisher = void 0;
9025
9846
  const stoppingOwnedPressureEngine = ownedPressureEngine;
9026
9847
  const stoppingOwnedJournal = ownedJournal;
9027
9848
  const maintenanceStopped = stoppingOwnedPressureEngine?.stop() ?? Promise.resolve();
@@ -9082,6 +9903,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9082
9903
  }
9083
9904
  }
9084
9905
  async function stop(opts = {}) {
9906
+ shuttingDown = true;
9085
9907
  await requestShutdown(opts.reason ?? "operator", { drainTimeoutMs: opts.drainTimeoutMs });
9086
9908
  }
9087
9909
  function requestShutdown(reason, opts = {}) {
@@ -9094,6 +9916,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9094
9916
  return current;
9095
9917
  }
9096
9918
  async function unpair() {
9919
+ shuttingDown = true;
9097
9920
  await runLifecycleMutation(unpairUnderLease);
9098
9921
  }
9099
9922
  async function unpairUnderLease() {
@@ -9172,6 +9995,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9172
9995
  }
9173
9996
  async function performControlShutdown(reason) {
9174
9997
  const effectiveReason = reason ?? "operator";
9998
+ shuttingDown = true;
9175
9999
  observer.noteShutdownRequested(effectiveReason);
9176
10000
  try {
9177
10001
  await requestShutdown(`control socket shutdown (${effectiveReason})`);
@@ -9209,7 +10033,107 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9209
10033
  if (!runner) throw new ControlError("not_found", "daemon is not started");
9210
10034
  return runner.requestApproval(parsed.taskId, parsed.summary);
9211
10035
  },
10036
+ /**
10037
+ * Plan `device-assertion-broker`: mint one short-lived, audience-scoped
10038
+ * device assertion for a sibling local process.
10039
+ *
10040
+ * SIX fail-closed gates, in this exact order, none of which signs
10041
+ * anything on the way out. The order is part of the contract, not an
10042
+ * implementation detail:
10043
+ *
10044
+ * 1. `assertion_disabled` — before anything else, because a daemon that
10045
+ * was never configured for this must not reveal, by answering
10046
+ * differently for different inputs, that it even validates params.
10047
+ * 2. `bad_request` — shape/length, checked before the allowlist so a
10048
+ * malformed request cannot be used to probe membership.
10049
+ * 3. `audience_denied` — EXACT `Set.has`, never a prefix/suffix rule
10050
+ * (`salesko-api.evil.com` and `salesko-ap` both fail against an entry
10051
+ * of `salesko-api`). The message deliberately does not echo the
10052
+ * allowlist: a refusal must not be an enumeration oracle.
10053
+ * 4. `shutting_down` — see `performControlShutdown`'s own comment for
10054
+ * the minting window this closes.
10055
+ * 5. `revoked` — the server-side revocation this daemon already knows
10056
+ * about.
10057
+ * 6. `not_paired` — the on-disk record, re-read on EVERY call (never
10058
+ * cached), so clearing `device.json` removes local signing authority
10059
+ * immediately.
10060
+ *
10061
+ * Only after all six does the private key get imported, used once, and
10062
+ * dropped (`device-assertion-signer.ts`).
10063
+ *
10064
+ * Honest limit, and it must stay in the docs as well as here: gates 4-6
10065
+ * are only HALF of revocation. They make this daemon stop minting
10066
+ * promptly, but an assertion already in a caller's hands is not recalled
10067
+ * by any of them. The other half is the host's own recheck at exchange
10068
+ * time, which is why core's `verifyDeviceAssertion` makes the device
10069
+ * row's `revoked` state a REQUIRED parameter. Nothing here entitles
10070
+ * anyone to claim this daemon delivers synchronous invalidation on its
10071
+ * own.
10072
+ */
10073
+ "assertion.issue": async (params) => {
10074
+ if (deviceAssertionAudiences === void 0) {
10075
+ observer.noteDeviceAssertion({ result: "denied", reason: "assertion_disabled" });
10076
+ throw new ControlError(
10077
+ "assertion_disabled",
10078
+ "this daemon is not configured to issue device assertions (DaemonConfig.deviceAssertion.audiences is absent or empty)"
10079
+ );
10080
+ }
10081
+ const parsed = parseAssertionIssueParams(params);
10082
+ if (!parsed) {
10083
+ const rawAudience = typeof params === "object" && params !== null && typeof params.audience === "string" ? params.audience : void 0;
10084
+ observer.noteDeviceAssertion({ result: "denied", reason: "bad_request", audience: rawAudience });
10085
+ throw new ControlError(
10086
+ "bad_request",
10087
+ `assertion.issue requires exactly {audience} where audience is a non-empty string of at most ${DEVICE_ASSERTION_AUDIENCE_MAX_BYTES} UTF-8 bytes`
10088
+ );
10089
+ }
10090
+ if (!deviceAssertionAudiences.has(parsed.audience)) {
10091
+ observer.noteDeviceAssertion({ result: "denied", reason: "audience_denied", audience: parsed.audience });
10092
+ throw new ControlError("audience_denied", "the requested audience is not allowed by this daemon");
10093
+ }
10094
+ if (shuttingDown) {
10095
+ observer.noteDeviceAssertion({ result: "denied", reason: "shutting_down", audience: parsed.audience });
10096
+ throw new ControlError("shutting_down", "this daemon is shutting down and will not issue new assertions");
10097
+ }
10098
+ if (auth.isRevoked()) {
10099
+ observer.noteDeviceAssertion({ result: "denied", reason: "revoked", audience: parsed.audience });
10100
+ throw new ControlError("revoked", "this device has been revoked by the server; re-pair required");
10101
+ }
10102
+ const record = await store.load();
10103
+ if (record === void 0) {
10104
+ observer.noteDeviceAssertion({ result: "denied", reason: "not_paired", audience: parsed.audience });
10105
+ throw new ControlError("not_paired", "this device is not paired; nothing can be asserted about it");
10106
+ }
10107
+ if (shuttingDown) {
10108
+ observer.noteDeviceAssertion({ result: "denied", reason: "shutting_down", audience: parsed.audience });
10109
+ throw new ControlError("shutting_down", "this daemon is shutting down and will not issue new assertions");
10110
+ }
10111
+ if (auth.isRevoked()) {
10112
+ observer.noteDeviceAssertion({ result: "denied", reason: "revoked", audience: parsed.audience });
10113
+ throw new ControlError("revoked", "this device has been revoked by the server; re-pair required");
10114
+ }
10115
+ const minted = mintDeviceAssertion({
10116
+ record,
10117
+ // `toHttpBase` is the one place a configured serverUrl is normalized
10118
+ // (ws:->http:, wss:->https:, path stripped), so an operator who
10119
+ // configured the websocket spelling and one who configured the HTTP
10120
+ // spelling of the same deployment produce the same issuer.
10121
+ issuer: new URL(toHttpBase(config.serverUrl)).origin,
10122
+ productId: config.productId,
10123
+ audience: parsed.audience,
10124
+ ttlMs: deviceAssertionTtlMs,
10125
+ now: /* @__PURE__ */ new Date()
10126
+ });
10127
+ observer.noteDeviceAssertion({
10128
+ result: "issued",
10129
+ audience: minted.claims.audience,
10130
+ jti: minted.claims.jti,
10131
+ expiresAt: minted.expiresAt
10132
+ });
10133
+ return { assertion: minted.envelope, expiresAt: minted.expiresAt };
10134
+ },
9212
10135
  shutdown: (params) => {
10136
+ shuttingDown = true;
9213
10137
  const { reason } = parseShutdownParams(params);
9214
10138
  setImmediate(() => {
9215
10139
  void performControlShutdown(reason).catch((err) => {
@@ -9254,299 +10178,350 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9254
10178
  return { pair, start, stop, status, subscribe, tasks, unpair, approve, reject };
9255
10179
  }
9256
10180
  function createDaemon(config) {
9257
- return createDaemonWithAdapters(config, buildDefaultAdapters(config.runtimeAllowlist));
9258
- }
9259
-
9260
- // src/lifecycle/service-types.ts
9261
- function nodeAgentProgram(opts) {
9262
- const program = {
9263
- command: opts.nodeBin ?? process.execPath,
9264
- args: [opts.agentBin, "start", "--config", opts.configPath]
9265
- };
9266
- if (opts.cwd !== void 0) program.cwd = opts.cwd;
9267
- return program;
9268
- }
9269
- function sanitizeServiceName(name) {
9270
- const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, "-");
9271
- const safe = cleaned.replace(/^-+/, "");
9272
- if (!safe) {
9273
- throw new Error(`service name "${name}" has no valid characters left after sanitizing (allowed: letters, digits, ".", "-", "_"; cannot consist only of leading "-")`);
9274
- }
9275
- return safe;
9276
- }
9277
-
9278
- // src/lifecycle/launchd.ts
9279
- var LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9280
- /operation not permitted/i,
9281
- /could not find domain/i,
9282
- /permission denied/i,
9283
- /access denied/i
9284
- ];
9285
- var LAUNCHD_NOT_LOADED = {
9286
- patterns: [/no such process/i, /could not find (specified )?service/i, /not loaded/i],
9287
- neverAbsence: LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE
9288
- };
9289
- function escapeXml(value) {
9290
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
10181
+ return createDaemonWithAdapters(config, buildDefaultAdapters(config));
9291
10182
  }
9292
- function plistString(value) {
9293
- return `<string>${escapeXml(value)}</string>`;
10183
+ var MAX_CONTROL_TOKEN_BYTES = 256;
10184
+ function errorMessage4(err) {
10185
+ return err instanceof Error ? err.message : String(err);
9294
10186
  }
9295
- function generateLaunchdPlist(def) {
9296
- const { label, program, logDir } = def;
9297
- const args = [program.command, ...program.args];
9298
- const cwd = program.cwd ?? os.homedir();
9299
- const outLog = path20.join(logDir, `${label}.out.log`);
9300
- const errLog = path20.join(logDir, `${label}.err.log`);
9301
- return `<?xml version="1.0" encoding="UTF-8"?>
9302
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
9303
- <plist version="1.0">
9304
- <dict>
9305
- <key>Label</key>
9306
- ${plistString(label)}
9307
- <key>ProgramArguments</key>
9308
- <array>
9309
- ${args.map((a) => ` ${plistString(a)}`).join("\n")}
9310
- </array>
9311
- <key>WorkingDirectory</key>
9312
- ${plistString(cwd)}
9313
- <key>RunAtLoad</key>
9314
- <true/>
9315
- <key>KeepAlive</key>
9316
- <dict>
9317
- <key>SuccessfulExit</key>
9318
- <false/>
9319
- </dict>
9320
- <key>ThrottleInterval</key>
9321
- <integer>10</integer>
9322
- <key>StandardOutPath</key>
9323
- ${plistString(outLog)}
9324
- <key>StandardErrorPath</key>
9325
- ${plistString(errLog)}
9326
- </dict>
9327
- </plist>
9328
- `;
10187
+ function sameFileState3(left, right) {
10188
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
9329
10189
  }
9330
- function createLaunchdLifecycle(def, deps = {}) {
9331
- const run = deps.run ?? defaultRunner;
9332
- const fs19 = deps.fs ?? promises;
9333
- const homedir = deps.homedir ?? (() => os.homedir());
9334
- const getuid = deps.getuid ?? (() => {
9335
- if (typeof process.getuid !== "function") {
9336
- throw new Error("launchd lifecycle requires a POSIX uid (process.getuid unavailable) \u2014 this module only runs on macOS");
9337
- }
9338
- return process.getuid();
9339
- });
9340
- const label = sanitizeServiceName(def.name);
9341
- const plistPath = () => path20.join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
9342
- const domainTarget = () => `gui/${getuid()}`;
9343
- const serviceTarget = () => `${domainTarget()}/${label}`;
9344
- async function fileExists(p) {
9345
- try {
9346
- await fs19.stat(p);
9347
- return true;
9348
- } catch {
9349
- return false;
9350
- }
9351
- }
9352
- async function writePlist(program) {
9353
- const xml = generateLaunchdPlist({ label, program, logDir: def.logDir });
9354
- await fs19.mkdir(path20.dirname(plistPath()), { recursive: true });
9355
- await fs19.mkdir(def.logDir, { recursive: true });
9356
- await fs19.writeFile(plistPath(), xml, "utf8");
9357
- }
9358
- async function install(opts = {}) {
9359
- await writePlist(opts.program ?? def.program);
9360
- await run("launchctl", ["bootout", serviceTarget()]);
9361
- await runOrThrow(run, "launchctl", ["bootstrap", domainTarget(), plistPath()], "launchctl bootstrap");
9362
- await runOrThrow(run, "launchctl", ["enable", serviceTarget()], "launchctl enable");
9363
- await run("launchctl", ["kickstart", "-k", serviceTarget()]);
10190
+ async function readControlToken(tokenPath) {
10191
+ let namedBefore;
10192
+ try {
10193
+ namedBefore = await promises.lstat(tokenPath, { bigint: true });
10194
+ } catch (err) {
10195
+ if (err.code === "ENOENT") return void 0;
10196
+ throw err;
9364
10197
  }
9365
- async function uninstall() {
9366
- await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
9367
- await fs19.rm(plistPath(), { force: true });
10198
+ if (!namedBefore.isFile() || namedBefore.isSymbolicLink()) {
10199
+ throw new Error("control token is not a real regular file");
9368
10200
  }
9369
- async function start() {
9370
- if (!await fileExists(plistPath())) {
9371
- throw new Error(`service "${label}" is not installed (no plist at ${plistPath()}) \u2014 call install() first`);
10201
+ const handle = await promises.open(
10202
+ tokenPath,
10203
+ constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)
10204
+ );
10205
+ try {
10206
+ const opened = await handle.stat({ bigint: true });
10207
+ const namedAfterOpen = await promises.lstat(tokenPath, { bigint: true });
10208
+ if (!opened.isFile() || !namedAfterOpen.isFile() || namedAfterOpen.isSymbolicLink() || !sameFileState3(namedBefore, opened) || !sameFileState3(opened, namedAfterOpen)) {
10209
+ throw new Error("control token pathname changed before safe open");
9372
10210
  }
9373
- await run("launchctl", ["bootstrap", domainTarget(), plistPath()]);
9374
- await runOrThrow(run, "launchctl", ["kickstart", "-k", serviceTarget()], "launchctl kickstart");
9375
- }
9376
- async function stop() {
9377
- await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
9378
- }
9379
- async function status() {
9380
- const installed = await fileExists(plistPath());
9381
- const result = await run("launchctl", ["print", serviceTarget()]);
9382
- const detail = result.stdout || result.stderr;
9383
- const running = result.code === 0 && /\bstate\s*=\s*running\b/i.test(detail);
9384
- const determinate = running || !LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
9385
- return { installed, running, determinate, detail };
10211
+ if (opened.size < 0 || opened.size > BigInt(MAX_CONTROL_TOKEN_BYTES)) {
10212
+ throw new Error("control token exceeds the bounded read limit");
10213
+ }
10214
+ const size = Number(opened.size);
10215
+ const bytes = Buffer.alloc(size);
10216
+ const { bytesRead } = await handle.read(bytes, 0, size, 0);
10217
+ const afterRead = await handle.stat({ bigint: true });
10218
+ const namedAfterRead = await promises.lstat(tokenPath, { bigint: true });
10219
+ if (bytesRead !== size || namedAfterRead.isSymbolicLink() || !sameFileState3(opened, afterRead) || !sameFileState3(afterRead, namedAfterRead)) {
10220
+ throw new Error("control token changed during bounded read");
10221
+ }
10222
+ return bytes.toString("utf8").trim();
10223
+ } finally {
10224
+ await handle.close();
9386
10225
  }
9387
- return { install, uninstall, start, stop, status };
9388
10226
  }
9389
- var SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9390
- /failed to connect to.*bus/i,
9391
- /connection refused/i,
9392
- /access denied/i,
9393
- /permission denied/i,
9394
- /interactive authentication required/i
9395
- ];
9396
- var SYSTEMD_NOT_LOADED = {
9397
- patterns: [/not loaded/i, /does not exist/i],
9398
- neverAbsence: SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE
9399
- };
9400
- function hasControlChar(value) {
9401
- for (let i = 0; i < value.length; i += 1) {
9402
- const code = value.charCodeAt(i);
9403
- if (code < 32 || code === 127) return true;
10227
+ async function connectControlClient(opts) {
10228
+ const tokenPath = controlTokenPath(opts.storeDir);
10229
+ let token;
10230
+ try {
10231
+ const read = await readControlToken(tokenPath);
10232
+ if (read === void 0) {
10233
+ return { ok: false, reason: "daemon is not running (no control.token found)" };
10234
+ }
10235
+ token = read;
10236
+ } catch (err) {
10237
+ return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
9404
10238
  }
9405
- return false;
9406
- }
9407
- function assertNoControlChars(value, field) {
9408
- if (hasControlChar(value)) {
9409
- throw new Error(
9410
- `systemd unit ${field} must not contain control characters (newline/CR/etc.) \u2014 refusing to generate a unit file that could inject an unintended directive (got ${JSON.stringify(value)})`
9411
- );
10239
+ if (!token) {
10240
+ return { ok: false, reason: "control token file is empty" };
10241
+ }
10242
+ const endpoint = controlEndpointPath(opts.productId, opts.storeDir);
10243
+ try {
10244
+ const client = await connectAndHandshake(endpoint, token, opts);
10245
+ return { ok: true, client };
10246
+ } catch (err) {
10247
+ return { ok: false, reason: `daemon control socket not reachable: ${errorMessage4(err)}` };
9412
10248
  }
9413
10249
  }
9414
- function escapeSystemdPercent(value) {
9415
- return value.replace(/%/g, "%%");
9416
- }
9417
- function quoteSystemdArg(value) {
9418
- assertNoControlChars(value, "program.command/program.args entry");
9419
- const escaped = escapeSystemdPercent(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$");
9420
- return `"${escaped}"`;
10250
+ function connectAndHandshake(endpoint, token, opts) {
10251
+ return new Promise((resolve, reject) => {
10252
+ const socket = net.createConnection(endpoint);
10253
+ const reader = new NdjsonLineReader();
10254
+ let phase = "server-hello";
10255
+ let settled = false;
10256
+ const clientNonce = randomNonceHex();
10257
+ const timer = setTimeout(() => {
10258
+ fail(new Error("handshake timed out"));
10259
+ }, opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
10260
+ timer.unref?.();
10261
+ function fail(err) {
10262
+ if (settled) return;
10263
+ settled = true;
10264
+ clearTimeout(timer);
10265
+ socket.removeAllListeners();
10266
+ socket.destroy();
10267
+ reject(err instanceof Error ? err : new Error(String(err)));
10268
+ }
10269
+ function succeed() {
10270
+ settled = true;
10271
+ clearTimeout(timer);
10272
+ socket.removeListener("error", onError);
10273
+ socket.removeListener("data", onData);
10274
+ resolve(createControlClient(socket, reader, opts));
10275
+ }
10276
+ function onData(chunk) {
10277
+ let lines;
10278
+ try {
10279
+ lines = reader.push(chunk);
10280
+ } catch (err) {
10281
+ fail(err);
10282
+ return;
10283
+ }
10284
+ for (const line of lines) {
10285
+ let parsed;
10286
+ try {
10287
+ parsed = JSON.parse(line);
10288
+ } catch {
10289
+ fail(new Error("malformed handshake frame"));
10290
+ return;
10291
+ }
10292
+ if (phase === "server-hello") {
10293
+ const hello = parseServerHello(parsed);
10294
+ if (!hello) {
10295
+ fail(new Error("malformed or unexpected server hello"));
10296
+ return;
10297
+ }
10298
+ if (!timingSafeEqualHex(hello.proof, computeServerProof(token, clientNonce))) {
10299
+ fail(new Error("server failed to prove it holds the control token"));
10300
+ return;
10301
+ }
10302
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, auth: computeClientAuth(token, hello.nonce) }));
10303
+ phase = "ready";
10304
+ continue;
10305
+ }
10306
+ if (!parseServerReady(parsed)) {
10307
+ fail(new Error("server did not confirm readiness"));
10308
+ return;
10309
+ }
10310
+ succeed();
10311
+ return;
10312
+ }
10313
+ }
10314
+ function onError(err) {
10315
+ fail(err);
10316
+ }
10317
+ socket.once("error", onError);
10318
+ socket.once("connect", () => {
10319
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, hello: "client", nonce: clientNonce }));
10320
+ socket.on("data", onData);
10321
+ });
10322
+ });
9421
10323
  }
9422
- function generateSystemdUnit(def) {
9423
- const { name, displayName, program, logDir } = def;
9424
- assertNoControlChars(name, "name");
9425
- assertNoControlChars(displayName, "displayName");
9426
- const cwd = program.cwd ?? os.homedir();
9427
- assertNoControlChars(cwd, "program.cwd");
9428
- const outLog = path20.join(logDir, `${name}.out.log`);
9429
- const errLog = path20.join(logDir, `${name}.err.log`);
9430
- assertNoControlChars(outLog, "logDir");
9431
- assertNoControlChars(errLog, "logDir");
9432
- const execStart = [program.command, ...program.args].map(quoteSystemdArg).join(" ");
9433
- return `[Unit]
9434
- Description=${escapeSystemdPercent(displayName)}
9435
-
9436
- [Service]
9437
- Type=simple
9438
- ExecStart=${execStart}
9439
- WorkingDirectory=${escapeSystemdPercent(cwd)}
9440
- Restart=on-failure
9441
- RestartSec=10
9442
- StandardOutput=append:${escapeSystemdPercent(outLog)}
9443
- StandardError=append:${escapeSystemdPercent(errLog)}
9444
-
9445
- [Install]
9446
- WantedBy=default.target
9447
- `;
10324
+ function withTimeout(promise, ms, message) {
10325
+ return new Promise((resolve, reject) => {
10326
+ const timer = setTimeout(() => reject(new Error(message)), ms);
10327
+ timer.unref?.();
10328
+ promise.then(
10329
+ (value) => {
10330
+ clearTimeout(timer);
10331
+ resolve(value);
10332
+ },
10333
+ (err) => {
10334
+ clearTimeout(timer);
10335
+ reject(err);
10336
+ }
10337
+ );
10338
+ });
9448
10339
  }
9449
- function createSystemdLifecycle(def, deps = {}) {
9450
- const run = deps.run ?? defaultRunner;
9451
- const fs19 = deps.fs ?? promises;
9452
- const homedir = deps.homedir ?? (() => os.homedir());
9453
- const name = sanitizeServiceName(def.name);
9454
- const unitName = `${name}.service`;
9455
- const unitPath = () => path20.join(homedir(), ".config", "systemd", "user", unitName);
9456
- async function fileExists(p) {
10340
+ function createControlClient(socket, reader, opts) {
10341
+ const pending = /* @__PURE__ */ new Map();
10342
+ let idSeq = 0;
10343
+ let closed = false;
10344
+ function handleFrame(parsed) {
10345
+ if (!isRecord2(parsed) || typeof parsed.id !== "string") return;
10346
+ const entry = pending.get(parsed.id);
10347
+ if (!entry) return;
10348
+ if ("event" in parsed) {
10349
+ entry.onEvent?.(parsed.event);
10350
+ return;
10351
+ }
10352
+ if (parsed.ok === true) {
10353
+ pending.delete(parsed.id);
10354
+ entry.resolve(parsed.done === true ? void 0 : parsed.result);
10355
+ return;
10356
+ }
10357
+ pending.delete(parsed.id);
10358
+ const shape = parsed.error;
10359
+ entry.reject(
10360
+ new ControlError(
10361
+ typeof shape?.code === "string" ? shape.code : "internal_error",
10362
+ typeof shape?.message === "string" ? shape.message : "unknown control error"
10363
+ )
10364
+ );
10365
+ }
10366
+ socket.on("data", (chunk) => {
10367
+ let lines;
9457
10368
  try {
9458
- await fs19.stat(p);
9459
- return true;
10369
+ lines = reader.push(chunk);
9460
10370
  } catch {
9461
- return false;
10371
+ socket.destroy();
10372
+ return;
9462
10373
  }
9463
- }
9464
- async function writeUnit(program) {
9465
- const unit = generateSystemdUnit({ name, displayName: def.displayName ?? def.name, program, logDir: def.logDir });
9466
- await fs19.mkdir(path20.dirname(unitPath()), { recursive: true });
9467
- await fs19.mkdir(def.logDir, { recursive: true });
9468
- await fs19.writeFile(unitPath(), unit, "utf8");
9469
- }
9470
- async function install(opts = {}) {
9471
- await writeUnit(opts.program ?? def.program);
9472
- await runOrThrow(run, "systemctl", ["--user", "daemon-reload"], "systemctl daemon-reload");
9473
- await runOrThrow(run, "systemctl", ["--user", "enable", "--now", unitName], "systemctl enable --now");
9474
- }
9475
- async function uninstall() {
9476
- await runIdempotent(run, "systemctl", ["--user", "disable", "--now", unitName], "systemctl disable --now", SYSTEMD_NOT_LOADED);
9477
- await fs19.rm(unitPath(), { force: true });
9478
- await run("systemctl", ["--user", "daemon-reload"]);
9479
- }
9480
- async function start() {
9481
- if (!await fileExists(unitPath())) {
9482
- throw new Error(`service "${name}" is not installed (no unit file at ${unitPath()}) \u2014 call install() first`);
10374
+ for (const line of lines) {
10375
+ let parsed;
10376
+ try {
10377
+ parsed = JSON.parse(line);
10378
+ } catch {
10379
+ continue;
10380
+ }
10381
+ handleFrame(parsed);
9483
10382
  }
9484
- await runOrThrow(run, "systemctl", ["--user", "start", unitName], "systemctl start");
9485
- }
9486
- async function stop() {
9487
- await runIdempotent(run, "systemctl", ["--user", "stop", unitName], "systemctl stop", SYSTEMD_NOT_LOADED);
10383
+ });
10384
+ socket.on("close", () => {
10385
+ closed = true;
10386
+ for (const entry of pending.values()) entry.reject(new Error("control connection closed"));
10387
+ pending.clear();
10388
+ });
10389
+ socket.on("error", () => {
10390
+ });
10391
+ function send(method, params, onEvent) {
10392
+ const id = `c${++idSeq}`;
10393
+ const promise = new Promise((resolve, reject) => {
10394
+ pending.set(id, { resolve, reject, onEvent });
10395
+ });
10396
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, method, params }));
10397
+ return { id, promise };
9488
10398
  }
9489
- async function status() {
9490
- const installed = await fileExists(unitPath());
9491
- const result = await run("systemctl", ["--user", "is-active", unitName]);
9492
- const detail = (result.stdout || result.stderr).trim();
9493
- const running = result.code === 0 && detail === "active";
9494
- const determinate = running || !SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
9495
- return { installed, running, determinate, detail };
10399
+ return {
10400
+ async request(method, params) {
10401
+ if (closed) throw new Error("control connection is closed");
10402
+ const { promise } = send(method, params);
10403
+ const result = await withTimeout(promise, opts.requestTimeoutMs ?? 1e4, `control request "${method}" timed out`);
10404
+ return result;
10405
+ },
10406
+ subscribe(method, params, onEvent) {
10407
+ const { id, promise } = send(method, params, onEvent);
10408
+ promise.catch(() => {
10409
+ });
10410
+ return {
10411
+ close: () => {
10412
+ pending.delete(id);
10413
+ socket.destroy();
10414
+ }
10415
+ };
10416
+ },
10417
+ close() {
10418
+ socket.destroy();
10419
+ }
10420
+ };
10421
+ }
10422
+ async function isControlDaemonGone(storeDir, productId) {
10423
+ const tokenGone = await promises.stat(controlTokenPath(storeDir)).then(
10424
+ () => false,
10425
+ (err) => err.code === "ENOENT"
10426
+ );
10427
+ if (!tokenGone) return false;
10428
+ const endpoint = controlEndpointPath(productId, storeDir);
10429
+ return new Promise((resolve) => {
10430
+ const socket = net.createConnection(endpoint);
10431
+ const finish = (gone) => {
10432
+ socket.removeAllListeners();
10433
+ socket.destroy();
10434
+ resolve(gone);
10435
+ };
10436
+ socket.once("connect", () => finish(false));
10437
+ socket.once("error", (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
10438
+ });
10439
+ }
10440
+
10441
+ // src/lifecycle/service-types.ts
10442
+ function nodeAgentProgram(opts) {
10443
+ const program = {
10444
+ command: opts.nodeBin ?? process.execPath,
10445
+ args: [opts.agentBin, "start", "--config", opts.configPath]
10446
+ };
10447
+ if (opts.cwd !== void 0) program.cwd = opts.cwd;
10448
+ return program;
10449
+ }
10450
+ function sanitizeServiceName(name) {
10451
+ const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, "-");
10452
+ const safe = cleaned.replace(/^-+/, "");
10453
+ if (!safe) {
10454
+ throw new Error(`service name "${name}" has no valid characters left after sanitizing (allowed: letters, digits, ".", "-", "_"; cannot consist only of leading "-")`);
9496
10455
  }
9497
- return { install, uninstall, start, stop, status };
10456
+ return safe;
9498
10457
  }
9499
- var WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9500
- /access is denied/i,
9501
- /access denied/i,
10458
+
10459
+ // src/lifecycle/launchd.ts
10460
+ var LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
10461
+ /operation not permitted/i,
10462
+ /could not find domain/i,
9502
10463
  /permission denied/i,
9503
- /being used by another process/i
10464
+ /access denied/i
9504
10465
  ];
9505
- var WINSW_NOT_INSTALLED = {
9506
- codes: [1060],
9507
- patterns: [/does not exist/i, /non-existent service/i],
9508
- neverAbsence: WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE
9509
- };
9510
- var WINSW_ALREADY_STOPPED = {
9511
- codes: [1062, ...WINSW_NOT_INSTALLED.codes ?? []],
9512
- patterns: [/not running/i, /has not been started/i, ...WINSW_NOT_INSTALLED.patterns],
9513
- neverAbsence: WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE
10466
+ var LAUNCHD_NOT_LOADED = {
10467
+ patterns: [/no such process/i, /could not find (specified )?service/i, /not loaded/i],
10468
+ neverAbsence: LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE
9514
10469
  };
9515
- function xmlEscape(value) {
10470
+ function escapeXml(value) {
9516
10471
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
9517
10472
  }
9518
- function generateWinswXml(def) {
9519
- const { id, displayName, program, logDir } = def;
9520
- const argXml = program.args.map((a) => ` <argument>${xmlEscape(a)}</argument>`).join("\n");
9521
- const cwdXml = program.cwd ? `
9522
- <workingdirectory>${xmlEscape(program.cwd)}</workingdirectory>` : "";
9523
- return `<service>
9524
- <id>${xmlEscape(id)}</id>
9525
- <name>${xmlEscape(displayName)}</name>
9526
- <description>${xmlEscape(displayName)} (managed by byok-agent; see templates/service/winsw/README.md)</description>
9527
- <executable>${xmlEscape(program.command)}</executable>
9528
- ${argXml}${cwdXml}
9529
- <logpath>${xmlEscape(logDir)}</logpath>
9530
- <log mode="roll"></log>
9531
- <startmode>Automatic</startmode>
9532
- <onfailure action="restart" delay="10 sec"/>
9533
- <onfailure action="restart" delay="30 sec"/>
9534
- <resetfailure>1 hour</resetfailure>
9535
- </service>
10473
+ function plistString(value) {
10474
+ return `<string>${escapeXml(value)}</string>`;
10475
+ }
10476
+ function generateLaunchdPlist(def) {
10477
+ const { label, program, logDir } = def;
10478
+ const args = [program.command, ...program.args];
10479
+ const cwd = program.cwd ?? os.homedir();
10480
+ const outLog = path20.join(logDir, `${label}.out.log`);
10481
+ const errLog = path20.join(logDir, `${label}.err.log`);
10482
+ return `<?xml version="1.0" encoding="UTF-8"?>
10483
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
10484
+ <plist version="1.0">
10485
+ <dict>
10486
+ <key>Label</key>
10487
+ ${plistString(label)}
10488
+ <key>ProgramArguments</key>
10489
+ <array>
10490
+ ${args.map((a) => ` ${plistString(a)}`).join("\n")}
10491
+ </array>
10492
+ <key>WorkingDirectory</key>
10493
+ ${plistString(cwd)}
10494
+ <key>RunAtLoad</key>
10495
+ <true/>
10496
+ <key>KeepAlive</key>
10497
+ <dict>
10498
+ <key>SuccessfulExit</key>
10499
+ <false/>
10500
+ </dict>
10501
+ <key>ThrottleInterval</key>
10502
+ <integer>10</integer>
10503
+ <key>StandardOutPath</key>
10504
+ ${plistString(outLog)}
10505
+ <key>StandardErrorPath</key>
10506
+ ${plistString(errLog)}
10507
+ </dict>
10508
+ </plist>
9536
10509
  `;
9537
10510
  }
9538
- function createWinswLifecycle(def, deps = {}) {
10511
+ function createLaunchdLifecycle(def, deps = {}) {
9539
10512
  const run = deps.run ?? defaultRunner;
9540
10513
  const fs19 = deps.fs ?? promises;
9541
- const windows = def.windows;
9542
- if (!windows) {
9543
- throw new Error("WinSW service lifecycle requires `ServiceDefinition.windows.winswBin` (the product-bundled WinSW executable path)");
9544
- }
9545
- const winswBin = windows.winswBin;
9546
- const id = sanitizeServiceName(def.name);
9547
- const installDir = windows.installDir ?? def.logDir;
9548
- const exePath = path20.join(installDir, `${id}.exe`);
9549
- const xmlPath = path20.join(installDir, `${id}.xml`);
10514
+ const homedir = deps.homedir ?? (() => os.homedir());
10515
+ const getuid = deps.getuid ?? (() => {
10516
+ if (typeof process.getuid !== "function") {
10517
+ throw new Error("launchd lifecycle requires a POSIX uid (process.getuid unavailable) \u2014 this module only runs on macOS");
10518
+ }
10519
+ return process.getuid();
10520
+ });
10521
+ const label = sanitizeServiceName(def.name);
10522
+ const plistPath = () => path20.join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
10523
+ const domainTarget = () => `gui/${getuid()}`;
10524
+ const serviceTarget = () => `${domainTarget()}/${label}`;
9550
10525
  async function fileExists(p) {
9551
10526
  try {
9552
10527
  await fs19.stat(p);
@@ -9555,381 +10530,330 @@ function createWinswLifecycle(def, deps = {}) {
9555
10530
  return false;
9556
10531
  }
9557
10532
  }
9558
- async function writeFiles(program) {
9559
- const xml = generateWinswXml({ id, displayName: def.displayName ?? def.name, program, logDir: def.logDir });
9560
- await fs19.mkdir(installDir, { recursive: true });
10533
+ async function writePlist(program) {
10534
+ const xml = generateLaunchdPlist({ label, program, logDir: def.logDir });
10535
+ await fs19.mkdir(path20.dirname(plistPath()), { recursive: true });
9561
10536
  await fs19.mkdir(def.logDir, { recursive: true });
9562
- await fs19.copyFile(winswBin, exePath);
9563
- await fs19.writeFile(xmlPath, xml, "utf8");
10537
+ await fs19.writeFile(plistPath(), xml, "utf8");
9564
10538
  }
9565
10539
  async function install(opts = {}) {
9566
- await writeFiles(opts.program ?? def.program);
9567
- await runOrThrow(run, exePath, ["install"], "winsw install");
9568
- await runOrThrow(run, exePath, ["start"], "winsw start");
10540
+ await writePlist(opts.program ?? def.program);
10541
+ await run("launchctl", ["bootout", serviceTarget()]);
10542
+ await runOrThrow(run, "launchctl", ["bootstrap", domainTarget(), plistPath()], "launchctl bootstrap");
10543
+ await runOrThrow(run, "launchctl", ["enable", serviceTarget()], "launchctl enable");
10544
+ await run("launchctl", ["kickstart", "-k", serviceTarget()]);
9569
10545
  }
9570
10546
  async function uninstall() {
9571
- await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_NOT_INSTALLED);
9572
- await runIdempotent(run, exePath, ["uninstall"], "winsw uninstall", WINSW_NOT_INSTALLED);
9573
- await fs19.rm(exePath, { force: true });
9574
- await fs19.rm(xmlPath, { force: true });
10547
+ await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
10548
+ await fs19.rm(plistPath(), { force: true });
9575
10549
  }
9576
10550
  async function start() {
9577
- if (!await fileExists(xmlPath)) {
9578
- throw new Error(`service "${id}" is not installed (no config at ${xmlPath}) \u2014 call install() first`);
10551
+ if (!await fileExists(plistPath())) {
10552
+ throw new Error(`service "${label}" is not installed (no plist at ${plistPath()}) \u2014 call install() first`);
9579
10553
  }
9580
- await runOrThrow(run, exePath, ["start"], "winsw start");
10554
+ await run("launchctl", ["bootstrap", domainTarget(), plistPath()]);
10555
+ await runOrThrow(run, "launchctl", ["kickstart", "-k", serviceTarget()], "launchctl kickstart");
9581
10556
  }
9582
10557
  async function stop() {
9583
- await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_ALREADY_STOPPED);
10558
+ await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
9584
10559
  }
9585
10560
  async function status() {
9586
- const installed = await fileExists(xmlPath);
9587
- const result = await run("sc.exe", ["query", id]);
9588
- const detail = (result.stdout || result.stderr).trim();
9589
- const running = result.code === 0 && /\bSTATE\b.*\bRUNNING\b/i.test(detail);
9590
- const determinate = running || !WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
10561
+ const installed = await fileExists(plistPath());
10562
+ const result = await run("launchctl", ["print", serviceTarget()]);
10563
+ const detail = result.stdout || result.stderr;
10564
+ const running = result.code === 0 && /\bstate\s*=\s*running\b/i.test(detail);
10565
+ const determinate = running || !LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
9591
10566
  return { installed, running, determinate, detail };
9592
10567
  }
9593
10568
  return { install, uninstall, start, stop, status };
9594
10569
  }
9595
-
9596
- // src/lifecycle/create-service-lifecycle.ts
9597
- var UnsupportedServicePlatformError = class extends Error {
9598
- constructor(platform) {
9599
- super(`no OS service lifecycle for platform "${platform}" \u2014 supported: darwin (launchd), linux (systemd --user), win32 (WinSW)`);
9600
- this.name = "UnsupportedServicePlatformError";
9601
- }
9602
- };
9603
- function createServiceLifecycle(def, opts = {}) {
9604
- const platform = opts.platform ?? process.platform;
9605
- switch (platform) {
9606
- case "darwin":
9607
- return createLaunchdLifecycle(def, opts.deps);
9608
- case "linux":
9609
- return createSystemdLifecycle(def, opts.deps);
9610
- case "win32":
9611
- return createWinswLifecycle(def, opts.deps);
9612
- default:
9613
- throw new UnsupportedServicePlatformError(platform);
9614
- }
9615
- }
9616
- var REQUIRED_FIELDS = ["productName", "productId", "serverUrl", "workspaceRoot"];
9617
- var ConfigError = class extends Error {
9618
- constructor(message) {
9619
- super(message);
9620
- this.name = "ConfigError";
9621
- }
10570
+ var SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
10571
+ /failed to connect to.*bus/i,
10572
+ /connection refused/i,
10573
+ /access denied/i,
10574
+ /permission denied/i,
10575
+ /interactive authentication required/i
10576
+ ];
10577
+ var SYSTEMD_NOT_LOADED = {
10578
+ patterns: [/not loaded/i, /does not exist/i],
10579
+ neverAbsence: SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE
9622
10580
  };
9623
- function loadConfig(configPath, overrides = {}) {
9624
- let base = {};
9625
- if (configPath) {
9626
- let raw;
9627
- try {
9628
- raw = readFileSync(configPath, "utf8");
9629
- } catch (err) {
9630
- throw new ConfigError(`could not read config at "${configPath}": ${err instanceof Error ? err.message : String(err)}`);
9631
- }
9632
- try {
9633
- base = JSON.parse(raw);
9634
- } catch (err) {
9635
- throw new ConfigError(`config at "${configPath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
9636
- }
9637
- }
9638
- const merged = { ...base, ...overrides };
9639
- if (merged.gitWorkspace !== void 0) {
9640
- try {
9641
- GitWorkspaceManager.validateConfig(merged.gitWorkspace);
9642
- } catch (error) {
9643
- throw new ConfigError(error instanceof Error ? error.message : "invalid gitWorkspace configuration");
9644
- }
10581
+ function hasControlChar(value) {
10582
+ for (let i = 0; i < value.length; i += 1) {
10583
+ const code = value.charCodeAt(i);
10584
+ if (code < 32 || code === 127) return true;
9645
10585
  }
9646
- for (const field of REQUIRED_FIELDS) {
9647
- if (!merged[field]) {
9648
- throw new ConfigError(`config is missing required field "${field}"`);
9649
- }
10586
+ return false;
10587
+ }
10588
+ function assertNoControlChars(value, field) {
10589
+ if (hasControlChar(value)) {
10590
+ throw new Error(
10591
+ `systemd unit ${field} must not contain control characters (newline/CR/etc.) \u2014 refusing to generate a unit file that could inject an unintended directive (got ${JSON.stringify(value)})`
10592
+ );
9650
10593
  }
9651
- return merged;
9652
10594
  }
9653
- function resolveStoreDir(config) {
9654
- return DeviceStore.resolveDir(config.productId, config.storeDir);
10595
+ function escapeSystemdPercent(value) {
10596
+ return value.replace(/%/g, "%%");
9655
10597
  }
9656
- function argValue(args, flag) {
9657
- const idx = args.indexOf(flag);
9658
- const value = idx >= 0 ? args[idx + 1] : void 0;
9659
- return value !== void 0 && !value.startsWith("--") ? value : void 0;
10598
+ function quoteSystemdArg(value) {
10599
+ assertNoControlChars(value, "program.command/program.args entry");
10600
+ const escaped = escapeSystemdPercent(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$");
10601
+ return `"${escaped}"`;
9660
10602
  }
9661
- function hasFlag(args, flag) {
9662
- return args.includes(flag);
10603
+ function generateSystemdUnit(def) {
10604
+ const { name, displayName, program, logDir } = def;
10605
+ assertNoControlChars(name, "name");
10606
+ assertNoControlChars(displayName, "displayName");
10607
+ const cwd = program.cwd ?? os.homedir();
10608
+ assertNoControlChars(cwd, "program.cwd");
10609
+ const outLog = path20.join(logDir, `${name}.out.log`);
10610
+ const errLog = path20.join(logDir, `${name}.err.log`);
10611
+ assertNoControlChars(outLog, "logDir");
10612
+ assertNoControlChars(errLog, "logDir");
10613
+ const execStart = [program.command, ...program.args].map(quoteSystemdArg).join(" ");
10614
+ return `[Unit]
10615
+ Description=${escapeSystemdPercent(displayName)}
10616
+
10617
+ [Service]
10618
+ Type=simple
10619
+ ExecStart=${execStart}
10620
+ WorkingDirectory=${escapeSystemdPercent(cwd)}
10621
+ Restart=on-failure
10622
+ RestartSec=10
10623
+ StandardOutput=append:${escapeSystemdPercent(outLog)}
10624
+ StandardError=append:${escapeSystemdPercent(errLog)}
10625
+
10626
+ [Install]
10627
+ WantedBy=default.target
10628
+ `;
9663
10629
  }
9664
- function positionalArgs(args, valueFlags = []) {
9665
- const result = [];
9666
- for (let i = 0; i < args.length; i++) {
9667
- const arg = args[i];
9668
- if (arg === void 0) continue;
9669
- if (valueFlags.includes(arg)) {
9670
- i++;
9671
- continue;
10630
+ function createSystemdLifecycle(def, deps = {}) {
10631
+ const run = deps.run ?? defaultRunner;
10632
+ const fs19 = deps.fs ?? promises;
10633
+ const homedir = deps.homedir ?? (() => os.homedir());
10634
+ const name = sanitizeServiceName(def.name);
10635
+ const unitName = `${name}.service`;
10636
+ const unitPath = () => path20.join(homedir(), ".config", "systemd", "user", unitName);
10637
+ async function fileExists(p) {
10638
+ try {
10639
+ await fs19.stat(p);
10640
+ return true;
10641
+ } catch {
10642
+ return false;
9672
10643
  }
9673
- result.push(arg);
9674
10644
  }
9675
- return result;
9676
- }
9677
- var MAX_CONTROL_TOKEN_BYTES = 256;
9678
- function errorMessage4(err) {
9679
- return err instanceof Error ? err.message : String(err);
9680
- }
9681
- function sameFileState3(left, right) {
9682
- return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
9683
- }
9684
- async function readControlToken(tokenPath) {
9685
- let namedBefore;
9686
- try {
9687
- namedBefore = await promises.lstat(tokenPath, { bigint: true });
9688
- } catch (err) {
9689
- if (err.code === "ENOENT") return void 0;
9690
- throw err;
10645
+ async function writeUnit(program) {
10646
+ const unit = generateSystemdUnit({ name, displayName: def.displayName ?? def.name, program, logDir: def.logDir });
10647
+ await fs19.mkdir(path20.dirname(unitPath()), { recursive: true });
10648
+ await fs19.mkdir(def.logDir, { recursive: true });
10649
+ await fs19.writeFile(unitPath(), unit, "utf8");
9691
10650
  }
9692
- if (!namedBefore.isFile() || namedBefore.isSymbolicLink()) {
9693
- throw new Error("control token is not a real regular file");
10651
+ async function install(opts = {}) {
10652
+ await writeUnit(opts.program ?? def.program);
10653
+ await runOrThrow(run, "systemctl", ["--user", "daemon-reload"], "systemctl daemon-reload");
10654
+ await runOrThrow(run, "systemctl", ["--user", "enable", "--now", unitName], "systemctl enable --now");
9694
10655
  }
9695
- const handle = await promises.open(
9696
- tokenPath,
9697
- constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)
9698
- );
9699
- try {
9700
- const opened = await handle.stat({ bigint: true });
9701
- const namedAfterOpen = await promises.lstat(tokenPath, { bigint: true });
9702
- if (!opened.isFile() || !namedAfterOpen.isFile() || namedAfterOpen.isSymbolicLink() || !sameFileState3(namedBefore, opened) || !sameFileState3(opened, namedAfterOpen)) {
9703
- throw new Error("control token pathname changed before safe open");
9704
- }
9705
- if (opened.size < 0 || opened.size > BigInt(MAX_CONTROL_TOKEN_BYTES)) {
9706
- throw new Error("control token exceeds the bounded read limit");
9707
- }
9708
- const size = Number(opened.size);
9709
- const bytes = Buffer.alloc(size);
9710
- const { bytesRead } = await handle.read(bytes, 0, size, 0);
9711
- const afterRead = await handle.stat({ bigint: true });
9712
- const namedAfterRead = await promises.lstat(tokenPath, { bigint: true });
9713
- if (bytesRead !== size || namedAfterRead.isSymbolicLink() || !sameFileState3(opened, afterRead) || !sameFileState3(afterRead, namedAfterRead)) {
9714
- throw new Error("control token changed during bounded read");
9715
- }
9716
- return bytes.toString("utf8").trim();
9717
- } finally {
9718
- await handle.close();
10656
+ async function uninstall() {
10657
+ await runIdempotent(run, "systemctl", ["--user", "disable", "--now", unitName], "systemctl disable --now", SYSTEMD_NOT_LOADED);
10658
+ await fs19.rm(unitPath(), { force: true });
10659
+ await run("systemctl", ["--user", "daemon-reload"]);
9719
10660
  }
9720
- }
9721
- async function connectControlClient(opts) {
9722
- const tokenPath = controlTokenPath(opts.storeDir);
9723
- let token;
9724
- try {
9725
- const read = await readControlToken(tokenPath);
9726
- if (read === void 0) {
9727
- return { ok: false, reason: "daemon is not running (no control.token found)" };
10661
+ async function start() {
10662
+ if (!await fileExists(unitPath())) {
10663
+ throw new Error(`service "${name}" is not installed (no unit file at ${unitPath()}) \u2014 call install() first`);
9728
10664
  }
9729
- token = read;
9730
- } catch (err) {
9731
- return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
10665
+ await runOrThrow(run, "systemctl", ["--user", "start", unitName], "systemctl start");
9732
10666
  }
9733
- if (!token) {
9734
- return { ok: false, reason: "control token file is empty" };
10667
+ async function stop() {
10668
+ await runIdempotent(run, "systemctl", ["--user", "stop", unitName], "systemctl stop", SYSTEMD_NOT_LOADED);
9735
10669
  }
9736
- const endpoint = controlEndpointPath(opts.productId, opts.storeDir);
9737
- try {
9738
- const client = await connectAndHandshake(endpoint, token, opts);
9739
- return { ok: true, client };
9740
- } catch (err) {
9741
- return { ok: false, reason: `daemon control socket not reachable: ${errorMessage4(err)}` };
10670
+ async function status() {
10671
+ const installed = await fileExists(unitPath());
10672
+ const result = await run("systemctl", ["--user", "is-active", unitName]);
10673
+ const detail = (result.stdout || result.stderr).trim();
10674
+ const running = result.code === 0 && detail === "active";
10675
+ const determinate = running || !SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
10676
+ return { installed, running, determinate, detail };
9742
10677
  }
10678
+ return { install, uninstall, start, stop, status };
9743
10679
  }
9744
- function connectAndHandshake(endpoint, token, opts) {
9745
- return new Promise((resolve, reject) => {
9746
- const socket = net.createConnection(endpoint);
9747
- const reader = new NdjsonLineReader();
9748
- let phase = "server-hello";
9749
- let settled = false;
9750
- const clientNonce = randomNonceHex();
9751
- const timer = setTimeout(() => {
9752
- fail(new Error("handshake timed out"));
9753
- }, opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
9754
- timer.unref?.();
9755
- function fail(err) {
9756
- if (settled) return;
9757
- settled = true;
9758
- clearTimeout(timer);
9759
- socket.removeAllListeners();
9760
- socket.destroy();
9761
- reject(err instanceof Error ? err : new Error(String(err)));
9762
- }
9763
- function succeed() {
9764
- settled = true;
9765
- clearTimeout(timer);
9766
- socket.removeListener("error", onError);
9767
- socket.removeListener("data", onData);
9768
- resolve(createControlClient(socket, reader, opts));
9769
- }
9770
- function onData(chunk) {
9771
- let lines;
9772
- try {
9773
- lines = reader.push(chunk);
9774
- } catch (err) {
9775
- fail(err);
9776
- return;
9777
- }
9778
- for (const line of lines) {
9779
- let parsed;
9780
- try {
9781
- parsed = JSON.parse(line);
9782
- } catch {
9783
- fail(new Error("malformed handshake frame"));
9784
- return;
9785
- }
9786
- if (phase === "server-hello") {
9787
- const hello = parseServerHello(parsed);
9788
- if (!hello) {
9789
- fail(new Error("malformed or unexpected server hello"));
9790
- return;
9791
- }
9792
- if (!timingSafeEqualHex(hello.proof, computeServerProof(token, clientNonce))) {
9793
- fail(new Error("server failed to prove it holds the control token"));
9794
- return;
9795
- }
9796
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, auth: computeClientAuth(token, hello.nonce) }));
9797
- phase = "ready";
9798
- continue;
9799
- }
9800
- if (!parseServerReady(parsed)) {
9801
- fail(new Error("server did not confirm readiness"));
9802
- return;
9803
- }
9804
- succeed();
9805
- return;
9806
- }
10680
+ var WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE = [
10681
+ /access is denied/i,
10682
+ /access denied/i,
10683
+ /permission denied/i,
10684
+ /being used by another process/i
10685
+ ];
10686
+ var WINSW_NOT_INSTALLED = {
10687
+ codes: [1060],
10688
+ patterns: [/does not exist/i, /non-existent service/i],
10689
+ neverAbsence: WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE
10690
+ };
10691
+ var WINSW_ALREADY_STOPPED = {
10692
+ codes: [1062, ...WINSW_NOT_INSTALLED.codes ?? []],
10693
+ patterns: [/not running/i, /has not been started/i, ...WINSW_NOT_INSTALLED.patterns],
10694
+ neverAbsence: WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE
10695
+ };
10696
+ function xmlEscape(value) {
10697
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
10698
+ }
10699
+ function generateWinswXml(def) {
10700
+ const { id, displayName, program, logDir } = def;
10701
+ const argXml = program.args.map((a) => ` <argument>${xmlEscape(a)}</argument>`).join("\n");
10702
+ const cwdXml = program.cwd ? `
10703
+ <workingdirectory>${xmlEscape(program.cwd)}</workingdirectory>` : "";
10704
+ return `<service>
10705
+ <id>${xmlEscape(id)}</id>
10706
+ <name>${xmlEscape(displayName)}</name>
10707
+ <description>${xmlEscape(displayName)} (managed by byok-agent; see templates/service/winsw/README.md)</description>
10708
+ <executable>${xmlEscape(program.command)}</executable>
10709
+ ${argXml}${cwdXml}
10710
+ <logpath>${xmlEscape(logDir)}</logpath>
10711
+ <log mode="roll"></log>
10712
+ <startmode>Automatic</startmode>
10713
+ <onfailure action="restart" delay="10 sec"/>
10714
+ <onfailure action="restart" delay="30 sec"/>
10715
+ <resetfailure>1 hour</resetfailure>
10716
+ </service>
10717
+ `;
10718
+ }
10719
+ function createWinswLifecycle(def, deps = {}) {
10720
+ const run = deps.run ?? defaultRunner;
10721
+ const fs19 = deps.fs ?? promises;
10722
+ const windows = def.windows;
10723
+ if (!windows) {
10724
+ throw new Error("WinSW service lifecycle requires `ServiceDefinition.windows.winswBin` (the product-bundled WinSW executable path)");
10725
+ }
10726
+ const winswBin = windows.winswBin;
10727
+ const id = sanitizeServiceName(def.name);
10728
+ const installDir = windows.installDir ?? def.logDir;
10729
+ const exePath = path20.join(installDir, `${id}.exe`);
10730
+ const xmlPath = path20.join(installDir, `${id}.xml`);
10731
+ async function fileExists(p) {
10732
+ try {
10733
+ await fs19.stat(p);
10734
+ return true;
10735
+ } catch {
10736
+ return false;
9807
10737
  }
9808
- function onError(err) {
9809
- fail(err);
10738
+ }
10739
+ async function writeFiles(program) {
10740
+ const xml = generateWinswXml({ id, displayName: def.displayName ?? def.name, program, logDir: def.logDir });
10741
+ await fs19.mkdir(installDir, { recursive: true });
10742
+ await fs19.mkdir(def.logDir, { recursive: true });
10743
+ await fs19.copyFile(winswBin, exePath);
10744
+ await fs19.writeFile(xmlPath, xml, "utf8");
10745
+ }
10746
+ async function install(opts = {}) {
10747
+ await writeFiles(opts.program ?? def.program);
10748
+ await runOrThrow(run, exePath, ["install"], "winsw install");
10749
+ await runOrThrow(run, exePath, ["start"], "winsw start");
10750
+ }
10751
+ async function uninstall() {
10752
+ await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_NOT_INSTALLED);
10753
+ await runIdempotent(run, exePath, ["uninstall"], "winsw uninstall", WINSW_NOT_INSTALLED);
10754
+ await fs19.rm(exePath, { force: true });
10755
+ await fs19.rm(xmlPath, { force: true });
10756
+ }
10757
+ async function start() {
10758
+ if (!await fileExists(xmlPath)) {
10759
+ throw new Error(`service "${id}" is not installed (no config at ${xmlPath}) \u2014 call install() first`);
9810
10760
  }
9811
- socket.once("error", onError);
9812
- socket.once("connect", () => {
9813
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, hello: "client", nonce: clientNonce }));
9814
- socket.on("data", onData);
9815
- });
9816
- });
10761
+ await runOrThrow(run, exePath, ["start"], "winsw start");
10762
+ }
10763
+ async function stop() {
10764
+ await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_ALREADY_STOPPED);
10765
+ }
10766
+ async function status() {
10767
+ const installed = await fileExists(xmlPath);
10768
+ const result = await run("sc.exe", ["query", id]);
10769
+ const detail = (result.stdout || result.stderr).trim();
10770
+ const running = result.code === 0 && /\bSTATE\b.*\bRUNNING\b/i.test(detail);
10771
+ const determinate = running || !WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
10772
+ return { installed, running, determinate, detail };
10773
+ }
10774
+ return { install, uninstall, start, stop, status };
9817
10775
  }
9818
- function withTimeout(promise, ms, message) {
9819
- return new Promise((resolve, reject) => {
9820
- const timer = setTimeout(() => reject(new Error(message)), ms);
9821
- timer.unref?.();
9822
- promise.then(
9823
- (value) => {
9824
- clearTimeout(timer);
9825
- resolve(value);
9826
- },
9827
- (err) => {
9828
- clearTimeout(timer);
9829
- reject(err);
9830
- }
9831
- );
9832
- });
10776
+
10777
+ // src/lifecycle/create-service-lifecycle.ts
10778
+ var UnsupportedServicePlatformError = class extends Error {
10779
+ constructor(platform) {
10780
+ super(`no OS service lifecycle for platform "${platform}" \u2014 supported: darwin (launchd), linux (systemd --user), win32 (WinSW)`);
10781
+ this.name = "UnsupportedServicePlatformError";
10782
+ }
10783
+ };
10784
+ function createServiceLifecycle(def, opts = {}) {
10785
+ const platform = opts.platform ?? process.platform;
10786
+ switch (platform) {
10787
+ case "darwin":
10788
+ return createLaunchdLifecycle(def, opts.deps);
10789
+ case "linux":
10790
+ return createSystemdLifecycle(def, opts.deps);
10791
+ case "win32":
10792
+ return createWinswLifecycle(def, opts.deps);
10793
+ default:
10794
+ throw new UnsupportedServicePlatformError(platform);
10795
+ }
9833
10796
  }
9834
- function createControlClient(socket, reader, opts) {
9835
- const pending = /* @__PURE__ */ new Map();
9836
- let idSeq = 0;
9837
- let closed = false;
9838
- function handleFrame(parsed) {
9839
- if (!isRecord2(parsed) || typeof parsed.id !== "string") return;
9840
- const entry = pending.get(parsed.id);
9841
- if (!entry) return;
9842
- if ("event" in parsed) {
9843
- entry.onEvent?.(parsed.event);
9844
- return;
10797
+ var REQUIRED_FIELDS = ["productName", "productId", "serverUrl", "workspaceRoot"];
10798
+ var ConfigError = class extends Error {
10799
+ constructor(message) {
10800
+ super(message);
10801
+ this.name = "ConfigError";
10802
+ }
10803
+ };
10804
+ function loadConfig(configPath, overrides = {}) {
10805
+ let base = {};
10806
+ if (configPath) {
10807
+ let raw;
10808
+ try {
10809
+ raw = readFileSync(configPath, "utf8");
10810
+ } catch (err) {
10811
+ throw new ConfigError(`could not read config at "${configPath}": ${err instanceof Error ? err.message : String(err)}`);
9845
10812
  }
9846
- if (parsed.ok === true) {
9847
- pending.delete(parsed.id);
9848
- entry.resolve(parsed.done === true ? void 0 : parsed.result);
9849
- return;
10813
+ try {
10814
+ base = JSON.parse(raw);
10815
+ } catch (err) {
10816
+ throw new ConfigError(`config at "${configPath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
9850
10817
  }
9851
- pending.delete(parsed.id);
9852
- const shape = parsed.error;
9853
- entry.reject(
9854
- new ControlError(
9855
- typeof shape?.code === "string" ? shape.code : "internal_error",
9856
- typeof shape?.message === "string" ? shape.message : "unknown control error"
9857
- )
9858
- );
9859
10818
  }
9860
- socket.on("data", (chunk) => {
9861
- let lines;
10819
+ const merged = { ...base, ...overrides };
10820
+ if (merged.gitWorkspace !== void 0) {
9862
10821
  try {
9863
- lines = reader.push(chunk);
9864
- } catch {
9865
- socket.destroy();
9866
- return;
9867
- }
9868
- for (const line of lines) {
9869
- let parsed;
9870
- try {
9871
- parsed = JSON.parse(line);
9872
- } catch {
9873
- continue;
9874
- }
9875
- handleFrame(parsed);
10822
+ GitWorkspaceManager.validateConfig(merged.gitWorkspace);
10823
+ } catch (error) {
10824
+ throw new ConfigError(error instanceof Error ? error.message : "invalid gitWorkspace configuration");
9876
10825
  }
9877
- });
9878
- socket.on("close", () => {
9879
- closed = true;
9880
- for (const entry of pending.values()) entry.reject(new Error("control connection closed"));
9881
- pending.clear();
9882
- });
9883
- socket.on("error", () => {
9884
- });
9885
- function send(method, params, onEvent) {
9886
- const id = `c${++idSeq}`;
9887
- const promise = new Promise((resolve, reject) => {
9888
- pending.set(id, { resolve, reject, onEvent });
9889
- });
9890
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, method, params }));
9891
- return { id, promise };
9892
10826
  }
9893
- return {
9894
- async request(method, params) {
9895
- if (closed) throw new Error("control connection is closed");
9896
- const { promise } = send(method, params);
9897
- const result = await withTimeout(promise, opts.requestTimeoutMs ?? 1e4, `control request "${method}" timed out`);
9898
- return result;
9899
- },
9900
- subscribe(method, params, onEvent) {
9901
- const { id, promise } = send(method, params, onEvent);
9902
- promise.catch(() => {
9903
- });
9904
- return {
9905
- close: () => {
9906
- pending.delete(id);
9907
- socket.destroy();
9908
- }
9909
- };
9910
- },
9911
- close() {
9912
- socket.destroy();
10827
+ for (const field of REQUIRED_FIELDS) {
10828
+ if (!merged[field]) {
10829
+ throw new ConfigError(`config is missing required field "${field}"`);
9913
10830
  }
9914
- };
10831
+ }
10832
+ return merged;
9915
10833
  }
9916
- async function isControlDaemonGone(storeDir, productId) {
9917
- const tokenGone = await promises.stat(controlTokenPath(storeDir)).then(
9918
- () => false,
9919
- (err) => err.code === "ENOENT"
9920
- );
9921
- if (!tokenGone) return false;
9922
- const endpoint = controlEndpointPath(productId, storeDir);
9923
- return new Promise((resolve) => {
9924
- const socket = net.createConnection(endpoint);
9925
- const finish = (gone) => {
9926
- socket.removeAllListeners();
9927
- socket.destroy();
9928
- resolve(gone);
9929
- };
9930
- socket.once("connect", () => finish(false));
9931
- socket.once("error", (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
9932
- });
10834
+ function resolveStoreDir(config) {
10835
+ return DeviceStore.resolveDir(config.productId, config.storeDir);
10836
+ }
10837
+ function argValue(args, flag) {
10838
+ const idx = args.indexOf(flag);
10839
+ const value = idx >= 0 ? args[idx + 1] : void 0;
10840
+ return value !== void 0 && !value.startsWith("--") ? value : void 0;
10841
+ }
10842
+ function hasFlag(args, flag) {
10843
+ return args.includes(flag);
10844
+ }
10845
+ function positionalArgs(args, valueFlags = []) {
10846
+ const result = [];
10847
+ for (let i = 0; i < args.length; i++) {
10848
+ const arg = args[i];
10849
+ if (arg === void 0) continue;
10850
+ if (valueFlags.includes(arg)) {
10851
+ i++;
10852
+ continue;
10853
+ }
10854
+ result.push(arg);
10855
+ }
10856
+ return result;
9933
10857
  }
9934
10858
 
9935
10859
  // src/bin/format.ts
@@ -10029,6 +10953,19 @@ function formatDaemonEventLine(event, options = {}) {
10029
10953
  ].filter((part) => part !== void 0);
10030
10954
  return parts.join(" ");
10031
10955
  }
10956
+ case "device-assertion": {
10957
+ const parts = event.result === "issued" ? [
10958
+ `${prefix} device-assertion result=issued`,
10959
+ `audience=${quote(event.audience)}`,
10960
+ `jti=${event.jti}`,
10961
+ `expiresAt=${event.expiresAt}`
10962
+ ] : [
10963
+ `${prefix} device-assertion result=denied`,
10964
+ `reason=${event.reason}`,
10965
+ event.audienceSize !== void 0 ? `audienceSize=${event.audienceSize}` : void 0
10966
+ ].filter((part) => part !== void 0);
10967
+ return parts.join(" ");
10968
+ }
10032
10969
  }
10033
10970
  }
10034
10971
  function formatTaskLine(task) {
@@ -11259,6 +12196,14 @@ function redactForAudit(event) {
11259
12196
  return { ...base, reason: event.reason, undeliveredOutboxCount: event.undeliveredOutboxCount };
11260
12197
  case "stale-approval-decision":
11261
12198
  return { ...base, taskId: event.taskId, decision: event.decision, reasonSize: byteSize(event.reason) };
12199
+ case "device-assertion":
12200
+ return event.result === "issued" ? {
12201
+ ...base,
12202
+ result: "issued",
12203
+ audience: event.audience,
12204
+ jti: event.jti,
12205
+ expiresAt: event.expiresAt
12206
+ } : { ...base, result: "denied", reason: event.reason, audienceSize: event.audienceSize };
11262
12207
  case "git-workspace":
11263
12208
  return {
11264
12209
  ...base,
@@ -11399,6 +12344,26 @@ function reconstructDaemonEvent(raw) {
11399
12344
  reason: reasonSize === void 0 ? void 0 : placeholderFor(reasonSize)
11400
12345
  };
11401
12346
  }
12347
+ case "device-assertion": {
12348
+ if (raw.result === "issued") {
12349
+ return {
12350
+ kind: "device-assertion",
12351
+ ts,
12352
+ result: "issued",
12353
+ audience: typeof raw.audience === "string" ? raw.audience : "",
12354
+ jti: typeof raw.jti === "string" ? raw.jti : "",
12355
+ expiresAt: typeof raw.expiresAt === "string" ? raw.expiresAt : ""
12356
+ };
12357
+ }
12358
+ const audienceSize = num(raw.audienceSize);
12359
+ return {
12360
+ kind: "device-assertion",
12361
+ ts,
12362
+ result: "denied",
12363
+ reason: typeof raw.reason === "string" ? raw.reason : "",
12364
+ ...audienceSize === void 0 ? {} : { audienceSize }
12365
+ };
12366
+ }
11402
12367
  case "git-workspace": {
11403
12368
  const commitsSinceBaseline = gitCount(raw.commitsSinceBaseline);
11404
12369
  const dirty = gitDirty(raw.dirty);