@byok-sdk/client 0.1.1 → 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.
Files changed (34) hide show
  1. package/README.md +46 -6
  2. package/dist/adapters/claude/claude-adapter.d.ts +3 -0
  3. package/dist/adapters/claude/resolve-bin.d.ts +2 -2
  4. package/dist/adapters/codex/codex-adapter.d.ts +5 -3
  5. package/dist/adapters/index.d.ts +1 -1
  6. package/dist/adapters/index.js +293 -100
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/adapters/pi/events.d.ts +10 -37
  9. package/dist/adapters/pi/permission-mapping.d.ts +4 -20
  10. package/dist/adapters/pi/pi-adapter.d.ts +22 -0
  11. package/dist/adapters/pi/resolve-bin.d.ts +18 -14
  12. package/dist/adapters/pi/rpc-client.d.ts +1 -4
  13. package/dist/adapters/provider-credential-environment.d.ts +18 -0
  14. package/dist/bin/byok-agent.js +1839 -838
  15. package/dist/bin/byok-agent.js.map +1 -1
  16. package/dist/bin/byok-approval-mcp.js +2 -2
  17. package/dist/bin/byok-approval-mcp.js.map +1 -1
  18. package/dist/daemon/assertion-client.d.ts +68 -0
  19. package/dist/daemon/capabilities-client.d.ts +48 -0
  20. package/dist/daemon/control-protocol.d.ts +81 -4
  21. package/dist/daemon/create-daemon.d.ts +169 -1
  22. package/dist/daemon/daemon-owner.d.ts +35 -0
  23. package/dist/daemon/device-assertion-signer.d.ts +41 -0
  24. package/dist/daemon/device-keys.d.ts +15 -13
  25. package/dist/daemon/observer.d.ts +68 -3
  26. package/dist/daemon/presence-publisher.d.ts +69 -0
  27. package/dist/daemon/skill-pack-installer.d.ts +116 -0
  28. package/dist/daemon/task-runner.d.ts +129 -3
  29. package/dist/index.d.ts +22 -3
  30. package/dist/index.js +1872 -284
  31. package/dist/index.js.map +1 -1
  32. package/dist/lifecycle/create-service-lifecycle.d.ts +2 -2
  33. package/dist/types.d.ts +29 -0
  34. package/package.json +6 -5
@@ -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';
4
- import { readFileSync, promises, linkSync, fstatSync, lstatSync, unlinkSync, constants, readSync, openSync, writeFileSync, fchmodSync, fsyncSync, closeSync, opendirSync, realpathSync, mkdirSync, existsSync, renameSync, chmodSync, statSync, readdirSync } from 'fs';
5
- import path19, { join, isAbsolute } from 'path';
3
+ import { randomUUID, createHash, randomBytes, timingSafeEqual, createHmac, createPrivateKey, generateKeyPairSync, sign } from 'crypto';
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, { 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';
@@ -72,7 +73,7 @@ function gitEnvironment(readOnly) {
72
73
  return env;
73
74
  }
74
75
  function stableGitWorkspaceOwnerId(storeDir, productId) {
75
- const identity = `${path19.resolve(storeDir)}\\0${productId}`;
76
+ const identity = `${path20.resolve(storeDir)}\\0${productId}`;
76
77
  return `store-product:${createHash("sha256").update(identity).digest("hex")}`;
77
78
  }
78
79
  var GUIDANCE = [
@@ -84,11 +85,11 @@ var GUIDANCE = [
84
85
  "Leave incomplete work visible for recovery."
85
86
  ].join("\n");
86
87
  function canonical(value) {
87
- return path19.resolve(value);
88
+ return path20.resolve(value);
88
89
  }
89
90
  function isContained(root, candidate) {
90
- const relative = path19.relative(root, candidate);
91
- return relative === "" || !relative.startsWith(`..${path19.sep}`) && !path19.isAbsolute(relative);
91
+ const relative = path20.relative(root, candidate);
92
+ return relative === "" || !relative.startsWith(`..${path20.sep}`) && !path20.isAbsolute(relative);
92
93
  }
93
94
  function bounded(value, max) {
94
95
  return Buffer.byteLength(value, "utf8") <= max ? value : value.slice(0, max);
@@ -193,7 +194,7 @@ var GitWorkspaceManager = class {
193
194
  await this.ensureOwnerMarker();
194
195
  }
195
196
  async ensureOwnerMarker() {
196
- const markerPath = path19.join(this.workspaceRoot, OWNER_MARKER);
197
+ const markerPath = path20.join(this.workspaceRoot, OWNER_MARKER);
197
198
  let existing;
198
199
  try {
199
200
  existing = JSON.parse(await promises.readFile(markerPath, "utf8"));
@@ -352,7 +353,7 @@ ${instruction}`;
352
353
  if (error instanceof GitWorkspaceError || code !== "ENOENT" && code !== "ENOTDIR") {
353
354
  throw new GitWorkspaceError("workspace-root-invalid", "workspace directory is invalid");
354
355
  }
355
- const parent = path19.dirname(current);
356
+ const parent = path20.dirname(current);
356
357
  if (parent === current) {
357
358
  throw new GitWorkspaceError("workspace-root-invalid", "workspace directory is invalid");
358
359
  }
@@ -410,7 +411,7 @@ async function atomicWriteFile(filePath, data, options = {}) {
410
411
  await target.close();
411
412
  }
412
413
  if (process.platform !== "win32") {
413
- const directory = await promises.open(path19.dirname(filePath), "r");
414
+ const directory = await promises.open(path20.dirname(filePath), "r");
414
415
  try {
415
416
  await directory.sync();
416
417
  } finally {
@@ -571,7 +572,7 @@ function isProtected(record) {
571
572
  var GitWorkspaceStore = class {
572
573
  constructor(storeDir, options = {}) {
573
574
  this.storeDir = storeDir;
574
- this.filePath = path19.join(storeDir, FILE_NAME);
575
+ this.filePath = path20.join(storeDir, FILE_NAME);
575
576
  this.maxRecords = Math.max(1, Math.floor(options.maxRecords ?? MAX_RECORDS));
576
577
  }
577
578
  storeDir;
@@ -701,19 +702,50 @@ var GitWorkspaceStore = class {
701
702
  await atomicWriteFile(this.filePath, JSON.stringify(ledger, null, 2), { mode: 384 });
702
703
  }
703
704
  };
704
-
705
- // src/adapters/pi/resolve-bin.ts
705
+ var PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
706
+ function readPackageJson(dir) {
707
+ const candidate = path20.join(dir, "package.json");
708
+ if (!existsSync(candidate)) return void 0;
709
+ try {
710
+ return JSON.parse(readFileSync(candidate, "utf8"));
711
+ } catch {
712
+ return void 0;
713
+ }
714
+ }
706
715
  function resolvePiBin() {
707
716
  const override = process.env.BYOK_PI_BIN;
708
717
  if (override) {
709
- return { command: override, source: "path" };
718
+ return { command: override, source: "env" };
719
+ }
720
+ try {
721
+ const mainEntryUrl = import.meta.resolve(PI_PACKAGE_NAME);
722
+ let dir = path20.dirname(fileURLToPath(mainEntryUrl));
723
+ for (let depth = 0; depth < 6; depth++) {
724
+ const pkg = readPackageJson(dir);
725
+ if (pkg?.name === PI_PACKAGE_NAME) {
726
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.pi;
727
+ if (binRel) {
728
+ return { command: path20.join(dir, binRel), source: "package" };
729
+ }
730
+ break;
731
+ }
732
+ const parent = path20.dirname(dir);
733
+ if (parent === dir) break;
734
+ dir = parent;
735
+ }
736
+ } catch (cause) {
737
+ throw new Error(
738
+ `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`,
739
+ { cause }
740
+ );
710
741
  }
711
- return { command: "pi", source: "path" };
742
+ throw new Error(
743
+ `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`
744
+ );
712
745
  }
713
746
 
714
747
  // src/adapters/pi/permission-mapping.ts
715
748
  var READONLY_TOOLS = ["read", "grep", "find", "ls"];
716
- var DEFAULT_ACTIVE_TOOLS = ["read", "bash", "edit", "write"];
717
749
  function mapPermissionPolicyToPiArgs(policy) {
718
750
  if (policy.network === false) {
719
751
  return {
@@ -732,22 +764,18 @@ function mapPermissionPolicyToPiArgs(policy) {
732
764
  const denyTools = policy.denyTools ?? [];
733
765
  if (policy.mode === "readonly") {
734
766
  const base = policy.allowTools ? policy.allowTools.filter((tool) => READONLY_TOOLS.includes(tool)) : [...READONLY_TOOLS];
735
- const effective = subtractDenied(base, denyTools);
736
- return { ok: true, args: effective.length === 0 ? ["--no-tools"] : ["--tools", effective.join(",")] };
737
- }
738
- if (denyTools.length > 0) {
739
- const base = policy.allowTools && policy.allowTools.length > 0 ? policy.allowTools : [...DEFAULT_ACTIVE_TOOLS];
740
- const effective = subtractDenied(base, denyTools);
741
- return { ok: true, args: effective.length === 0 ? ["--no-tools"] : ["--tools", effective.join(",")] };
767
+ if (base.length === 0) return { ok: true, args: ["--no-tools"] };
768
+ return {
769
+ ok: true,
770
+ args: ["--tools", base.join(","), ...denyTools.length > 0 ? ["--exclude-tools", denyTools.join(",")] : []]
771
+ };
742
772
  }
773
+ const args = [];
743
774
  if (policy.allowTools && policy.allowTools.length > 0) {
744
- return { ok: true, args: ["--tools", policy.allowTools.join(",")] };
775
+ args.push("--tools", policy.allowTools.join(","));
745
776
  }
746
- return { ok: true, args: [] };
747
- }
748
- function subtractDenied(tools, denyTools) {
749
- const denied = new Set(denyTools);
750
- return tools.filter((tool) => !denied.has(tool));
777
+ if (denyTools.length > 0) args.push("--exclude-tools", denyTools.join(","));
778
+ return { ok: true, args };
751
779
  }
752
780
 
753
781
  // src/adapters/pi/events.ts
@@ -772,7 +800,7 @@ function mapPiMessageToAgentEvent(msg) {
772
800
  output: { result: msg.result, isError: msg.isError === true }
773
801
  };
774
802
  }
775
- case "agent_end":
803
+ case "agent_settled":
776
804
  return { type: "turn_end" };
777
805
  /**
778
806
  * `artifact` is NOT a real pi RPC message — pi's own `write` tool only
@@ -808,16 +836,22 @@ function mapPiMessageToAgentEvent(msg) {
808
836
  // `recordUnmappedFrame`) can tell "known, expected, silently ignored"
809
837
  // apart from "genuinely never seen before" (falls to `default` below).
810
838
  case "agent_start":
839
+ case "agent_end":
840
+ // one low-level run; `agent_settled` is BYOK completion
811
841
  case "turn_start":
812
842
  case "turn_end":
813
- // pi's own per-LLM-turn boundary, not ours — see `agent_end` above
843
+ // pi's own per-LLM-turn boundary, not ours
814
844
  case "message_start":
815
845
  case "message_end":
846
+ case "bash_execution_update":
816
847
  case "tool_execution_update":
817
848
  case "queue_update":
818
849
  case "compaction_start":
819
850
  case "compaction_end":
820
851
  case "auto_retry_start":
852
+ case "summarization_retry_scheduled":
853
+ case "summarization_retry_attempt_start":
854
+ case "summarization_retry_finished":
821
855
  case "session_info_changed":
822
856
  case "thinking_level_changed":
823
857
  return void 0;
@@ -827,15 +861,20 @@ function mapPiMessageToAgentEvent(msg) {
827
861
  }
828
862
  var ROUTINE_PI_EVENT_TYPES = /* @__PURE__ */ new Set([
829
863
  "agent_start",
864
+ "agent_end",
830
865
  "turn_start",
831
866
  "turn_end",
832
867
  "message_start",
833
868
  "message_end",
869
+ "bash_execution_update",
834
870
  "tool_execution_update",
835
871
  "queue_update",
836
872
  "compaction_start",
837
873
  "compaction_end",
838
874
  "auto_retry_start",
875
+ "summarization_retry_scheduled",
876
+ "summarization_retry_attempt_start",
877
+ "summarization_retry_finished",
839
878
  "session_info_changed",
840
879
  "thinking_level_changed"
841
880
  ]);
@@ -969,10 +1008,7 @@ var PiRpcClient = class {
969
1008
  * traffic. Logs once per distinct type (not per occurrence, so a
970
1009
  * repeating unmapped type can't spam stdout); the running tally is also
971
1010
  * folded into this client's exit-time error message (`buildExitError`) so
972
- * a post-mortem on a failed/hung task has it without needing separate log
973
- * scraping. This is the exact mechanism that would have turned this
974
- * task's root-cause hang (`agent_end` arriving with no mapping) into a
975
- * one-line, immediate warning instead of a silent stall.
1011
+ * a post-mortem on a failed/hung task has it without separate log scraping.
976
1012
  */
977
1013
  recordUnmappedFrame(type) {
978
1014
  const next = (this.unmappedFrameCounts.get(type) ?? 0) + 1;
@@ -1083,13 +1119,8 @@ var PiRpcClient = class {
1083
1119
  }
1084
1120
  };
1085
1121
 
1086
- // src/adapters/pi/pi-adapter.ts
1087
- var execFileAsync = promisify(execFile);
1088
- var DETECT_TIMEOUT_MS = 5e3;
1089
- function errorMessage(err) {
1090
- return err instanceof Error ? err.message : String(err);
1091
- }
1092
- var KNOWN_PROVIDER_ENV_VARS = [
1122
+ // src/adapters/provider-credential-environment.ts
1123
+ var PROVIDER_CREDENTIAL_ENV_NAMES = [
1093
1124
  "ANTHROPIC_API_KEY",
1094
1125
  "ANTHROPIC_OAUTH_TOKEN",
1095
1126
  "OPENAI_API_KEY",
@@ -1100,24 +1131,66 @@ var KNOWN_PROVIDER_ENV_VARS = [
1100
1131
  "MISTRAL_API_KEY",
1101
1132
  "OPENROUTER_API_KEY",
1102
1133
  "XAI_API_KEY",
1103
- // Confirmed against the installed pi's own docs/providers.md ("ZAI |
1104
- // `ZAI_API_KEY` | `zai`") and exercised live against real GLM traffic
1105
- // during this task's acceptance run — omitting it made `authPresent`
1106
- // silently false for a perfectly valid, working z.ai/GLM setup.
1107
1134
  "ZAI_API_KEY"
1108
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
+ }
1109
1181
  var PiAdapter = class {
1110
1182
  constructor(options = {}) {
1111
1183
  this.options = options;
1112
1184
  }
1113
1185
  options;
1114
1186
  id = "pi";
1187
+ supportsDispatchSelection = true;
1115
1188
  async detect() {
1116
- const bin = this.resolveBin();
1117
1189
  try {
1190
+ const bin = this.resolveBin();
1118
1191
  const { stdout, stderr } = await execFileAsync(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS });
1119
1192
  const version = stdout.trim() || stderr.trim();
1120
- 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);
1121
1194
  return { present: true, version, authPresent };
1122
1195
  } catch {
1123
1196
  return { present: false };
@@ -1136,7 +1209,7 @@ var PiAdapter = class {
1136
1209
  * variable beyond the platform baseline (`daemon/environment.ts`).
1137
1210
  */
1138
1211
  environmentRequirements() {
1139
- return { credentialNames: KNOWN_PROVIDER_ENV_VARS };
1212
+ return { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES };
1140
1213
  }
1141
1214
  async start(task, ctx) {
1142
1215
  if (typeof task.instruction !== "string") {
@@ -1148,12 +1221,45 @@ var PiAdapter = class {
1148
1221
  }
1149
1222
  const bin = this.resolveBin();
1150
1223
  const resumeSessionId = task.sessionRef;
1151
- 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
+ }
1152
1258
  const rpc = new PiRpcClient({
1153
- command: bin.command,
1259
+ command,
1154
1260
  args,
1155
1261
  cwd: ctx.workspaceDir,
1156
- env: ctx.env,
1262
+ env: selection === void 0 ? ctx.env : withoutProviderCredentials(ctx.env),
1157
1263
  spawnFn: this.options.spawnFn
1158
1264
  });
1159
1265
  const response = await rpc.send({ type: "prompt", message: task.instruction });
@@ -1172,7 +1278,7 @@ var PiAdapter = class {
1172
1278
  throw err;
1173
1279
  }
1174
1280
  }
1175
- return new PiSession(sessionRef, rpc);
1281
+ return new PiSession(sessionRef, rpc, selection);
1176
1282
  }
1177
1283
  resolveBin() {
1178
1284
  return (this.options.resolveBin ?? resolvePiBin)();
@@ -1200,12 +1306,14 @@ async function resolveFreshSessionId(rpc) {
1200
1306
  );
1201
1307
  }
1202
1308
  var PiSession = class {
1203
- constructor(sessionRef, rpc) {
1309
+ constructor(sessionRef, rpc, selection) {
1204
1310
  this.sessionRef = sessionRef;
1205
1311
  this.rpc = rpc;
1312
+ this.selection = selection;
1206
1313
  }
1207
1314
  sessionRef;
1208
1315
  rpc;
1316
+ selection;
1209
1317
  get events() {
1210
1318
  const rpc = this.rpc;
1211
1319
  return {
@@ -1234,6 +1342,12 @@ var PiSession = class {
1234
1342
  if (typeof task.instruction !== "string") {
1235
1343
  throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1236
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
+ }
1237
1351
  await this.rpc.send({ type: "prompt", message: task.instruction, streamingBehavior: "followUp" });
1238
1352
  }
1239
1353
  async interrupt() {
@@ -1260,7 +1374,7 @@ function resolveApprovalMcpBin() {
1260
1374
  if (override) {
1261
1375
  return { command: override, args: [], source: "env" };
1262
1376
  }
1263
- const distBin = path19.join(path19.dirname(fileURLToPath(import.meta.url)), "bin", "byok-approval-mcp.js");
1377
+ const distBin = path20.join(path20.dirname(fileURLToPath(import.meta.url)), "bin", "byok-approval-mcp.js");
1264
1378
  return { command: process.execPath, args: [distBin], source: "dist" };
1265
1379
  }
1266
1380
 
@@ -1291,7 +1405,7 @@ function mapPermissionPolicyToClaudeArgs(policy) {
1291
1405
  }
1292
1406
  if (policy.mode === "readonly") {
1293
1407
  const base = policy.allowTools ? policy.allowTools.filter((tool) => READONLY_TOOLS2.includes(tool)) : [...READONLY_TOOLS2];
1294
- const effective = subtractDenied2(base, denyTools);
1408
+ const effective = subtractDenied(base, denyTools);
1295
1409
  return { ok: true, args: ["--permission-mode", "default", "--tools", effective.join(",")] };
1296
1410
  }
1297
1411
  if (denyTools.length > 0) {
@@ -1308,7 +1422,7 @@ function mapPermissionPolicyToClaudeArgs(policy) {
1308
1422
  }
1309
1423
  return { ok: true, args };
1310
1424
  }
1311
- function subtractDenied2(tools, denyTools) {
1425
+ function subtractDenied(tools, denyTools) {
1312
1426
  const denied = new Set(denyTools);
1313
1427
  return tools.filter((tool) => !denied.has(tool));
1314
1428
  }
@@ -1340,7 +1454,7 @@ var EXTENSION_CONTENT_TYPES = {
1340
1454
  ".yml": "application/yaml"
1341
1455
  };
1342
1456
  function guessContentType(filePath) {
1343
- const ext = path19.extname(filePath).toLowerCase();
1457
+ const ext = path20.extname(filePath).toLowerCase();
1344
1458
  return EXTENSION_CONTENT_TYPES[ext] ?? "application/octet-stream";
1345
1459
  }
1346
1460
  function mapAssistant(msg, correlation) {
@@ -1412,11 +1526,11 @@ function tryBuildArtifactEvent(msg, workspaceDir) {
1412
1526
  const filePath = toolUseResult && typeof toolUseResult.filePath === "string" ? toolUseResult.filePath : void 0;
1413
1527
  if (!filePath) return void 0;
1414
1528
  const realWorkspaceDir = tryRealpath(workspaceDir) ?? workspaceDir;
1415
- const fileDir = path19.dirname(filePath);
1529
+ const fileDir = path20.dirname(filePath);
1416
1530
  const realFileDir = tryRealpath(fileDir) ?? fileDir;
1417
- const realFilePath = path19.join(realFileDir, path19.basename(filePath));
1418
- const relative = path19.relative(realWorkspaceDir, realFilePath);
1419
- if (relative === "" || relative.startsWith("..") || path19.isAbsolute(relative)) {
1531
+ const realFilePath = path20.join(realFileDir, path20.basename(filePath));
1532
+ const relative = path20.relative(realWorkspaceDir, realFilePath);
1533
+ if (relative === "" || relative.startsWith("..") || path20.isAbsolute(relative)) {
1420
1534
  return void 0;
1421
1535
  }
1422
1536
  return { type: "artifact", name: relative, contentType: guessContentType(filePath) };
@@ -1658,7 +1772,7 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1658
1772
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1659
1773
  var execFileAsync2 = promisify(execFile);
1660
1774
  var DETECT_TIMEOUT_MS2 = 5e3;
1661
- async function cleanupApprovalMcpConfigDir(dir) {
1775
+ async function cleanupMcpConfigDir(dir) {
1662
1776
  if (!dir) return;
1663
1777
  await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1664
1778
  });
@@ -1668,6 +1782,7 @@ var ClaudeAdapter = class {
1668
1782
  this.options = options;
1669
1783
  }
1670
1784
  options;
1785
+ supportsDispatchSelection = true;
1671
1786
  id = "claude";
1672
1787
  async detect() {
1673
1788
  const bin = this.resolveBin();
@@ -1681,7 +1796,13 @@ var ClaudeAdapter = class {
1681
1796
  }
1682
1797
  }
1683
1798
  capabilities() {
1684
- 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
+ };
1685
1806
  }
1686
1807
  /**
1687
1808
  * M5: deliberate product-boundary decision, not an oversight — byok's
@@ -1707,42 +1828,51 @@ var ClaudeAdapter = class {
1707
1828
  if (!mapping.ok) {
1708
1829
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
1709
1830
  }
1710
- 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;
1711
1835
  if (mapping.needsApprovalMcp) {
1712
1836
  if (!ctx.approvalChannel) {
1713
1837
  throw new PolicyUnsupportedError(
1714
1838
  'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1715
1839
  );
1716
1840
  }
1717
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1718
- approvalMcpConfigDir = await promises.mkdtemp(path19.join(os.tmpdir(), "byok-approval-mcp-"));
1719
- 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(() => {
1720
1850
  });
1721
- const mcpConfigPath = path19.join(approvalMcpConfigDir, "mcp-config.json");
1722
- const mcpConfig = {
1723
- mcpServers: {
1724
- [APPROVAL_MCP_SERVER_NAME]: {
1725
- command: approvalMcpBin.command,
1726
- args: approvalMcpBin.args,
1727
- env: {
1728
- BYOK_STORE_DIR: ctx.approvalChannel.storeDir,
1729
- BYOK_PRODUCT_ID: ctx.approvalChannel.productId,
1730
- BYOK_TASK_ID: ctx.approvalChannel.taskId,
1731
- BYOK_APPROVAL_TIMEOUT_MS: String(ctx.approvalChannel.timeoutMs)
1732
- }
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)
1733
1865
  }
1734
- }
1735
- };
1736
- await promises.writeFile(mcpConfigPath, JSON.stringify(mcpConfig), { mode: 384 });
1866
+ };
1867
+ }
1868
+ await promises.writeFile(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 384 });
1737
1869
  mapping.args = [
1738
1870
  ...mapping.args,
1739
- "--permission-prompt-tool",
1740
- `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`,
1871
+ ...mapping.needsApprovalMcp ? ["--permission-prompt-tool", `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`] : [],
1741
1872
  "--mcp-config",
1742
1873
  mcpConfigPath,
1743
- // Never let this task's confirm-mode run pick up some OTHER MCP
1744
- // server from ambient project/user config the approval channel is
1745
- // 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.
1746
1876
  "--strict-mcp-config"
1747
1877
  ];
1748
1878
  }
@@ -1759,6 +1889,7 @@ var ClaudeAdapter = class {
1759
1889
  // "Error: When using --print, --output-format=stream-json requires
1760
1890
  // --verbose", before spawning any model call.
1761
1891
  "--verbose",
1892
+ ...modelId ? ["--model", modelId] : [],
1762
1893
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1763
1894
  ...mapping.args
1764
1895
  ];
@@ -1766,7 +1897,7 @@ var ClaudeAdapter = class {
1766
1897
  command: bin.command,
1767
1898
  args,
1768
1899
  cwd: ctx.workspaceDir,
1769
- env: ctx.env,
1900
+ env: withoutProviderCredentials(ctx.env),
1770
1901
  spawnFn: this.options.spawnFn
1771
1902
  });
1772
1903
  client.writeUserMessage(task.instruction);
@@ -1775,17 +1906,24 @@ var ClaudeAdapter = class {
1775
1906
  sessionRef = await client.waitForInit();
1776
1907
  } catch (err) {
1777
1908
  client.kill();
1778
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1909
+ await cleanupMcpConfigDir(mcpConfigDir);
1779
1910
  throw err;
1780
1911
  }
1781
1912
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1782
1913
  client.kill();
1783
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1914
+ await cleanupMcpConfigDir(mcpConfigDir);
1784
1915
  throw new Error(
1785
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)`
1786
1917
  );
1787
1918
  }
1788
- 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
+ );
1789
1927
  }
1790
1928
  /**
1791
1929
  * `claude auth status --json` is claude's OWN non-secret login-state
@@ -1819,19 +1957,31 @@ var ClaudeAdapter = class {
1819
1957
  return (this.options.resolveBin ?? resolveClaudeBin)();
1820
1958
  }
1821
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
+ }
1822
1970
  var ClaudeSession = class {
1823
- constructor(sessionRef, client, workspaceDir, approvalChannel, approvalMcpConfigDir) {
1971
+ constructor(sessionRef, client, workspaceDir, approvalChannel, mcpConfigDir, modelId) {
1824
1972
  this.sessionRef = sessionRef;
1825
1973
  this.client = client;
1826
1974
  this.workspaceDir = workspaceDir;
1827
1975
  this.approvalChannel = approvalChannel;
1828
- this.approvalMcpConfigDir = approvalMcpConfigDir;
1976
+ this.mcpConfigDir = mcpConfigDir;
1977
+ this.modelId = modelId;
1829
1978
  }
1830
1979
  sessionRef;
1831
1980
  client;
1832
1981
  workspaceDir;
1833
1982
  approvalChannel;
1834
- approvalMcpConfigDir;
1983
+ mcpConfigDir;
1984
+ modelId;
1835
1985
  correlation = createToolUseCorrelation();
1836
1986
  get events() {
1837
1987
  const client = this.client;
@@ -1882,6 +2032,12 @@ var ClaudeSession = class {
1882
2032
  if (typeof task.instruction !== "string") {
1883
2033
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1884
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
+ }
1885
2041
  this.client.writeUserMessage(task.instruction);
1886
2042
  }
1887
2043
  /**
@@ -1901,7 +2057,7 @@ var ClaudeSession = class {
1901
2057
  }
1902
2058
  async close() {
1903
2059
  this.client.kill();
1904
- await cleanupApprovalMcpConfigDir(this.approvalMcpConfigDir);
2060
+ await cleanupMcpConfigDir(this.mcpConfigDir);
1905
2061
  }
1906
2062
  /**
1907
2063
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -2065,8 +2221,8 @@ function extractArtifactEvents(changes, workspaceDir) {
2065
2221
  const absolutePath = typeof change.path === "string" ? change.path : void 0;
2066
2222
  const kind = typeof change.kind === "string" ? change.kind : void 0;
2067
2223
  if (!absolutePath || kind === "delete") continue;
2068
- const relative = path19.relative(workspaceDir, absolutePath);
2069
- if (relative.length === 0 || relative.startsWith("..") || path19.isAbsolute(relative)) continue;
2224
+ const relative = path20.relative(workspaceDir, absolutePath);
2225
+ if (relative.length === 0 || relative.startsWith("..") || path20.isAbsolute(relative)) continue;
2070
2226
  events.push({ type: "artifact", name: relative, contentType: guessContentType2(relative) });
2071
2227
  }
2072
2228
  return events;
@@ -2087,7 +2243,7 @@ var CONTENT_TYPE_BY_EXTENSION = {
2087
2243
  ".csv": "text/csv"
2088
2244
  };
2089
2245
  function guessContentType2(relativePath) {
2090
- return CONTENT_TYPE_BY_EXTENSION[path19.extname(relativePath).toLowerCase()] ?? "application/octet-stream";
2246
+ return CONTENT_TYPE_BY_EXTENSION[path20.extname(relativePath).toLowerCase()] ?? "application/octet-stream";
2091
2247
  }
2092
2248
  function extractErrorMessage(rawError) {
2093
2249
  if (typeof rawError === "string") return rawError;
@@ -2225,6 +2381,7 @@ var CodexAdapter = class {
2225
2381
  this.options = options;
2226
2382
  }
2227
2383
  options;
2384
+ supportsDispatchSelection = true;
2228
2385
  id = "codex";
2229
2386
  async detect() {
2230
2387
  const bin = this.resolveBin();
@@ -2248,9 +2405,10 @@ var CodexAdapter = class {
2248
2405
  * Two independently-verified channel gotchas apply here, the "pi lesson"
2249
2406
  * yet again:
2250
2407
  * - `codex login status`'s human-readable "Logged in using ChatGPT"
2251
- * message prints on STDERR, not stdout (the opposite-channel
2252
- * counterpart of pi's own `--version`-goes-to-stderr surprise) both
2253
- * streams are checked here for exactly that reason.
2408
+ * message prints on STDERR, not stdout both streams are checked
2409
+ * here for exactly that reason. pi's `--version` is the same class of
2410
+ * hazard from the other direction: its channel has moved between pi
2411
+ * releases (see ../pi/pi-adapter.ts), so neither stream is assumed.
2254
2412
  * - The NOT-logged-in message/exit-code shape was deliberately never
2255
2413
  * empirically tested: this machine has a real, live ChatGPT login, and
2256
2414
  * running `codex logout` to observe the negative case would have
@@ -2296,17 +2454,20 @@ ${withStreams.stderr ?? ""}`);
2296
2454
  if (!mapping.ok) {
2297
2455
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2298
2456
  }
2457
+ const modelId = subscriptionModel2(task);
2299
2458
  const bin = this.resolveBin();
2300
2459
  const queue = new AsyncQueue();
2301
2460
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
2302
2461
  const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
2462
+ const runtimeEnv = withoutProviderCredentials(ctx.env);
2303
2463
  const { sessionRef, runner } = await runCodexTurn({
2304
2464
  command: bin.command,
2305
2465
  resumeRef: task.sessionRef,
2306
2466
  instruction: task.instruction,
2467
+ modelId,
2307
2468
  policyArgs: mapping.args,
2308
2469
  cwd: ctx.workspaceDir,
2309
- env: ctx.env,
2470
+ env: runtimeEnv,
2310
2471
  spawnFn: this.options.spawnFn,
2311
2472
  workspaceDir,
2312
2473
  queue,
@@ -2323,7 +2484,8 @@ ${withStreams.stderr ?? ""}`);
2323
2484
  queue,
2324
2485
  recordUnmapped,
2325
2486
  initialRunner: runner,
2326
- preparedGit: ctx.gitWorkspace !== void 0
2487
+ preparedGit: ctx.gitWorkspace !== void 0,
2488
+ modelId
2327
2489
  });
2328
2490
  }
2329
2491
  resolveBin() {
@@ -2344,12 +2506,25 @@ function makeUnmappedFrameRecorder(counts) {
2344
2506
  }
2345
2507
  };
2346
2508
  }
2347
- function buildArgv(resumeRef, policyArgs, instruction, preparedGit = false) {
2509
+ function buildArgv(resumeRef, policyArgs, instruction, modelId, preparedGit = false) {
2348
2510
  const base = resumeRef !== void 0 ? ["exec", "resume", resumeRef] : ["exec"];
2349
- 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
+ ];
2350
2519
  }
2351
2520
  async function runCodexTurn(params) {
2352
- 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
+ );
2353
2528
  let firstLineSettled = false;
2354
2529
  let resolveFirstLine;
2355
2530
  let rejectFirstLine;
@@ -2450,6 +2625,7 @@ var CodexSession = class {
2450
2625
  queue;
2451
2626
  recordUnmapped;
2452
2627
  preparedGit;
2628
+ modelId;
2453
2629
  currentRunner;
2454
2630
  closed = false;
2455
2631
  constructor(options) {
@@ -2461,6 +2637,7 @@ var CodexSession = class {
2461
2637
  this.queue = options.queue;
2462
2638
  this.recordUnmapped = options.recordUnmapped;
2463
2639
  this.preparedGit = options.preparedGit;
2640
+ this.modelId = options.modelId;
2464
2641
  this.currentRunner = options.initialRunner;
2465
2642
  void this.forgetRunnerOnceClosed(options.initialRunner);
2466
2643
  }
@@ -2521,6 +2698,13 @@ var CodexSession = class {
2521
2698
  if (!mapping.ok) {
2522
2699
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2523
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;
2524
2708
  const resumeRef = this.sessionRef;
2525
2709
  let sessionRef;
2526
2710
  let runner;
@@ -2529,9 +2713,10 @@ var CodexSession = class {
2529
2713
  command: this.command,
2530
2714
  resumeRef,
2531
2715
  instruction: task.instruction,
2716
+ modelId,
2532
2717
  policyArgs: mapping.args,
2533
2718
  cwd: this.workspaceDir,
2534
- env: this.env,
2719
+ env: withoutProviderCredentials(this.env),
2535
2720
  spawnFn: this.spawnFn,
2536
2721
  workspaceDir: this.workspaceDir,
2537
2722
  queue: this.queue,
@@ -2591,6 +2776,16 @@ var CodexSession = class {
2591
2776
  );
2592
2777
  }
2593
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
+ }
2594
2789
 
2595
2790
  // src/daemon/approvals.ts
2596
2791
  var ApprovalNotFoundError = class extends Error {
@@ -2693,12 +2888,12 @@ var DeviceStore = class _DeviceStore {
2693
2888
  */
2694
2889
  constructor(storeDir, secureDirOptions) {
2695
2890
  this.secureDirOptions = secureDirOptions;
2696
- this.filePath = path19.join(storeDir, "device.json");
2891
+ this.filePath = path20.join(storeDir, "device.json");
2697
2892
  }
2698
2893
  secureDirOptions;
2699
2894
  filePath;
2700
2895
  static defaultDir(productId) {
2701
- return path19.join(os.homedir(), ".byok", productId);
2896
+ return path20.join(os.homedir(), ".byok", productId);
2702
2897
  }
2703
2898
  /**
2704
2899
  * Resolve the one store pathname every daemon/CLI component must share.
@@ -2707,7 +2902,7 @@ var DeviceStore = class _DeviceStore {
2707
2902
  * cwd to pin a quarantine directory inode.
2708
2903
  */
2709
2904
  static resolveDir(productId, configured) {
2710
- return path19.resolve(configured ?? _DeviceStore.defaultDir(productId));
2905
+ return path20.resolve(configured ?? _DeviceStore.defaultDir(productId));
2711
2906
  }
2712
2907
  async load() {
2713
2908
  const opened = await this.openBounded();
@@ -2748,7 +2943,7 @@ var DeviceStore = class _DeviceStore {
2748
2943
  }
2749
2944
  }
2750
2945
  async save(record) {
2751
- const storeDir = path19.dirname(this.filePath);
2946
+ const storeDir = path20.dirname(this.filePath);
2752
2947
  await ensureSecureDir(storeDir, this.secureDirOptions);
2753
2948
  await atomicWriteFile(this.filePath, JSON.stringify(record, null, 2), { mode: 384 });
2754
2949
  }
@@ -2815,13 +3010,10 @@ function exportPrivateKeyPem(privateKey) {
2815
3010
  function importPrivateKeyPem(pem) {
2816
3011
  return createPrivateKey(pem);
2817
3012
  }
2818
- var NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
2819
3013
  function signNonce(privateKey, nonce) {
2820
- const signature = sign(null, Buffer.from(NONCE_SIGNING_DOMAIN + nonce, "utf8"), privateKey);
3014
+ const signature = sign(null, nonceSigningBytes(nonce), privateKey);
2821
3015
  return signature.toString("base64url");
2822
3016
  }
2823
-
2824
- // src/daemon/url.ts
2825
3017
  function toHttpBase(serverUrl) {
2826
3018
  const url = new URL(serverUrl);
2827
3019
  if (url.protocol === "ws:") url.protocol = "http:";
@@ -2831,7 +3023,7 @@ function toHttpBase(serverUrl) {
2831
3023
  return url.toString();
2832
3024
  }
2833
3025
  function toWsUrl(serverUrl) {
2834
- const url = new URL("/byok/ws", toHttpBase(serverUrl));
3026
+ const url = new URL(BYOK_WS_PATH, toHttpBase(serverUrl));
2835
3027
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2836
3028
  return url.toString();
2837
3029
  }
@@ -2919,7 +3111,7 @@ var AuthManager = class {
2919
3111
  return await this.runCredentialMutation(async () => {
2920
3112
  const existing = this.record ?? await this.opts.store.load();
2921
3113
  const keyPair = existing ? { privateKey: importPrivateKeyPem(existing.devicePrivateKeyPem), publicKeyBase64Url: existing.devicePublicKey } : generateDeviceKeyPair();
2922
- const url = new URL("/byok/pair", toHttpBase(this.opts.serverUrl));
3114
+ const url = new URL(BYOK_PAIR_PATH, toHttpBase(this.opts.serverUrl));
2923
3115
  const res = await fetch(url, {
2924
3116
  method: "POST",
2925
3117
  headers: { "content-type": "application/json" },
@@ -2953,6 +3145,7 @@ var AuthManager = class {
2953
3145
  async getValidAccessToken() {
2954
3146
  if (!this.record) throw new Error("device is not paired yet; call pair(pairingCode) first");
2955
3147
  if (this.revoked) throw new DeviceRevokedError();
3148
+ if (this.renewing) return this.renewing;
2956
3149
  if (msUntilExpiry(this.record.expiresAt) > RENEW_MARGIN_MS) return this.record.accessToken;
2957
3150
  return this.renew();
2958
3151
  }
@@ -2981,7 +3174,7 @@ var AuthManager = class {
2981
3174
  const record = this.record;
2982
3175
  const base = toHttpBase(this.opts.serverUrl);
2983
3176
  const privateKey = importPrivateKeyPem(record.devicePrivateKeyPem);
2984
- const challengeRes = await fetch(new URL("/byok/challenge", base), {
3177
+ const challengeRes = await fetch(new URL(BYOK_CHALLENGE_PATH, base), {
2985
3178
  method: "POST",
2986
3179
  headers: { "content-type": "application/json" },
2987
3180
  body: JSON.stringify({ deviceId: record.deviceId })
@@ -2994,7 +3187,7 @@ var AuthManager = class {
2994
3187
  }
2995
3188
  const { nonce } = await challengeRes.json();
2996
3189
  const signature = signNonce(privateKey, nonce);
2997
- const tokenRes = await fetch(new URL("/byok/token", base), {
3190
+ const tokenRes = await fetch(new URL(BYOK_TOKEN_PATH, base), {
2998
3191
  method: "POST",
2999
3192
  headers: { "content-type": "application/json" },
3000
3193
  body: JSON.stringify({ deviceId: record.deviceId, nonce, signature })
@@ -3092,7 +3285,7 @@ var BlobClient = class {
3092
3285
  async resolveInstruction(blobRef) {
3093
3286
  const base = toHttpBase(this.serverUrl);
3094
3287
  const urlRes = await authedFetch(
3095
- new URL(`/byok/blobs/${encodeURIComponent(blobRef.blobId)}/url`, base),
3288
+ new URL(byokBlobUrlPath(blobRef.blobId), base),
3096
3289
  { method: "GET" },
3097
3290
  this.auth
3098
3291
  );
@@ -3120,7 +3313,7 @@ var BlobClient = class {
3120
3313
  const base = toHttpBase(this.serverUrl);
3121
3314
  const reservationId = `blob_${randomUUID()}`;
3122
3315
  const createRes = await authedFetch(
3123
- new URL("/byok/blobs", base),
3316
+ new URL(BYOK_BLOBS_PATH, base),
3124
3317
  {
3125
3318
  method: "POST",
3126
3319
  headers: {
@@ -3152,7 +3345,7 @@ var BlobClient = class {
3152
3345
  let response;
3153
3346
  try {
3154
3347
  response = await authedFetch(
3155
- new URL(`/byok/blobs/${encodeURIComponent(blobId)}/finalize`, base),
3348
+ new URL(byokBlobFinalizePath(blobId), base),
3156
3349
  {
3157
3350
  method: "POST",
3158
3351
  headers: { "idempotency-key": reservationId }
@@ -3175,26 +3368,187 @@ var BlobClient = class {
3175
3368
  throw lastFailure;
3176
3369
  }
3177
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
+ }
3178
3531
  var CONTROL_PROTOCOL_VERSION = 1;
3179
3532
  var HANDSHAKE_TIMEOUT_MS = 3e3;
3180
3533
  var UNIX_SOCKET_PATH_SOFT_LIMIT = 100;
3534
+ var CONTROL_SOCKET_FALLBACK_ROOT = "/tmp";
3181
3535
  function shortHash(input) {
3182
3536
  return createHash("sha256").update(input, "utf8").digest("hex").slice(0, 16);
3183
3537
  }
3184
3538
  function controlSocketPath(storeDir) {
3185
- const candidate = path19.join(storeDir, "control.sock");
3539
+ const candidate = path20.join(storeDir, "control.sock");
3186
3540
  if (Buffer.byteLength(candidate, "utf8") <= UNIX_SOCKET_PATH_SOFT_LIMIT) return candidate;
3187
- return path19.join(os.tmpdir(), `byok-${shortHash(storeDir)}`, "sock");
3541
+ return path20.join(CONTROL_SOCKET_FALLBACK_ROOT, `byok-${shortHash(storeDir)}`, "sock");
3188
3542
  }
3189
3543
  function controlPipeName(productId, storeDir) {
3190
- const id = shortHash(`${productId}|${path19.resolve(storeDir)}`);
3544
+ const id = shortHash(`${productId}|${path20.resolve(storeDir)}`);
3191
3545
  return `\\\\.\\pipe\\byok-${id}`;
3192
3546
  }
3193
3547
  function controlEndpointPath(productId, storeDir, platform = process.platform) {
3194
3548
  return platform === "win32" ? controlPipeName(productId, storeDir) : controlSocketPath(storeDir);
3195
3549
  }
3196
3550
  function controlTokenPath(storeDir) {
3197
- return path19.join(storeDir, "control.token");
3551
+ return path20.join(storeDir, "control.token");
3198
3552
  }
3199
3553
  var SERVER_PROOF_LABEL = "byok-control-server|";
3200
3554
  var CLIENT_AUTH_LABEL = "byok-control-client|";
@@ -3290,6 +3644,16 @@ function parseApprovalsRequestParams(value) {
3290
3644
  if (typeof value.summary !== "string") return void 0;
3291
3645
  return { taskId: value.taskId, summary: value.summary };
3292
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
+ }
3293
3657
  function parseShutdownParams(value) {
3294
3658
  if (!isRecord2(value)) return {};
3295
3659
  return value.reason === "unpair" || value.reason === "operator" ? { reason: value.reason } : {};
@@ -3351,7 +3715,7 @@ async function assertOwnedPrivateDir(dir) {
3351
3715
  }
3352
3716
  async function bindControlEndpoint(server, endpoint) {
3353
3717
  if (process.platform !== "win32") {
3354
- const endpointDir = path19.dirname(endpoint);
3718
+ const endpointDir = path20.dirname(endpoint);
3355
3719
  await promises.mkdir(endpointDir, { recursive: true, mode: 448 });
3356
3720
  await promises.chmod(endpointDir, 448).catch(() => {
3357
3721
  });
@@ -3622,7 +3986,7 @@ var LongPollClient = class {
3622
3986
  try {
3623
3987
  const base = toHttpBase(this.opts.serverUrl);
3624
3988
  const res = await authedFetch(
3625
- new URL("/byok/messages", base),
3989
+ new URL(BYOK_MESSAGES_PATH, base),
3626
3990
  {
3627
3991
  method: "POST",
3628
3992
  headers: { "content-type": "application/json" },
@@ -3646,7 +4010,7 @@ var LongPollClient = class {
3646
4010
  while (this.running) {
3647
4011
  try {
3648
4012
  const base = toHttpBase(this.opts.serverUrl);
3649
- const url = new URL("/byok/events", base);
4013
+ const url = new URL(BYOK_EVENTS_PATH, base);
3650
4014
  const cursor = this.opts.getCursor();
3651
4015
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
3652
4016
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
@@ -4632,7 +4996,7 @@ function sameFileState2(left, right) {
4632
4996
  return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
4633
4997
  }
4634
4998
  async function openOperationalHealthFile(storeDir) {
4635
- const filePath = path19.join(storeDir, OPERATIONAL_HEALTH_FILENAME);
4999
+ const filePath = path20.join(storeDir, OPERATIONAL_HEALTH_FILENAME);
4636
5000
  let namedBefore;
4637
5001
  try {
4638
5002
  namedBefore = await promises.lstat(filePath, { bigint: true });
@@ -4674,7 +5038,7 @@ var OperationalHealthTracker = class {
4674
5038
  #writeTail = Promise.resolve();
4675
5039
  #started = false;
4676
5040
  constructor(storeDir, options = {}) {
4677
- this.#filePath = path19.join(storeDir, OPERATIONAL_HEALTH_FILENAME);
5041
+ this.#filePath = path20.join(storeDir, OPERATIONAL_HEALTH_FILENAME);
4678
5042
  this.#windowMs = options.windowMs ?? 6e4;
4679
5043
  this.#failureThreshold = options.failureThreshold ?? 3;
4680
5044
  this.#maxFailures = options.maxFailures ?? 128;
@@ -4761,7 +5125,7 @@ var OperationalHealthTracker = class {
4761
5125
  async #load() {
4762
5126
  let opened;
4763
5127
  try {
4764
- opened = await openOperationalHealthFile(path19.dirname(this.#filePath));
5128
+ opened = await openOperationalHealthFile(path20.dirname(this.#filePath));
4765
5129
  } catch (err) {
4766
5130
  throw new Error("operational health state could not be read");
4767
5131
  }
@@ -4797,7 +5161,7 @@ var OperationalHealthTracker = class {
4797
5161
  if (!this.#state) return;
4798
5162
  const body = JSON.stringify(this.#state, null, 2);
4799
5163
  this.#writeTail = this.#writeTail.then(async () => {
4800
- await ensureSecureDir(path19.dirname(this.#filePath));
5164
+ await ensureSecureDir(path20.dirname(this.#filePath));
4801
5165
  await atomicWriteFile(this.#filePath, body, { mode: 384, fsync: true });
4802
5166
  });
4803
5167
  try {
@@ -4909,19 +5273,19 @@ var RECLAIM_FILENAME = `${DAEMON_OWNER_FILENAME}.reclaim`;
4909
5273
  var MAX_OWNER_BYTES = 4096;
4910
5274
  var RECLAIM_MALFORMED_GRACE_MS = 3e4;
4911
5275
  var SELF_PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1e3).toISOString();
4912
- var STORE_MUTEX_PORT_BASE = 1e4;
4913
- var STORE_MUTEX_PORT_COUNT = 2e4;
4914
- var STORE_MUTEX_PORT_CANDIDATES = 32;
4915
5276
  var STORE_MUTEX_ID_PREFIX = "byok-store-mutex-v1:";
4916
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";
4917
5281
  function storeMutexIdentity(canonicalStoreDir) {
4918
5282
  return createHash("sha256").update(canonicalStoreDir).digest("hex");
4919
5283
  }
4920
- function storeMutexPort(identity, attempt) {
4921
- const digest = Buffer.from(identity, "hex");
4922
- const start = digest.readUInt32BE(0) % STORE_MUTEX_PORT_COUNT;
4923
- const step = 1 + digest.readUInt32BE(4) % (STORE_MUTEX_PORT_COUNT - 1);
4924
- 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");
4925
5289
  }
4926
5290
  var DaemonOwnerActiveError = class extends Error {
4927
5291
  constructor(role) {
@@ -5042,70 +5406,90 @@ async function createLivenessListener() {
5042
5406
  })
5043
5407
  };
5044
5408
  }
5045
- async function probeStoreMutex(port) {
5409
+ async function probeStoreMutex(endpoint, identity) {
5046
5410
  return new Promise((resolve) => {
5047
- const socket = createConnection({ host: "127.0.0.1", port });
5411
+ const socket = createConnection(endpoint);
5048
5412
  let settled = false;
5049
5413
  let raw = "";
5050
5414
  const finish = (result) => {
5051
5415
  if (settled) return;
5052
5416
  settled = true;
5417
+ clearTimeout(timer);
5418
+ socket.removeAllListeners();
5053
5419
  socket.destroy();
5054
5420
  resolve(result);
5055
5421
  };
5422
+ const timer = setTimeout(() => finish({ kind: "occupied" }), STORE_MUTEX_PROBE_TIMEOUT_MS);
5056
5423
  socket.setEncoding("utf8");
5057
- socket.setTimeout(STORE_MUTEX_PROBE_TIMEOUT_MS, () => finish({ kind: "uncertain" }));
5058
5424
  socket.on("data", (chunk) => {
5059
5425
  raw += chunk;
5060
- if (raw.length > STORE_MUTEX_ID_PREFIX.length + 64 + 1) finish({ kind: "foreign-or-gone" });
5061
- });
5062
- socket.once("end", () => {
5063
- const line = raw.trimEnd();
5064
- const identity = line.startsWith(STORE_MUTEX_ID_PREFIX) ? line.slice(STORE_MUTEX_ID_PREFIX.length) : void 0;
5065
- finish(
5066
- identity && /^[a-f0-9]{64}$/.test(identity) ? { kind: "identity", identity } : { kind: "foreign-or-gone" }
5067
- );
5426
+ if (raw.length > STORE_MUTEX_ID_PREFIX.length + 64 + 1) finish({ kind: "occupied" });
5068
5427
  });
5069
- 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
+ );
5070
5433
  });
5071
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
+ }
5072
5455
  async function acquireStoreMutex(canonicalStoreDir) {
5073
5456
  const identity = storeMutexIdentity(canonicalStoreDir);
5074
- for (let attempt = 0; attempt < STORE_MUTEX_PORT_CANDIDATES; attempt += 1) {
5075
- 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}
5076
5468
  `));
5077
- const port = storeMutexPort(identity, attempt);
5078
- try {
5079
- await new Promise((resolve, reject) => {
5080
- server.once("error", reject);
5081
- server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
5082
- server.removeListener("error", reject);
5083
- resolve();
5084
- });
5469
+ try {
5470
+ await new Promise((resolve, reject) => {
5471
+ server.once("error", reject);
5472
+ server.listen(endpoint, () => {
5473
+ server.removeListener("error", reject);
5474
+ resolve();
5085
5475
  });
5086
- } catch (err) {
5087
- if (err.code !== "EADDRINUSE") throw err;
5088
- const probe = await probeStoreMutex(port);
5089
- if (probe.kind === "uncertain" || probe.kind === "identity" && probe.identity === identity) {
5090
- throw new DaemonOwnerActiveError("unknown");
5091
- }
5092
- continue;
5093
- }
5094
- server.unref();
5095
- let closed = false;
5096
- return {
5097
- port,
5098
- close: () => new Promise((resolve, reject) => {
5099
- if (closed) {
5100
- resolve();
5101
- return;
5102
- }
5103
- closed = true;
5104
- server.close((err) => err ? reject(err) : resolve());
5105
- })
5106
- };
5476
+ });
5477
+ } catch (err) {
5478
+ if (err.code === "EADDRINUSE") throw new DaemonOwnerActiveError("unknown");
5479
+ throw err;
5107
5480
  }
5108
- 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
+ };
5109
5493
  }
5110
5494
  async function reclaimExistsAndIsActive(reclaimPath) {
5111
5495
  let stat;
@@ -5153,8 +5537,8 @@ async function acquireDaemonOwner(storeDir, role, clock = () => /* @__PURE__ */
5153
5537
  await mutex.close().catch(() => void 0);
5154
5538
  throw err;
5155
5539
  }
5156
- const ownerPath = path19.join(storeDir, DAEMON_OWNER_FILENAME);
5157
- const reclaimPath = path19.join(storeDir, RECLAIM_FILENAME);
5540
+ const ownerPath = path20.join(storeDir, DAEMON_OWNER_FILENAME);
5541
+ const reclaimPath = path20.join(storeDir, RECLAIM_FILENAME);
5158
5542
  const record = {
5159
5543
  version: 2,
5160
5544
  pid: process.pid,
@@ -5221,6 +5605,7 @@ function toRuntimeInfoCapabilities(caps) {
5221
5605
  steer: caps.steer,
5222
5606
  resume: caps.resume,
5223
5607
  approvalInteractive: caps.approvalInteractive,
5608
+ ...caps.mcpToolsets === void 0 ? {} : { mcpToolsets: caps.mcpToolsets },
5224
5609
  permissionModes: caps.permissionModes
5225
5610
  };
5226
5611
  }
@@ -5231,7 +5616,7 @@ var CursorStore = class {
5231
5616
  storeDir;
5232
5617
  fileFor(serverUrl, deviceId) {
5233
5618
  const key = createHash("sha256").update(`${serverUrl}::${deviceId}`).digest("hex").slice(0, 32);
5234
- return path19.join(this.storeDir, `cursor-${key}.json`);
5619
+ return path20.join(this.storeDir, `cursor-${key}.json`);
5235
5620
  }
5236
5621
  async load(serverUrl, deviceId) {
5237
5622
  let raw;
@@ -5251,7 +5636,7 @@ var CursorStore = class {
5251
5636
  }
5252
5637
  async save(serverUrl, deviceId, cursor) {
5253
5638
  const file = this.fileFor(serverUrl, deviceId);
5254
- await promises.mkdir(path19.dirname(file), { recursive: true, mode: 448 });
5639
+ await promises.mkdir(path20.dirname(file), { recursive: true, mode: 448 });
5255
5640
  await atomicWriteFile(file, JSON.stringify({ cursor }));
5256
5641
  }
5257
5642
  /** Remove any persisted cursor for (serverUrl, deviceId) — a no-op if none exists. Called from `pair()` (finding F5) so a device that's about to be replaced never leaves a cursor a future, unrelated device could somehow inherit. */
@@ -5294,7 +5679,7 @@ var DaemonObserver = class {
5294
5679
  }
5295
5680
  /**
5296
5681
  * Feed a raw INBOUND (server -> daemon) envelope. Deliberately narrow: only
5297
- * `task.offer` produces a local event here — every other inbound type
5682
+ * either offer variant produces a local event here — every other inbound type
5298
5683
  * (`task.cancel`/`task.steer`/`task.approve`/`task.reject`) is a
5299
5684
  * best-effort notification whose OWN observable effect already surfaces
5300
5685
  * through the daemon's outbound envelopes (`task.cancelled`, `task.progress`
@@ -5302,7 +5687,7 @@ var DaemonObserver = class {
5302
5687
  * where those are actually reported from.
5303
5688
  */
5304
5689
  handleInboundEnvelope(envelope) {
5305
- if (envelope.type !== "task.offer") return;
5690
+ if (envelope.type !== "task.offer" && envelope.type !== "task.offer_with_toolsets") return;
5306
5691
  const taskId = envelope.task_id;
5307
5692
  if (this.taskInfo.has(taskId)) return;
5308
5693
  this.upsertTask(taskId, { state: "Offered", runtime: envelope.payload.runtime });
@@ -5423,6 +5808,37 @@ var DaemonObserver = class {
5423
5808
  noteShutdownComplete(reason, undeliveredOutboxCount) {
5424
5809
  this.emit({ kind: "shutdown-complete", ts: nowIso(), reason, undeliveredOutboxCount });
5425
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
+ }
5426
5842
  /** M4 Phase 3 hardening: see the `stale-approval-decision` `DaemonEvent` variant's own doc comment. */
5427
5843
  noteStaleApprovalDecision(taskId, decision, reason) {
5428
5844
  this.emit({ kind: "stale-approval-decision", ts: nowIso(), taskId, decision, reason });
@@ -5547,7 +5963,7 @@ var SessionWorkspaceStore = class {
5547
5963
  */
5548
5964
  queue = Promise.resolve();
5549
5965
  constructor(storeDir) {
5550
- this.filePath = path19.join(storeDir, "session-workspaces.json");
5966
+ this.filePath = path20.join(storeDir, "session-workspaces.json");
5551
5967
  }
5552
5968
  async get(sessionRef) {
5553
5969
  return this.enqueue(async () => {
@@ -5605,7 +6021,7 @@ var SessionWorkspaceStore = class {
5605
6021
  }
5606
6022
  }
5607
6023
  async save(all) {
5608
- const dir = path19.dirname(this.filePath);
6024
+ const dir = path20.dirname(this.filePath);
5609
6025
  await promises.mkdir(dir, { recursive: true, mode: 448 });
5610
6026
  const tmpPath = `${this.filePath}.${process.pid}-${tmpSeq2++}.tmp`;
5611
6027
  try {
@@ -5710,9 +6126,9 @@ function isSqliteAvailable() {
5710
6126
  }
5711
6127
  }
5712
6128
  var SECURE_FILE_MODE = 384;
5713
- function openJournalDatabase(path24, busyTimeoutMs, faults) {
6129
+ function openJournalDatabase(path25, busyTimeoutMs, faults) {
5714
6130
  const { DatabaseSync } = loadSqliteModule();
5715
- const db = new DatabaseSync(path24, { timeout: busyTimeoutMs });
6131
+ const db = new DatabaseSync(path25, { timeout: busyTimeoutMs });
5716
6132
  try {
5717
6133
  faults?.onStep?.("after-open");
5718
6134
  db.exec("PRAGMA auto_vacuum = INCREMENTAL;");
@@ -5855,9 +6271,9 @@ var RECEIVED_STATE = "received";
5855
6271
  function byteLength(value) {
5856
6272
  return Buffer.byteLength(value, "utf8");
5857
6273
  }
5858
- function fileBytes(path24) {
6274
+ function fileBytes(path25) {
5859
6275
  try {
5860
- return statSync(path24).size;
6276
+ return statSync(path25).size;
5861
6277
  } catch {
5862
6278
  return 0;
5863
6279
  }
@@ -7061,6 +7477,21 @@ var MAX_INLINE_ARTIFACT_BYTES = 64 * 1024;
7061
7477
  var MAX_TRACKED_TASK_IDS = 2e3;
7062
7478
  var MAX_DURATION_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxDurationMs";
7063
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
+ }
7064
7495
  var DEFAULT_MAX_TASK_OUTPUT_BYTES = 64 * 1024 * 1024;
7065
7496
  function isKnownRuntimeId(id) {
7066
7497
  return RuntimeIdSchema.safeParse(id).success;
@@ -7073,6 +7504,14 @@ function orderByPreference(candidates, preference) {
7073
7504
  function adapterSupportsMode(adapter, mode) {
7074
7505
  return adapter.capabilities().permissionModes.includes(mode);
7075
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
+ }
7076
7515
  function errorMessage3(err) {
7077
7516
  return err instanceof Error ? err.message : String(err);
7078
7517
  }
@@ -7108,8 +7547,8 @@ function estimateEventBytes(event) {
7108
7547
  }
7109
7548
  async function openArtifact(workspaceDir, name) {
7110
7549
  const realWorkspaceDir = await promises.realpath(workspaceDir).catch(() => workspaceDir);
7111
- const candidate = path19.resolve(realWorkspaceDir, name);
7112
- const prefix = realWorkspaceDir.endsWith(path19.sep) ? realWorkspaceDir : realWorkspaceDir + path19.sep;
7550
+ const candidate = path20.resolve(realWorkspaceDir, name);
7551
+ const prefix = realWorkspaceDir.endsWith(path20.sep) ? realWorkspaceDir : realWorkspaceDir + path20.sep;
7113
7552
  if (candidate !== realWorkspaceDir && !candidate.startsWith(prefix)) {
7114
7553
  return { ok: false, reason: `artifact name "${name}" resolves outside the task workspace \u2014 rejected` };
7115
7554
  }
@@ -7428,6 +7867,9 @@ var TaskRunner = class {
7428
7867
  case "task.offer":
7429
7868
  await this.handleOffer(envelope.task_id, envelope.payload);
7430
7869
  return;
7870
+ case "task.offer_with_toolsets":
7871
+ await this.handleOffer(envelope.task_id, envelope.payload);
7872
+ return;
7431
7873
  case "task.cancel":
7432
7874
  await this.handleCancel(envelope.task_id, envelope.payload.reason);
7433
7875
  return;
@@ -7481,7 +7923,22 @@ var TaskRunner = class {
7481
7923
  );
7482
7924
  return;
7483
7925
  }
7484
- 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);
7485
7942
  if (!pick.ok) {
7486
7943
  this.decline(taskId, pick.reason, pick.retryable);
7487
7944
  return;
@@ -7507,7 +7964,7 @@ var TaskRunner = class {
7507
7964
  const sameProtocolTask = ledger?.taskId === taskId;
7508
7965
  const interruptedOldTask = ledger?.phase === "interrupted" && sameProtocolTask;
7509
7966
  const activeDifferentTask = ledger !== void 0 && ledger.taskId !== taskId && (ledger.phase === "preparing" || ledger.phase === "active");
7510
- if (!known || known.workspaceKind !== "git" || !known.gitWorkspaceId || !ledger || ledger.workspaceId !== known.gitWorkspaceId || ledger.sessionRef !== payload.sessionRef || path19.resolve(ledger.workspaceDir) !== path19.resolve(known.workspaceDir) || interruptedOldTask || activeDifferentTask) {
7967
+ if (!known || known.workspaceKind !== "git" || !known.gitWorkspaceId || !ledger || ledger.workspaceId !== known.gitWorkspaceId || ledger.sessionRef !== payload.sessionRef || path20.resolve(ledger.workspaceDir) !== path20.resolve(known.workspaceDir) || interruptedOldTask || activeDifferentTask) {
7511
7968
  this.decline(taskId, "session is incompatible with Git workspace mode", true);
7512
7969
  return;
7513
7970
  }
@@ -7522,7 +7979,7 @@ var TaskRunner = class {
7522
7979
  return;
7523
7980
  }
7524
7981
  } else {
7525
- workspaceDir = path19.join(this.deps.workspaceRoot, taskId);
7982
+ workspaceDir = path20.join(this.deps.workspaceRoot, taskId);
7526
7983
  }
7527
7984
  try {
7528
7985
  gitLease = await gitManager.acquireLease(workspaceDir, payload.sessionRef);
@@ -7532,7 +7989,7 @@ var TaskRunner = class {
7532
7989
  }
7533
7990
  } else if (!this.deps.gitWorkspaceManager && !this.deps.gitWorkspaceStore) {
7534
7991
  known = payload.sessionRef ? await this.deps.sessionWorkspaces.get(payload.sessionRef) : void 0;
7535
- workspaceDir = known?.workspaceDir ?? path19.join(this.deps.workspaceRoot, taskId);
7992
+ workspaceDir = known?.workspaceDir ?? path20.join(this.deps.workspaceRoot, taskId);
7536
7993
  plainWorkspaceNeedsResolve = true;
7537
7994
  } else {
7538
7995
  this.decline(taskId, "workspace mode is unavailable", true);
@@ -7644,6 +8101,7 @@ var TaskRunner = class {
7644
8101
  const ctx = {
7645
8102
  workspaceDir,
7646
8103
  policy: decision.policy,
8104
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {},
7647
8105
  ...gitWorkspaceId ? { gitWorkspace: { workspaceId: gitWorkspaceId, baseline: gitBaseline } } : {},
7648
8106
  // M5: no longer `process.env` verbatim (see `environment.ts`'s own
7649
8107
  // module doc comment for the credential-leak gap that closed) —
@@ -7680,7 +8138,7 @@ var TaskRunner = class {
7680
8138
  }
7681
8139
  };
7682
8140
  const effectiveOffer = {
7683
- ...payload,
8141
+ ...withoutRequiredToolsets(payload),
7684
8142
  instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
7685
8143
  // Never forward a sessionRef this device has no recorded workspace
7686
8144
  // for (stale, from another device, or simply made up) — an adapter
@@ -7758,6 +8216,36 @@ var TaskRunner = class {
7758
8216
  if (typeof instruction === "string") return instruction;
7759
8217
  return this.deps.blobClient.resolveInstruction(instruction.blobRef);
7760
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
+ }
7761
8249
  async pump(active) {
7762
8250
  try {
7763
8251
  for await (const event of active.session.events) {
@@ -7791,11 +8279,38 @@ var TaskRunner = class {
7791
8279
  if (event.type === "turn_end") {
7792
8280
  active.batcher.push(event);
7793
8281
  active.batcher.flush();
8282
+ const finalOutput = active.summaryParts.join("");
8283
+ const outcome = await this.resolveResultDocument(active, finalOutput);
8284
+ if (!outcome.deliver) return;
7794
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
+ }
7795
8294
  this.deps.send(
7796
8295
  createEnvelope(
7797
8296
  "task.complete",
7798
- { 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
+ },
7799
8314
  { taskId: active.taskId, sessionRef: active.session.sessionRef }
7800
8315
  )
7801
8316
  );
@@ -8333,6 +8848,102 @@ var TaskRunner = class {
8333
8848
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId }));
8334
8849
  await this.finish(taskId);
8335
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
+ }
8336
8947
  async observeGit(active, phase) {
8337
8948
  if (!active.gitWorkspaceId || !this.deps.gitWorkspaceManager || !this.deps.gitWorkspaceStore) return;
8338
8949
  try {
@@ -8404,7 +9015,7 @@ var TaskRunner = class {
8404
9015
  }
8405
9016
  /** `reuseDir`, when set (a known sessionRef's recorded workspace), is used verbatim instead of a fresh `workspaceRoot/<taskId>` directory — `mkdir recursive` is idempotent either way, so ensuring-exists is safe to do unconditionally. */
8406
9017
  async resolveWorkspaceDir(taskId, reuseDir) {
8407
- const dir = reuseDir ?? path19.join(this.deps.workspaceRoot, taskId);
9018
+ const dir = reuseDir ?? path20.join(this.deps.workspaceRoot, taskId);
8408
9019
  await promises.mkdir(dir, { recursive: true });
8409
9020
  return dir;
8410
9021
  }
@@ -8435,7 +9046,7 @@ var TaskRunner = class {
8435
9046
  * is device-specific (which runtimes happen to be installed here), so a
8436
9047
  * different device's installed runtime set might satisfy it.
8437
9048
  */
8438
- async pickAdapter(requestedRuntime, policyMode) {
9049
+ async pickAdapter(requestedRuntime, policyMode, requiresMcpToolsets) {
8439
9050
  const allowlist = this.deps.runtimeAllowlist;
8440
9051
  if (requestedRuntime) {
8441
9052
  if (allowlist && !allowlist.includes(requestedRuntime)) {
@@ -8456,6 +9067,13 @@ var TaskRunner = class {
8456
9067
  retryable: false
8457
9068
  };
8458
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
+ }
8459
9077
  const detected = await adapter.detect();
8460
9078
  if (!detected.present) {
8461
9079
  return {
@@ -8470,12 +9088,13 @@ var TaskRunner = class {
8470
9088
  const candidates = orderByPreference(eligible, this.deps.runtimePreference ?? DEFAULT_RUNTIME_PREFERENCE);
8471
9089
  for (const adapter of candidates) {
8472
9090
  if (!adapterSupportsMode(adapter, policyMode)) continue;
9091
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) continue;
8473
9092
  const detected = await adapter.detect();
8474
9093
  if (detected.present) return { ok: true, adapter };
8475
9094
  }
8476
9095
  return {
8477
9096
  ok: false,
8478
- 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}"`,
8479
9098
  retryable: true
8480
9099
  };
8481
9100
  }
@@ -8506,7 +9125,7 @@ function toJournalEnvelopeRecord(envelope, identity) {
8506
9125
  bytes,
8507
9126
  bytesHash: journalHash(bytes),
8508
9127
  receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
8509
- opensTask: envelope.type === "task.offer"
9128
+ opensTask: envelope.type === "task.offer" || envelope.type === "task.offer_with_toolsets"
8510
9129
  };
8511
9130
  }
8512
9131
  function isRuntimeId(id) {
@@ -8530,29 +9149,216 @@ function computeCapabilities(adapters) {
8530
9149
  if (adapters.some((adapter) => adapter.capabilities().steer)) flags.push("steer");
8531
9150
  flags.push("blob-upload");
8532
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
+ }
8533
9161
  return flags;
8534
9162
  }
8535
9163
  var ALL_RUNTIME_IDS = ["pi", "claude", "codex"];
8536
- function buildAdapter(id) {
9164
+ function buildAdapter(id, config) {
8537
9165
  switch (id) {
8538
9166
  case "pi":
8539
- return new PiAdapter();
9167
+ return new PiAdapter({ byokLauncher: config.piByokLauncher });
8540
9168
  case "claude":
8541
9169
  return new ClaudeAdapter();
8542
9170
  case "codex":
8543
9171
  return new CodexAdapter();
8544
9172
  }
8545
9173
  }
8546
- function buildDefaultAdapters(runtimeAllowlist) {
8547
- const ids = runtimeAllowlist ? ALL_RUNTIME_IDS.filter((id) => runtimeAllowlist.includes(id)) : ALL_RUNTIME_IDS;
8548
- 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));
9177
+ }
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)) {
9189
+ throw new Error(
9190
+ "DaemonConfig.piByokLauncher profileDbPath and sessionDir must be absolute paths"
9191
+ );
9192
+ }
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
+ );
9197
+ }
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;
8549
9339
  }
8550
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
+ }
8551
9348
  if (config.maxTaskOutputBytes !== void 0 && !(config.maxTaskOutputBytes > 0)) {
8552
9349
  throw new Error(
8553
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".`
8554
9351
  );
8555
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;
8556
9362
  const storeDir = DeviceStore.resolveDir(config.productId, config.storeDir);
8557
9363
  const store = new DeviceStore(storeDir);
8558
9364
  const operationalHealth = new OperationalHealthTracker(storeDir);
@@ -8643,6 +9449,9 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8643
9449
  let runner;
8644
9450
  let controlServerHandle;
8645
9451
  let daemonOwnerLease;
9452
+ let presencePublisher;
9453
+ let presenceDiscovery;
9454
+ let presenceDiscoveryInFlight = false;
8646
9455
  let shutdownPromise;
8647
9456
  const pendingLateMutationBarriers = /* @__PURE__ */ new Set();
8648
9457
  let startedAt;
@@ -8815,6 +9624,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8815
9624
  deviceId: record.deviceId,
8816
9625
  // M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment above.
8817
9626
  runtimeEnvironment: config.runtimeEnvironment,
9627
+ ...mcpToolsets ? { mcpToolsets } : {},
8818
9628
  // M3-2a: `send` is already this file's OWN closure (not something
8819
9629
  // `TaskRunner` builds) — every `task.claim`/`task.started`/
8820
9630
  // `task.progress`/`task.artifact`/`task.await_approval`/
@@ -8854,6 +9664,11 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8854
9664
  shutdownInterruptTimeoutMs: overrides.shutdown?.taskInterruptTimeoutMs,
8855
9665
  // M5 batch-3 (workstream 2): see DaemonConfig.maxTaskOutputBytes's own doc comment — already validated above.
8856
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 } : {},
8857
9672
  // M4 Phase 3 hardening: bridges TaskRunner's stale-approval-race
8858
9673
  // finding out to the SAME local observability seam every other
8859
9674
  // daemon-local event already uses (see observer.ts's own module doc
@@ -8929,8 +9744,10 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8929
9744
  return runner?.handleEnvelope(envelope) ?? Promise.resolve();
8930
9745
  },
8931
9746
  onStateChange: (state) => {
9747
+ const wasSettled = connectionState === "open" || connectionState === "degraded";
8932
9748
  connectionState = state;
8933
9749
  observer.noteConnectionState(state);
9750
+ if (!wasSettled && (state === "open" || state === "degraded")) runPresenceDiscovery();
8934
9751
  },
8935
9752
  backoff: overrides.backoff,
8936
9753
  liveness: overrides.liveness,
@@ -8948,6 +9765,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8948
9765
  });
8949
9766
  await connection.start();
8950
9767
  await connection.waitForAck();
9768
+ startPresenceProducer();
8951
9769
  } catch (err) {
8952
9770
  try {
8953
9771
  await runShutdownSequence("startup failed", { drainTimeoutMs: 0 });
@@ -8957,7 +9775,41 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8957
9775
  throw err;
8958
9776
  }
8959
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
+ }
8960
9811
  async function runShutdownSequence(reason, opts = {}) {
9812
+ shuttingDown = true;
8961
9813
  const errors = [];
8962
9814
  let mutationBarrierComplete = hostedStorageInitializationBarrierComplete;
8963
9815
  if (!hostedStorageInitializationBarrierComplete) {
@@ -8986,6 +9838,11 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8986
9838
  errors.push(new Error("a prior active task teardown remains unsettled; ownership lease retained"));
8987
9839
  }
8988
9840
  }
9841
+ presenceDiscovery?.abort();
9842
+ presenceDiscovery = void 0;
9843
+ presenceDiscoveryInFlight = false;
9844
+ presencePublisher?.stop();
9845
+ presencePublisher = void 0;
8989
9846
  const stoppingOwnedPressureEngine = ownedPressureEngine;
8990
9847
  const stoppingOwnedJournal = ownedJournal;
8991
9848
  const maintenanceStopped = stoppingOwnedPressureEngine?.stop() ?? Promise.resolve();
@@ -9046,6 +9903,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9046
9903
  }
9047
9904
  }
9048
9905
  async function stop(opts = {}) {
9906
+ shuttingDown = true;
9049
9907
  await requestShutdown(opts.reason ?? "operator", { drainTimeoutMs: opts.drainTimeoutMs });
9050
9908
  }
9051
9909
  function requestShutdown(reason, opts = {}) {
@@ -9058,6 +9916,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9058
9916
  return current;
9059
9917
  }
9060
9918
  async function unpair() {
9919
+ shuttingDown = true;
9061
9920
  await runLifecycleMutation(unpairUnderLease);
9062
9921
  }
9063
9922
  async function unpairUnderLease() {
@@ -9136,6 +9995,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9136
9995
  }
9137
9996
  async function performControlShutdown(reason) {
9138
9997
  const effectiveReason = reason ?? "operator";
9998
+ shuttingDown = true;
9139
9999
  observer.noteShutdownRequested(effectiveReason);
9140
10000
  try {
9141
10001
  await requestShutdown(`control socket shutdown (${effectiveReason})`);
@@ -9173,7 +10033,107 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9173
10033
  if (!runner) throw new ControlError("not_found", "daemon is not started");
9174
10034
  return runner.requestApproval(parsed.taskId, parsed.summary);
9175
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
+ },
9176
10135
  shutdown: (params) => {
10136
+ shuttingDown = true;
9177
10137
  const { reason } = parseShutdownParams(params);
9178
10138
  setImmediate(() => {
9179
10139
  void performControlShutdown(reason).catch((err) => {
@@ -9218,299 +10178,350 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9218
10178
  return { pair, start, stop, status, subscribe, tasks, unpair, approve, reject };
9219
10179
  }
9220
10180
  function createDaemon(config) {
9221
- return createDaemonWithAdapters(config, buildDefaultAdapters(config.runtimeAllowlist));
9222
- }
9223
-
9224
- // src/lifecycle/service-types.ts
9225
- function nodeAgentProgram(opts) {
9226
- const program = {
9227
- command: opts.nodeBin ?? process.execPath,
9228
- args: [opts.agentBin, "start", "--config", opts.configPath]
9229
- };
9230
- if (opts.cwd !== void 0) program.cwd = opts.cwd;
9231
- return program;
9232
- }
9233
- function sanitizeServiceName(name) {
9234
- const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, "-");
9235
- const safe = cleaned.replace(/^-+/, "");
9236
- if (!safe) {
9237
- throw new Error(`service name "${name}" has no valid characters left after sanitizing (allowed: letters, digits, ".", "-", "_"; cannot consist only of leading "-")`);
9238
- }
9239
- return safe;
9240
- }
9241
-
9242
- // src/lifecycle/launchd.ts
9243
- var LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9244
- /operation not permitted/i,
9245
- /could not find domain/i,
9246
- /permission denied/i,
9247
- /access denied/i
9248
- ];
9249
- var LAUNCHD_NOT_LOADED = {
9250
- patterns: [/no such process/i, /could not find (specified )?service/i, /not loaded/i],
9251
- neverAbsence: LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE
9252
- };
9253
- function escapeXml(value) {
9254
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
10181
+ return createDaemonWithAdapters(config, buildDefaultAdapters(config));
9255
10182
  }
9256
- function plistString(value) {
9257
- 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);
9258
10186
  }
9259
- function generateLaunchdPlist(def) {
9260
- const { label, program, logDir } = def;
9261
- const args = [program.command, ...program.args];
9262
- const cwd = program.cwd ?? os.homedir();
9263
- const outLog = path19.join(logDir, `${label}.out.log`);
9264
- const errLog = path19.join(logDir, `${label}.err.log`);
9265
- return `<?xml version="1.0" encoding="UTF-8"?>
9266
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
9267
- <plist version="1.0">
9268
- <dict>
9269
- <key>Label</key>
9270
- ${plistString(label)}
9271
- <key>ProgramArguments</key>
9272
- <array>
9273
- ${args.map((a) => ` ${plistString(a)}`).join("\n")}
9274
- </array>
9275
- <key>WorkingDirectory</key>
9276
- ${plistString(cwd)}
9277
- <key>RunAtLoad</key>
9278
- <true/>
9279
- <key>KeepAlive</key>
9280
- <dict>
9281
- <key>SuccessfulExit</key>
9282
- <false/>
9283
- </dict>
9284
- <key>ThrottleInterval</key>
9285
- <integer>10</integer>
9286
- <key>StandardOutPath</key>
9287
- ${plistString(outLog)}
9288
- <key>StandardErrorPath</key>
9289
- ${plistString(errLog)}
9290
- </dict>
9291
- </plist>
9292
- `;
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;
9293
10189
  }
9294
- function createLaunchdLifecycle(def, deps = {}) {
9295
- const run = deps.run ?? defaultRunner;
9296
- const fs19 = deps.fs ?? promises;
9297
- const homedir = deps.homedir ?? (() => os.homedir());
9298
- const getuid = deps.getuid ?? (() => {
9299
- if (typeof process.getuid !== "function") {
9300
- throw new Error("launchd lifecycle requires a POSIX uid (process.getuid unavailable) \u2014 this module only runs on macOS");
9301
- }
9302
- return process.getuid();
9303
- });
9304
- const label = sanitizeServiceName(def.name);
9305
- const plistPath = () => path19.join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
9306
- const domainTarget = () => `gui/${getuid()}`;
9307
- const serviceTarget = () => `${domainTarget()}/${label}`;
9308
- async function fileExists(p) {
9309
- try {
9310
- await fs19.stat(p);
9311
- return true;
9312
- } catch {
9313
- return false;
9314
- }
9315
- }
9316
- async function writePlist(program) {
9317
- const xml = generateLaunchdPlist({ label, program, logDir: def.logDir });
9318
- await fs19.mkdir(path19.dirname(plistPath()), { recursive: true });
9319
- await fs19.mkdir(def.logDir, { recursive: true });
9320
- await fs19.writeFile(plistPath(), xml, "utf8");
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;
9321
10197
  }
9322
- async function install(opts = {}) {
9323
- await writePlist(opts.program ?? def.program);
9324
- await run("launchctl", ["bootout", serviceTarget()]);
9325
- await runOrThrow(run, "launchctl", ["bootstrap", domainTarget(), plistPath()], "launchctl bootstrap");
9326
- await runOrThrow(run, "launchctl", ["enable", serviceTarget()], "launchctl enable");
9327
- await run("launchctl", ["kickstart", "-k", serviceTarget()]);
10198
+ if (!namedBefore.isFile() || namedBefore.isSymbolicLink()) {
10199
+ throw new Error("control token is not a real regular file");
9328
10200
  }
9329
- async function uninstall() {
9330
- await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
9331
- await fs19.rm(plistPath(), { force: true });
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");
10210
+ }
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();
9332
10225
  }
9333
- async function start() {
9334
- if (!await fileExists(plistPath())) {
9335
- throw new Error(`service "${label}" is not installed (no plist at ${plistPath()}) \u2014 call install() first`);
10226
+ }
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)" };
9336
10234
  }
9337
- await run("launchctl", ["bootstrap", domainTarget(), plistPath()]);
9338
- await runOrThrow(run, "launchctl", ["kickstart", "-k", serviceTarget()], "launchctl kickstart");
10235
+ token = read;
10236
+ } catch (err) {
10237
+ return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
9339
10238
  }
9340
- async function stop() {
9341
- await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
10239
+ if (!token) {
10240
+ return { ok: false, reason: "control token file is empty" };
9342
10241
  }
9343
- async function status() {
9344
- const installed = await fileExists(plistPath());
9345
- const result = await run("launchctl", ["print", serviceTarget()]);
9346
- const detail = result.stdout || result.stderr;
9347
- const running = result.code === 0 && /\bstate\s*=\s*running\b/i.test(detail);
9348
- const determinate = running || !LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
9349
- return { installed, running, determinate, detail };
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)}` };
9350
10248
  }
9351
- return { install, uninstall, start, stop, status };
9352
10249
  }
9353
- var SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9354
- /failed to connect to.*bus/i,
9355
- /connection refused/i,
9356
- /access denied/i,
9357
- /permission denied/i,
9358
- /interactive authentication required/i
9359
- ];
9360
- var SYSTEMD_NOT_LOADED = {
9361
- patterns: [/not loaded/i, /does not exist/i],
9362
- neverAbsence: SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE
9363
- };
9364
- function hasControlChar(value) {
9365
- for (let i = 0; i < value.length; i += 1) {
9366
- const code = value.charCodeAt(i);
9367
- if (code < 32 || code === 127) return true;
9368
- }
9369
- return false;
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
+ });
9370
10323
  }
9371
- function assertNoControlChars(value, field) {
9372
- if (hasControlChar(value)) {
9373
- throw new Error(
9374
- `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)})`
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
+ }
9375
10337
  );
9376
- }
9377
- }
9378
- function escapeSystemdPercent(value) {
9379
- return value.replace(/%/g, "%%");
9380
- }
9381
- function quoteSystemdArg(value) {
9382
- assertNoControlChars(value, "program.command/program.args entry");
9383
- const escaped = escapeSystemdPercent(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$");
9384
- return `"${escaped}"`;
9385
- }
9386
- function generateSystemdUnit(def) {
9387
- const { name, displayName, program, logDir } = def;
9388
- assertNoControlChars(name, "name");
9389
- assertNoControlChars(displayName, "displayName");
9390
- const cwd = program.cwd ?? os.homedir();
9391
- assertNoControlChars(cwd, "program.cwd");
9392
- const outLog = path19.join(logDir, `${name}.out.log`);
9393
- const errLog = path19.join(logDir, `${name}.err.log`);
9394
- assertNoControlChars(outLog, "logDir");
9395
- assertNoControlChars(errLog, "logDir");
9396
- const execStart = [program.command, ...program.args].map(quoteSystemdArg).join(" ");
9397
- return `[Unit]
9398
- Description=${escapeSystemdPercent(displayName)}
9399
-
9400
- [Service]
9401
- Type=simple
9402
- ExecStart=${execStart}
9403
- WorkingDirectory=${escapeSystemdPercent(cwd)}
9404
- Restart=on-failure
9405
- RestartSec=10
9406
- StandardOutput=append:${escapeSystemdPercent(outLog)}
9407
- StandardError=append:${escapeSystemdPercent(errLog)}
9408
-
9409
- [Install]
9410
- WantedBy=default.target
9411
- `;
10338
+ });
9412
10339
  }
9413
- function createSystemdLifecycle(def, deps = {}) {
9414
- const run = deps.run ?? defaultRunner;
9415
- const fs19 = deps.fs ?? promises;
9416
- const homedir = deps.homedir ?? (() => os.homedir());
9417
- const name = sanitizeServiceName(def.name);
9418
- const unitName = `${name}.service`;
9419
- const unitPath = () => path19.join(homedir(), ".config", "systemd", "user", unitName);
9420
- 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;
9421
10368
  try {
9422
- await fs19.stat(p);
9423
- return true;
10369
+ lines = reader.push(chunk);
9424
10370
  } catch {
9425
- return false;
10371
+ socket.destroy();
10372
+ return;
9426
10373
  }
9427
- }
9428
- async function writeUnit(program) {
9429
- const unit = generateSystemdUnit({ name, displayName: def.displayName ?? def.name, program, logDir: def.logDir });
9430
- await fs19.mkdir(path19.dirname(unitPath()), { recursive: true });
9431
- await fs19.mkdir(def.logDir, { recursive: true });
9432
- await fs19.writeFile(unitPath(), unit, "utf8");
9433
- }
9434
- async function install(opts = {}) {
9435
- await writeUnit(opts.program ?? def.program);
9436
- await runOrThrow(run, "systemctl", ["--user", "daemon-reload"], "systemctl daemon-reload");
9437
- await runOrThrow(run, "systemctl", ["--user", "enable", "--now", unitName], "systemctl enable --now");
9438
- }
9439
- async function uninstall() {
9440
- await runIdempotent(run, "systemctl", ["--user", "disable", "--now", unitName], "systemctl disable --now", SYSTEMD_NOT_LOADED);
9441
- await fs19.rm(unitPath(), { force: true });
9442
- await run("systemctl", ["--user", "daemon-reload"]);
9443
- }
9444
- async function start() {
9445
- if (!await fileExists(unitPath())) {
9446
- 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);
9447
10382
  }
9448
- await runOrThrow(run, "systemctl", ["--user", "start", unitName], "systemctl start");
9449
- }
9450
- async function stop() {
9451
- 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 };
9452
10398
  }
9453
- async function status() {
9454
- const installed = await fileExists(unitPath());
9455
- const result = await run("systemctl", ["--user", "is-active", unitName]);
9456
- const detail = (result.stdout || result.stderr).trim();
9457
- const running = result.code === 0 && detail === "active";
9458
- const determinate = running || !SYSTEMD_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
9459
- 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 "-")`);
9460
10455
  }
9461
- return { install, uninstall, start, stop, status };
10456
+ return safe;
9462
10457
  }
9463
- var WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9464
- /access is denied/i,
9465
- /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,
9466
10463
  /permission denied/i,
9467
- /being used by another process/i
10464
+ /access denied/i
9468
10465
  ];
9469
- var WINSW_NOT_INSTALLED = {
9470
- codes: [1060],
9471
- patterns: [/does not exist/i, /non-existent service/i],
9472
- neverAbsence: WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE
9473
- };
9474
- var WINSW_ALREADY_STOPPED = {
9475
- codes: [1062, ...WINSW_NOT_INSTALLED.codes ?? []],
9476
- patterns: [/not running/i, /has not been started/i, ...WINSW_NOT_INSTALLED.patterns],
9477
- 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
9478
10469
  };
9479
- function xmlEscape(value) {
10470
+ function escapeXml(value) {
9480
10471
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
9481
10472
  }
9482
- function generateWinswXml(def) {
9483
- const { id, displayName, program, logDir } = def;
9484
- const argXml = program.args.map((a) => ` <argument>${xmlEscape(a)}</argument>`).join("\n");
9485
- const cwdXml = program.cwd ? `
9486
- <workingdirectory>${xmlEscape(program.cwd)}</workingdirectory>` : "";
9487
- return `<service>
9488
- <id>${xmlEscape(id)}</id>
9489
- <name>${xmlEscape(displayName)}</name>
9490
- <description>${xmlEscape(displayName)} (managed by byok-agent; see templates/service/winsw/README.md)</description>
9491
- <executable>${xmlEscape(program.command)}</executable>
9492
- ${argXml}${cwdXml}
9493
- <logpath>${xmlEscape(logDir)}</logpath>
9494
- <log mode="roll"></log>
9495
- <startmode>Automatic</startmode>
9496
- <onfailure action="restart" delay="10 sec"/>
9497
- <onfailure action="restart" delay="30 sec"/>
9498
- <resetfailure>1 hour</resetfailure>
9499
- </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>
9500
10509
  `;
9501
10510
  }
9502
- function createWinswLifecycle(def, deps = {}) {
10511
+ function createLaunchdLifecycle(def, deps = {}) {
9503
10512
  const run = deps.run ?? defaultRunner;
9504
10513
  const fs19 = deps.fs ?? promises;
9505
- const windows = def.windows;
9506
- if (!windows) {
9507
- throw new Error("WinSW service lifecycle requires `ServiceDefinition.windows.winswBin` (the product-bundled WinSW executable path)");
9508
- }
9509
- const winswBin = windows.winswBin;
9510
- const id = sanitizeServiceName(def.name);
9511
- const installDir = windows.installDir ?? def.logDir;
9512
- const exePath = path19.join(installDir, `${id}.exe`);
9513
- const xmlPath = path19.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}`;
9514
10525
  async function fileExists(p) {
9515
10526
  try {
9516
10527
  await fs19.stat(p);
@@ -9519,381 +10530,330 @@ function createWinswLifecycle(def, deps = {}) {
9519
10530
  return false;
9520
10531
  }
9521
10532
  }
9522
- async function writeFiles(program) {
9523
- const xml = generateWinswXml({ id, displayName: def.displayName ?? def.name, program, logDir: def.logDir });
9524
- 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 });
9525
10536
  await fs19.mkdir(def.logDir, { recursive: true });
9526
- await fs19.copyFile(winswBin, exePath);
9527
- await fs19.writeFile(xmlPath, xml, "utf8");
10537
+ await fs19.writeFile(plistPath(), xml, "utf8");
9528
10538
  }
9529
10539
  async function install(opts = {}) {
9530
- await writeFiles(opts.program ?? def.program);
9531
- await runOrThrow(run, exePath, ["install"], "winsw install");
9532
- 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()]);
9533
10545
  }
9534
10546
  async function uninstall() {
9535
- await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_NOT_INSTALLED);
9536
- await runIdempotent(run, exePath, ["uninstall"], "winsw uninstall", WINSW_NOT_INSTALLED);
9537
- await fs19.rm(exePath, { force: true });
9538
- 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 });
9539
10549
  }
9540
10550
  async function start() {
9541
- if (!await fileExists(xmlPath)) {
9542
- 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`);
9543
10553
  }
9544
- await runOrThrow(run, exePath, ["start"], "winsw start");
10554
+ await run("launchctl", ["bootstrap", domainTarget(), plistPath()]);
10555
+ await runOrThrow(run, "launchctl", ["kickstart", "-k", serviceTarget()], "launchctl kickstart");
9545
10556
  }
9546
10557
  async function stop() {
9547
- await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_ALREADY_STOPPED);
10558
+ await runIdempotent(run, "launchctl", ["bootout", serviceTarget()], "launchctl bootout", LAUNCHD_NOT_LOADED);
9548
10559
  }
9549
10560
  async function status() {
9550
- const installed = await fileExists(xmlPath);
9551
- const result = await run("sc.exe", ["query", id]);
9552
- const detail = (result.stdout || result.stderr).trim();
9553
- const running = result.code === 0 && /\bSTATE\b.*\bRUNNING\b/i.test(detail);
9554
- 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));
9555
10566
  return { installed, running, determinate, detail };
9556
10567
  }
9557
10568
  return { install, uninstall, start, stop, status };
9558
10569
  }
9559
-
9560
- // src/lifecycle/create-service-lifecycle.ts
9561
- var UnsupportedServicePlatformError = class extends Error {
9562
- constructor(platform) {
9563
- super(`no OS service lifecycle for platform "${platform}" \u2014 supported: darwin (launchd), linux (systemd --user), win32 (WinSW)`);
9564
- this.name = "UnsupportedServicePlatformError";
9565
- }
9566
- };
9567
- function createServiceLifecycle(def, opts = {}) {
9568
- const platform = opts.platform ?? process.platform;
9569
- switch (platform) {
9570
- case "darwin":
9571
- return createLaunchdLifecycle(def, opts.deps);
9572
- case "linux":
9573
- return createSystemdLifecycle(def, opts.deps);
9574
- case "win32":
9575
- return createWinswLifecycle(def, opts.deps);
9576
- default:
9577
- throw new UnsupportedServicePlatformError(platform);
9578
- }
9579
- }
9580
- var REQUIRED_FIELDS = ["productName", "productId", "serverUrl", "workspaceRoot"];
9581
- var ConfigError = class extends Error {
9582
- constructor(message) {
9583
- super(message);
9584
- this.name = "ConfigError";
9585
- }
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
9586
10580
  };
9587
- function loadConfig(configPath, overrides = {}) {
9588
- let base = {};
9589
- if (configPath) {
9590
- let raw;
9591
- try {
9592
- raw = readFileSync(configPath, "utf8");
9593
- } catch (err) {
9594
- throw new ConfigError(`could not read config at "${configPath}": ${err instanceof Error ? err.message : String(err)}`);
9595
- }
9596
- try {
9597
- base = JSON.parse(raw);
9598
- } catch (err) {
9599
- throw new ConfigError(`config at "${configPath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
9600
- }
9601
- }
9602
- const merged = { ...base, ...overrides };
9603
- if (merged.gitWorkspace !== void 0) {
9604
- try {
9605
- GitWorkspaceManager.validateConfig(merged.gitWorkspace);
9606
- } catch (error) {
9607
- throw new ConfigError(error instanceof Error ? error.message : "invalid gitWorkspace configuration");
9608
- }
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;
9609
10585
  }
9610
- for (const field of REQUIRED_FIELDS) {
9611
- if (!merged[field]) {
9612
- throw new ConfigError(`config is missing required field "${field}"`);
9613
- }
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
+ );
9614
10593
  }
9615
- return merged;
9616
10594
  }
9617
- function resolveStoreDir(config) {
9618
- return DeviceStore.resolveDir(config.productId, config.storeDir);
10595
+ function escapeSystemdPercent(value) {
10596
+ return value.replace(/%/g, "%%");
9619
10597
  }
9620
- function argValue(args, flag) {
9621
- const idx = args.indexOf(flag);
9622
- const value = idx >= 0 ? args[idx + 1] : void 0;
9623
- 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}"`;
9624
10602
  }
9625
- function hasFlag(args, flag) {
9626
- 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
+ `;
9627
10629
  }
9628
- function positionalArgs(args, valueFlags = []) {
9629
- const result = [];
9630
- for (let i = 0; i < args.length; i++) {
9631
- const arg = args[i];
9632
- if (arg === void 0) continue;
9633
- if (valueFlags.includes(arg)) {
9634
- i++;
9635
- 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;
9636
10643
  }
9637
- result.push(arg);
9638
10644
  }
9639
- return result;
9640
- }
9641
- var MAX_CONTROL_TOKEN_BYTES = 256;
9642
- function errorMessage4(err) {
9643
- return err instanceof Error ? err.message : String(err);
9644
- }
9645
- function sameFileState3(left, right) {
9646
- return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
9647
- }
9648
- async function readControlToken(tokenPath) {
9649
- let namedBefore;
9650
- try {
9651
- namedBefore = await promises.lstat(tokenPath, { bigint: true });
9652
- } catch (err) {
9653
- if (err.code === "ENOENT") return void 0;
9654
- 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");
9655
10650
  }
9656
- if (!namedBefore.isFile() || namedBefore.isSymbolicLink()) {
9657
- 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");
9658
10655
  }
9659
- const handle = await promises.open(
9660
- tokenPath,
9661
- constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)
9662
- );
9663
- try {
9664
- const opened = await handle.stat({ bigint: true });
9665
- const namedAfterOpen = await promises.lstat(tokenPath, { bigint: true });
9666
- if (!opened.isFile() || !namedAfterOpen.isFile() || namedAfterOpen.isSymbolicLink() || !sameFileState3(namedBefore, opened) || !sameFileState3(opened, namedAfterOpen)) {
9667
- throw new Error("control token pathname changed before safe open");
9668
- }
9669
- if (opened.size < 0 || opened.size > BigInt(MAX_CONTROL_TOKEN_BYTES)) {
9670
- throw new Error("control token exceeds the bounded read limit");
9671
- }
9672
- const size = Number(opened.size);
9673
- const bytes = Buffer.alloc(size);
9674
- const { bytesRead } = await handle.read(bytes, 0, size, 0);
9675
- const afterRead = await handle.stat({ bigint: true });
9676
- const namedAfterRead = await promises.lstat(tokenPath, { bigint: true });
9677
- if (bytesRead !== size || namedAfterRead.isSymbolicLink() || !sameFileState3(opened, afterRead) || !sameFileState3(afterRead, namedAfterRead)) {
9678
- throw new Error("control token changed during bounded read");
9679
- }
9680
- return bytes.toString("utf8").trim();
9681
- } finally {
9682
- 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"]);
9683
10660
  }
9684
- }
9685
- async function connectControlClient(opts) {
9686
- const tokenPath = controlTokenPath(opts.storeDir);
9687
- let token;
9688
- try {
9689
- const read = await readControlToken(tokenPath);
9690
- if (read === void 0) {
9691
- 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`);
9692
10664
  }
9693
- token = read;
9694
- } catch (err) {
9695
- return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
10665
+ await runOrThrow(run, "systemctl", ["--user", "start", unitName], "systemctl start");
9696
10666
  }
9697
- if (!token) {
9698
- 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);
9699
10669
  }
9700
- const endpoint = controlEndpointPath(opts.productId, opts.storeDir);
9701
- try {
9702
- const client = await connectAndHandshake(endpoint, token, opts);
9703
- return { ok: true, client };
9704
- } catch (err) {
9705
- 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 };
9706
10677
  }
10678
+ return { install, uninstall, start, stop, status };
9707
10679
  }
9708
- function connectAndHandshake(endpoint, token, opts) {
9709
- return new Promise((resolve, reject) => {
9710
- const socket = net.createConnection(endpoint);
9711
- const reader = new NdjsonLineReader();
9712
- let phase = "server-hello";
9713
- let settled = false;
9714
- const clientNonce = randomNonceHex();
9715
- const timer = setTimeout(() => {
9716
- fail(new Error("handshake timed out"));
9717
- }, opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
9718
- timer.unref?.();
9719
- function fail(err) {
9720
- if (settled) return;
9721
- settled = true;
9722
- clearTimeout(timer);
9723
- socket.removeAllListeners();
9724
- socket.destroy();
9725
- reject(err instanceof Error ? err : new Error(String(err)));
9726
- }
9727
- function succeed() {
9728
- settled = true;
9729
- clearTimeout(timer);
9730
- socket.removeListener("error", onError);
9731
- socket.removeListener("data", onData);
9732
- resolve(createControlClient(socket, reader, opts));
9733
- }
9734
- function onData(chunk) {
9735
- let lines;
9736
- try {
9737
- lines = reader.push(chunk);
9738
- } catch (err) {
9739
- fail(err);
9740
- return;
9741
- }
9742
- for (const line of lines) {
9743
- let parsed;
9744
- try {
9745
- parsed = JSON.parse(line);
9746
- } catch {
9747
- fail(new Error("malformed handshake frame"));
9748
- return;
9749
- }
9750
- if (phase === "server-hello") {
9751
- const hello = parseServerHello(parsed);
9752
- if (!hello) {
9753
- fail(new Error("malformed or unexpected server hello"));
9754
- return;
9755
- }
9756
- if (!timingSafeEqualHex(hello.proof, computeServerProof(token, clientNonce))) {
9757
- fail(new Error("server failed to prove it holds the control token"));
9758
- return;
9759
- }
9760
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, auth: computeClientAuth(token, hello.nonce) }));
9761
- phase = "ready";
9762
- continue;
9763
- }
9764
- if (!parseServerReady(parsed)) {
9765
- fail(new Error("server did not confirm readiness"));
9766
- return;
9767
- }
9768
- succeed();
9769
- return;
9770
- }
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;
9771
10737
  }
9772
- function onError(err) {
9773
- 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`);
9774
10760
  }
9775
- socket.once("error", onError);
9776
- socket.once("connect", () => {
9777
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, hello: "client", nonce: clientNonce }));
9778
- socket.on("data", onData);
9779
- });
9780
- });
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 };
9781
10775
  }
9782
- function withTimeout(promise, ms, message) {
9783
- return new Promise((resolve, reject) => {
9784
- const timer = setTimeout(() => reject(new Error(message)), ms);
9785
- timer.unref?.();
9786
- promise.then(
9787
- (value) => {
9788
- clearTimeout(timer);
9789
- resolve(value);
9790
- },
9791
- (err) => {
9792
- clearTimeout(timer);
9793
- reject(err);
9794
- }
9795
- );
9796
- });
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
+ }
9797
10796
  }
9798
- function createControlClient(socket, reader, opts) {
9799
- const pending = /* @__PURE__ */ new Map();
9800
- let idSeq = 0;
9801
- let closed = false;
9802
- function handleFrame(parsed) {
9803
- if (!isRecord2(parsed) || typeof parsed.id !== "string") return;
9804
- const entry = pending.get(parsed.id);
9805
- if (!entry) return;
9806
- if ("event" in parsed) {
9807
- entry.onEvent?.(parsed.event);
9808
- 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)}`);
9809
10812
  }
9810
- if (parsed.ok === true) {
9811
- pending.delete(parsed.id);
9812
- entry.resolve(parsed.done === true ? void 0 : parsed.result);
9813
- 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)}`);
9814
10817
  }
9815
- pending.delete(parsed.id);
9816
- const shape = parsed.error;
9817
- entry.reject(
9818
- new ControlError(
9819
- typeof shape?.code === "string" ? shape.code : "internal_error",
9820
- typeof shape?.message === "string" ? shape.message : "unknown control error"
9821
- )
9822
- );
9823
10818
  }
9824
- socket.on("data", (chunk) => {
9825
- let lines;
10819
+ const merged = { ...base, ...overrides };
10820
+ if (merged.gitWorkspace !== void 0) {
9826
10821
  try {
9827
- lines = reader.push(chunk);
9828
- } catch {
9829
- socket.destroy();
9830
- return;
9831
- }
9832
- for (const line of lines) {
9833
- let parsed;
9834
- try {
9835
- parsed = JSON.parse(line);
9836
- } catch {
9837
- continue;
9838
- }
9839
- handleFrame(parsed);
10822
+ GitWorkspaceManager.validateConfig(merged.gitWorkspace);
10823
+ } catch (error) {
10824
+ throw new ConfigError(error instanceof Error ? error.message : "invalid gitWorkspace configuration");
9840
10825
  }
9841
- });
9842
- socket.on("close", () => {
9843
- closed = true;
9844
- for (const entry of pending.values()) entry.reject(new Error("control connection closed"));
9845
- pending.clear();
9846
- });
9847
- socket.on("error", () => {
9848
- });
9849
- function send(method, params, onEvent) {
9850
- const id = `c${++idSeq}`;
9851
- const promise = new Promise((resolve, reject) => {
9852
- pending.set(id, { resolve, reject, onEvent });
9853
- });
9854
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, method, params }));
9855
- return { id, promise };
9856
10826
  }
9857
- return {
9858
- async request(method, params) {
9859
- if (closed) throw new Error("control connection is closed");
9860
- const { promise } = send(method, params);
9861
- const result = await withTimeout(promise, opts.requestTimeoutMs ?? 1e4, `control request "${method}" timed out`);
9862
- return result;
9863
- },
9864
- subscribe(method, params, onEvent) {
9865
- const { id, promise } = send(method, params, onEvent);
9866
- promise.catch(() => {
9867
- });
9868
- return {
9869
- close: () => {
9870
- pending.delete(id);
9871
- socket.destroy();
9872
- }
9873
- };
9874
- },
9875
- close() {
9876
- socket.destroy();
10827
+ for (const field of REQUIRED_FIELDS) {
10828
+ if (!merged[field]) {
10829
+ throw new ConfigError(`config is missing required field "${field}"`);
9877
10830
  }
9878
- };
10831
+ }
10832
+ return merged;
9879
10833
  }
9880
- async function isControlDaemonGone(storeDir, productId) {
9881
- const tokenGone = await promises.stat(controlTokenPath(storeDir)).then(
9882
- () => false,
9883
- (err) => err.code === "ENOENT"
9884
- );
9885
- if (!tokenGone) return false;
9886
- const endpoint = controlEndpointPath(productId, storeDir);
9887
- return new Promise((resolve) => {
9888
- const socket = net.createConnection(endpoint);
9889
- const finish = (gone) => {
9890
- socket.removeAllListeners();
9891
- socket.destroy();
9892
- resolve(gone);
9893
- };
9894
- socket.once("connect", () => finish(false));
9895
- socket.once("error", (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
9896
- });
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;
9897
10857
  }
9898
10858
 
9899
10859
  // src/bin/format.ts
@@ -9993,6 +10953,19 @@ function formatDaemonEventLine(event, options = {}) {
9993
10953
  ].filter((part) => part !== void 0);
9994
10954
  return parts.join(" ");
9995
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
+ }
9996
10969
  }
9997
10970
  }
9998
10971
  function formatTaskLine(task) {
@@ -10284,7 +11257,7 @@ function safeProtocol(serverUrl) {
10284
11257
  }
10285
11258
  }
10286
11259
  async function inspectDevice(storeDir) {
10287
- const filePath = path19.join(storeDir, "device.json");
11260
+ const filePath = path20.join(storeDir, "device.json");
10288
11261
  let pathStat;
10289
11262
  try {
10290
11263
  pathStat = await promises.lstat(filePath);
@@ -10364,11 +11337,11 @@ async function copyOpenFileBounded(source, expected, destinationPath) {
10364
11337
  }
10365
11338
  }
10366
11339
  async function inspectJournal(storeDir) {
10367
- const journalPath = path19.join(storeDir, JOURNAL_DB_FILENAME);
11340
+ const journalPath = path20.join(storeDir, JOURNAL_DB_FILENAME);
10368
11341
  try {
10369
11342
  const mainIdentity = await regularFileIdentity(journalPath);
10370
11343
  if (mainIdentity === void 0) return { status: "missing" };
10371
- const walIdentity = await regularFileIdentity(path19.join(storeDir, `${JOURNAL_DB_FILENAME}-wal`));
11344
+ const walIdentity = await regularFileIdentity(path20.join(storeDir, `${JOURNAL_DB_FILENAME}-wal`));
10372
11345
  let sizeBytes = Number(mainIdentity.size);
10373
11346
  let walBytes = walIdentity === void 0 ? void 0 : Number(walIdentity.size);
10374
11347
  if (!isSqliteAvailable()) {
@@ -10376,7 +11349,7 @@ async function inspectJournal(storeDir) {
10376
11349
  }
10377
11350
  const componentNames = [JOURNAL_DB_FILENAME, `${JOURNAL_DB_FILENAME}-wal`, `${JOURNAL_DB_FILENAME}-shm`];
10378
11351
  const initial = /* @__PURE__ */ new Map();
10379
- for (const name of componentNames) initial.set(name, await regularFileIdentity(path19.join(storeDir, name)));
11352
+ for (const name of componentNames) initial.set(name, await regularFileIdentity(path20.join(storeDir, name)));
10380
11353
  const snapshotMain = initial.get(JOURNAL_DB_FILENAME);
10381
11354
  if (!snapshotMain) return { status: "unavailable", reason: "journal changed during diagnostics snapshot" };
10382
11355
  sizeBytes = Number(snapshotMain.size);
@@ -10388,7 +11361,7 @@ async function inspectJournal(storeDir) {
10388
11361
  let handle;
10389
11362
  try {
10390
11363
  handle = await promises.open(
10391
- path19.join(storeDir, name),
11364
+ path20.join(storeDir, name),
10392
11365
  constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)
10393
11366
  );
10394
11367
  } catch (err) {
@@ -10417,28 +11390,28 @@ async function inspectJournal(storeDir) {
10417
11390
  reason: "journal exceeds the bounded diagnostics copy limit"
10418
11391
  };
10419
11392
  }
10420
- const tempDir = await promises.mkdtemp(path19.join(os.tmpdir(), "byok-journal-inspect-"));
11393
+ const tempDir = await promises.mkdtemp(path20.join(os.tmpdir(), "byok-journal-inspect-"));
10421
11394
  const { DatabaseSync } = loadSqliteModule();
10422
11395
  let db;
10423
11396
  try {
10424
11397
  for (const name of componentNames) {
10425
11398
  const component = opened.get(name);
10426
- if (component && !await copyOpenFileBounded(component.handle, component.identity, path19.join(tempDir, name))) {
11399
+ if (component && !await copyOpenFileBounded(component.handle, component.identity, path20.join(tempDir, name))) {
10427
11400
  return { status: "unavailable", sizeBytes, ...walBytes === void 0 ? {} : { walBytes }, reason: "journal changed during diagnostics snapshot" };
10428
11401
  }
10429
11402
  }
10430
11403
  for (const [name, component] of opened) {
10431
- if (!sameIdentity(component.identity, identityFromBigIntStat(await component.handle.stat({ bigint: true }))) || !sameIdentity(component.identity, await regularFileIdentity(path19.join(storeDir, name)))) {
11404
+ if (!sameIdentity(component.identity, identityFromBigIntStat(await component.handle.stat({ bigint: true }))) || !sameIdentity(component.identity, await regularFileIdentity(path20.join(storeDir, name)))) {
10432
11405
  return { status: "unavailable", sizeBytes, ...walBytes === void 0 ? {} : { walBytes }, reason: "journal changed during diagnostics snapshot" };
10433
11406
  }
10434
11407
  }
10435
11408
  for (const name of componentNames) {
10436
- if (!opened.has(name) && await regularFileIdentity(path19.join(storeDir, name)) !== void 0) {
11409
+ if (!opened.has(name) && await regularFileIdentity(path20.join(storeDir, name)) !== void 0) {
10437
11410
  return { status: "unavailable", sizeBytes, ...walBytes === void 0 ? {} : { walBytes }, reason: "journal changed during diagnostics snapshot" };
10438
11411
  }
10439
11412
  }
10440
11413
  const header = Buffer.alloc(16);
10441
- const copiedHandle = await promises.open(path19.join(tempDir, JOURNAL_DB_FILENAME), "r");
11414
+ const copiedHandle = await promises.open(path20.join(tempDir, JOURNAL_DB_FILENAME), "r");
10442
11415
  try {
10443
11416
  const { bytesRead } = await copiedHandle.read(header, 0, header.length, 0);
10444
11417
  if (bytesRead !== 16 || header.toString("binary") !== "SQLite format 3\0") {
@@ -10447,7 +11420,7 @@ async function inspectJournal(storeDir) {
10447
11420
  } finally {
10448
11421
  await copiedHandle.close();
10449
11422
  }
10450
- db = new DatabaseSync(path19.join(tempDir, JOURNAL_DB_FILENAME), { readOnly: true });
11423
+ db = new DatabaseSync(path20.join(tempDir, JOURNAL_DB_FILENAME), { readOnly: true });
10451
11424
  const result = db.prepare("PRAGMA quick_check(1)").get();
10452
11425
  if (result?.quick_check !== "ok") {
10453
11426
  return { status: "corrupt", sizeBytes, ...walBytes === void 0 ? {} : { walBytes }, reason: "journal quick_check failed" };
@@ -10482,7 +11455,7 @@ async function inspectWorkspace(workspaceRoot) {
10482
11455
  }
10483
11456
  }
10484
11457
  function readPinnedQuarantineFile(name, maxBytes, includeBytes, budget) {
10485
- if (path19.basename(name) !== name || name === "." || name === "..") {
11458
+ if (path20.basename(name) !== name || name === "." || name === "..") {
10486
11459
  throw new Error("quarantine manifest contains an invalid evidence name");
10487
11460
  }
10488
11461
  const namedBefore = lstatSync(name, { bigint: true });
@@ -10586,10 +11559,10 @@ function inspectQuarantinePinned(dir, expectedDirectory) {
10586
11559
  if (isJournalQuarantineManifest(parsed)) {
10587
11560
  const manifestBase = manifestName.slice(0, -".manifest.json".length);
10588
11561
  const boundNames = parsed.files.map((file) => {
10589
- if (path19.dirname(path19.resolve(file)) !== path19.resolve(".")) {
11562
+ if (path20.dirname(path20.resolve(file)) !== path20.resolve(".")) {
10590
11563
  throw new Error("journal quarantine manifest points outside quarantine");
10591
11564
  }
10592
- return path19.basename(file);
11565
+ return path20.basename(file);
10593
11566
  });
10594
11567
  if (!boundNames.includes(manifestBase)) {
10595
11568
  throw new Error("journal quarantine manifest is not bound to its primary database evidence");
@@ -10629,7 +11602,7 @@ function inspectQuarantinePinned(dir, expectedDirectory) {
10629
11602
  }
10630
11603
  }
10631
11604
  async function inspectQuarantine(storeDir) {
10632
- const dir = path19.join(storeDir, JOURNAL_QUARANTINE_DIRNAME);
11605
+ const dir = path20.join(storeDir, JOURNAL_QUARANTINE_DIRNAME);
10633
11606
  let directory;
10634
11607
  try {
10635
11608
  directory = await promises.lstat(dir, { bigint: true });
@@ -10700,7 +11673,7 @@ function checksFor(snapshot) {
10700
11673
  ];
10701
11674
  }
10702
11675
  async function collectDiagnostics(config, storeDir, options = {}) {
10703
- const resolvedStoreDir = path19.resolve(storeDir);
11676
+ const resolvedStoreDir = path20.resolve(storeDir);
10704
11677
  const adapters = options.adapters ?? defaultRuntimeAdapters(config.runtimeAllowlist);
10705
11678
  const connectControl = options.connectControl ?? connectControlClient;
10706
11679
  const [device, probedRuntimes, health, journal, workspace, quarantine, controlConnection] = await Promise.all([
@@ -10875,7 +11848,7 @@ function publishQuarantineEvidencePinned(quarantineDir, expectedDirectory, sourc
10875
11848
  unlinkSync(sourcePath);
10876
11849
  sourceRemoved = true;
10877
11850
  if (process.platform !== "win32") {
10878
- const directoryFd = openSync(path19.dirname(sourcePath), constants.O_RDONLY);
11851
+ const directoryFd = openSync(path20.dirname(sourcePath), constants.O_RDONLY);
10879
11852
  try {
10880
11853
  fsyncSync(directoryFd);
10881
11854
  } finally {
@@ -10914,10 +11887,10 @@ function publishQuarantineEvidencePinned(quarantineDir, expectedDirectory, sourc
10914
11887
  }
10915
11888
  }
10916
11889
  async function quarantineCorruptOperationalHealth(storeDir, options = {}) {
10917
- const resolvedStoreDir = path19.resolve(storeDir);
11890
+ const resolvedStoreDir = path20.resolve(storeDir);
10918
11891
  const owner = await acquireDaemonOwner(resolvedStoreDir, "doctor", options.clock);
10919
11892
  try {
10920
- const sourcePath = path19.join(resolvedStoreDir, OPERATIONAL_HEALTH_FILENAME);
11893
+ const sourcePath = path20.join(resolvedStoreDir, OPERATIONAL_HEALTH_FILENAME);
10921
11894
  let opened;
10922
11895
  try {
10923
11896
  opened = await openOperationalHealthFile(resolvedStoreDir);
@@ -10934,7 +11907,7 @@ async function quarantineCorruptOperationalHealth(storeDir, options = {}) {
10934
11907
  }
10935
11908
  const sourceStat = await source.stat({ bigint: true });
10936
11909
  if (!sourceStat.isFile()) throw new Error("operational health state is not a regular file; refusing quarantine");
10937
- const quarantineDir = path19.join(resolvedStoreDir, JOURNAL_QUARANTINE_DIRNAME);
11910
+ const quarantineDir = path20.join(resolvedStoreDir, JOURNAL_QUARANTINE_DIRNAME);
10938
11911
  try {
10939
11912
  const existing = await promises.lstat(quarantineDir);
10940
11913
  if (!existing.isDirectory() || existing.isSymbolicLink()) {
@@ -11035,8 +12008,8 @@ function buildServiceDefinition(config, configPath, rest) {
11035
12008
  const name = argValue(rest, "--name") ?? config.productId;
11036
12009
  const agentBin = argValue(rest, "--agent-bin") ?? process.argv[1] ?? "byok-agent";
11037
12010
  const nodeBin = argValue(rest, "--node-bin") ?? process.execPath;
11038
- const absoluteConfigPath = path19.resolve(configPath);
11039
- const logDir = path19.join(resolveStoreDir(config), "service-logs");
12011
+ const absoluteConfigPath = path20.resolve(configPath);
12012
+ const logDir = path20.join(resolveStoreDir(config), "service-logs");
11040
12013
  const definition = {
11041
12014
  name,
11042
12015
  displayName: config.branding?.displayName ?? config.productName,
@@ -11090,7 +12063,7 @@ async function runServiceStatusCommand(config, configPath, rest, deps = {}) {
11090
12063
  log(`detail: ${status.detail.trim() || "(none)"}`);
11091
12064
  }
11092
12065
  function auditLogPath(storeDir) {
11093
- return path19.join(storeDir, "audit.jsonl");
12066
+ return path20.join(storeDir, "audit.jsonl");
11094
12067
  }
11095
12068
  var AUDIT_LOG_MODE = 384;
11096
12069
  var AUDIT_STORE_DIR_MODE = 448;
@@ -11223,6 +12196,14 @@ function redactForAudit(event) {
11223
12196
  return { ...base, reason: event.reason, undeliveredOutboxCount: event.undeliveredOutboxCount };
11224
12197
  case "stale-approval-decision":
11225
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 };
11226
12207
  case "git-workspace":
11227
12208
  return {
11228
12209
  ...base,
@@ -11363,6 +12344,26 @@ function reconstructDaemonEvent(raw) {
11363
12344
  reason: reasonSize === void 0 ? void 0 : placeholderFor(reasonSize)
11364
12345
  };
11365
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
+ }
11366
12367
  case "git-workspace": {
11367
12368
  const commitsSinceBaseline = gitCount(raw.commitsSinceBaseline);
11368
12369
  const dirty = gitDirty(raw.dirty);
@@ -11912,11 +12913,11 @@ async function createSupportBundle(config, storeDir, options = {}) {
11912
12913
  };
11913
12914
  }
11914
12915
  async function writeSupportBundle(outputPath, bundle, secureFileOptions = {}) {
11915
- const dir = path19.dirname(outputPath);
12916
+ const dir = path20.dirname(outputPath);
11916
12917
  const parentStat = await promises.stat(dir);
11917
12918
  if (!parentStat.isDirectory()) throw new Error("support bundle output parent is not a directory");
11918
- const privateDir = path19.join(dir, `.${path19.basename(outputPath)}.${process.pid}.${randomUUID()}.private`);
11919
- const tempPath = path19.join(privateDir, "bundle.tmp");
12919
+ const privateDir = path20.join(dir, `.${path20.basename(outputPath)}.${process.pid}.${randomUUID()}.private`);
12920
+ const tempPath = path20.join(privateDir, "bundle.tmp");
11920
12921
  try {
11921
12922
  await promises.mkdir(privateDir, { mode: 448 });
11922
12923
  await ensureSecureDir(privateDir, secureFileOptions);
@@ -11946,7 +12947,7 @@ async function writeSupportBundle(outputPath, bundle, secureFileOptions = {}) {
11946
12947
  // src/bin/commands/support-bundle.ts
11947
12948
  async function runSupportBundleCommand(config, options) {
11948
12949
  if (!options.outputPath) throw new Error("support-bundle requires --output <path>");
11949
- const outputPath = path19.resolve(options.outputPath);
12950
+ const outputPath = path20.resolve(options.outputPath);
11950
12951
  const bundle = await createSupportBundle(config, resolveStoreDir(config), options);
11951
12952
  await writeSupportBundle(outputPath, bundle);
11952
12953
  const log = options.log ?? ((line) => console.log(line));
@@ -11982,8 +12983,8 @@ async function runTasksFollowCommand(config, deps) {
11982
12983
  });
11983
12984
  return;
11984
12985
  }
11985
- const path24 = auditLogPath(storeDir);
11986
- await followAuditLog(path24, (event) => log(formatDaemonEventLine(event)), {
12986
+ const path25 = auditLogPath(storeDir);
12987
+ await followAuditLog(path25, (event) => log(formatDaemonEventLine(event)), {
11987
12988
  signal: deps.signal,
11988
12989
  pollIntervalMs: deps.pollIntervalMs,
11989
12990
  fromEnd: true