@love-moon/conductor-cli 0.9.0 → 0.11.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.
package/src/daemon.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  import { resolveResumeContext } from "./fire/resume.js";
39
39
  import {
40
40
  filterRuntimeSupportedAllowCliList,
41
+ inferBuiltInRuntimeBackendFromCommand,
41
42
  listAdvertisedBackends,
42
43
  resolveConfiguredRuntimeBackend,
43
44
  isBuiltInRuntimeBackend,
@@ -46,6 +47,7 @@ import {
46
47
  normalizeRuntimeBackendAlias,
47
48
  normalizeRuntimeBackendName,
48
49
  } from "./runtime-backends.js";
50
+ import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
49
51
  import {
50
52
  PACKAGE_NAME,
51
53
  buildUpgradeCommand,
@@ -67,7 +69,31 @@ import {
67
69
  maskErrorForLogs,
68
70
  redactSecretsForLogs,
69
71
  } from "./handoff-log-mask.js";
72
+ import {
73
+ DAEMON_LOCK_FILE_NAME,
74
+ FORCE_KILL_UNKNOWN_OWNER_ENV_VAR,
75
+ buildDaemonInstanceIdentity,
76
+ compareDaemonLockIdentity,
77
+ describeDaemonLockOwner,
78
+ describeForceRestartRefusal,
79
+ parseDaemonLockState,
80
+ serializeDaemonLock,
81
+ } from "./daemon-lock.js";
70
82
  import { StringDecoder } from "node:string_decoder";
83
+ import {
84
+ MAX_GUEST_DAEMONS,
85
+ buildGuestConfigYaml,
86
+ buildGuestEnv,
87
+ filterGuestCapabilities,
88
+ isGuestAiManagerActionAllowed,
89
+ isGuestRestartAllowed,
90
+ isPathInsideGuestRoot,
91
+ nextRestartDelayMs,
92
+ reconcileGuests,
93
+ startOrphanWatchdog,
94
+ resolveGuestPaths,
95
+ writeGuestConfig,
96
+ } from "./guest-daemon.js";
71
97
 
72
98
  dotenv.config();
73
99
 
@@ -76,6 +102,10 @@ const __dirname = path.dirname(__filename);
76
102
  const PACKAGE_ROOT = path.join(__dirname, "..");
77
103
  const moduleRequire = createRequire(import.meta.url);
78
104
  const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
105
+ // RFC 0035: the launcher a guest daemon child is spawned with. Same binary as
106
+ // the host daemon, so guests always match the installed version and there is
107
+ // no second update path to keep in sync.
108
+ const DAEMON_LAUNCHER_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon.js");
79
109
  const CLI_VERSION = (() => {
80
110
  try {
81
111
  return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
@@ -353,6 +383,57 @@ function serializeRuntimeBackendMap(runtimeBackendMap) {
353
383
  .join(",");
354
384
  }
355
385
 
386
+ // Backends whose install/auth health can be probed via the AI manager. Others
387
+ // (external providers) are left out of the advertised health map so the web
388
+ // runtime preflight fails open for them.
389
+ const RUNTIME_HEALTH_TOOLS = new Set(["codex", "claude", "copilot", "kimi", "dsh"]);
390
+
391
+ function runtimeHealthToolForBackend(backend, runtimeBackendMap) {
392
+ const normalized = String(backend || "").trim().toLowerCase();
393
+ if (RUNTIME_HEALTH_TOOLS.has(normalized)) {
394
+ return normalized;
395
+ }
396
+ const runtime = String(runtimeBackendMap?.[normalized] || "").trim().toLowerCase();
397
+ return RUNTIME_HEALTH_TOOLS.has(runtime) ? runtime : null;
398
+ }
399
+
400
+ function serializeRuntimeHealth(runtimeHealth) {
401
+ if (!runtimeHealth || typeof runtimeHealth !== "object") {
402
+ return "";
403
+ }
404
+ return Object.entries(runtimeHealth)
405
+ .filter(([backend, state]) => backend && state)
406
+ .sort(([left], [right]) => left.localeCompare(right))
407
+ .map(([backend, state]) => `${backend}=${state}`)
408
+ .join(",");
409
+ }
410
+
411
+ // Best-effort per-backend runtime health for the web runtime preflight. Only a
412
+ // cheap install probe (which/--version) runs, once at startup. We advertise
413
+ // only a POSITIVE `ready` confirmation: a supported backend whose default-name
414
+ // `which` probe fails is very likely resolved via a custom or absolute path
415
+ // (allow_cli_list, or a systemd PATH that differs from the login shell) and is
416
+ // still runnable, so reporting it as "missing" would be an unreliable false
417
+ // negative that could wrongly block task creation. Omitting it lets the web
418
+ // preflight fail open for that backend.
419
+ async function computeAdvertisedRuntimeHealth(manager, supportedBackends, runtimeBackendMap) {
420
+ if (!manager || typeof manager.checkInstallAll !== "function") {
421
+ return {};
422
+ }
423
+ const install = await manager.checkInstallAll();
424
+ const runtimeHealth = {};
425
+ for (const backend of supportedBackends || []) {
426
+ const tool = runtimeHealthToolForBackend(backend, runtimeBackendMap);
427
+ if (!tool) {
428
+ continue;
429
+ }
430
+ if (install?.[tool]?.installed) {
431
+ runtimeHealth[String(backend).trim().toLowerCase()] = "ready";
432
+ }
433
+ }
434
+ return runtimeHealth;
435
+ }
436
+
356
437
  async function defaultCreatePty(command, args, options) {
357
438
  if (!nodePtySpawnPromise) {
358
439
  const spawnHelperInfo = ensureNodePtySpawnHelperExecutable();
@@ -442,6 +523,18 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
442
523
  return { helperPath, updated: true };
443
524
  }
444
525
 
526
+ // A tool-preset PTY task runs the configured allow_cli_list command through a
527
+ // login shell, bypassing ai-sdk entirely. claude refuses to start as root with
528
+ // `--dangerously-skip-permissions`, so rewrite the command the same way the
529
+ // ai-sdk session rewrites its permission mode — one shared root check, so the
530
+ // two paths cannot drift.
531
+ export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
532
+ if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
533
+ return cliCommand;
534
+ }
535
+ return resolveClaudeCommandForRoot(cliCommand, env);
536
+ }
537
+
445
538
  export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
446
539
  const normalizedWorkspacePath =
447
540
  typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
@@ -624,6 +717,12 @@ function parseTaskWorktreeLaunchConfig(launchConfig) {
624
717
  projectRepoRoot,
625
718
  projectWorkspacePath,
626
719
  projectRelativePath,
720
+ // Reuse-only members (reviewers in a multi-agent group) share the owner's
721
+ // worktree but must never run `git worktree add` themselves.
722
+ worktreeReuseOnly: normalizeBooleanFlag(
723
+ normalizedLaunchConfig.worktreeReuseOnly ??
724
+ normalizedLaunchConfig.worktree_reuse_only,
725
+ ),
627
726
  };
628
727
  }
629
728
 
@@ -721,6 +820,106 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
721
820
  };
722
821
  }
723
822
 
823
+ // Module-level so the PTY launch wiring is directly testable: the tool-preset
824
+ // branch has to hand the *child's* env to the root check, and that seam is
825
+ // exactly where a bug hid before (the check read the daemon's own process.env,
826
+ // silently ignoring a per-task IS_SANDBOX=1 opt-out).
827
+ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
828
+ const allowCliList = deps.allowCliList || {};
829
+ const supportedBackends = Array.isArray(deps.supportedBackends) ? deps.supportedBackends : [];
830
+ const existsSyncFn = deps.existsSync || fs.existsSync;
831
+ const baseEnv = deps.baseEnv || process.env;
832
+ const log = typeof deps.log === "function" ? deps.log : () => {};
833
+ const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
834
+ const entrypointType =
835
+ normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
836
+ normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
837
+ (normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
838
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset)
839
+ ? "tool_preset"
840
+ : "shell");
841
+ const preferredShell = resolveDefaultPtyShell({
842
+ explicitShell: normalizedLaunchConfig.shell,
843
+ envShell: process.env.SHELL,
844
+ comspec: process.env.COMSPEC,
845
+ platform: process.platform,
846
+ existsSync: existsSyncFn,
847
+ });
848
+ const cwd =
849
+ normalizeOptionalString(normalizedLaunchConfig.cwd) ||
850
+ fallbackCwd;
851
+ const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
852
+ const cols = normalizePositiveInt(
853
+ normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
854
+ DEFAULT_TERMINAL_COLS,
855
+ );
856
+ const rows = normalizePositiveInt(
857
+ normalizedLaunchConfig.rows,
858
+ DEFAULT_TERMINAL_ROWS,
859
+ );
860
+
861
+ if (entrypointType === "tool_preset") {
862
+ const toolPreset =
863
+ normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
864
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
865
+ supportedBackends[0] ||
866
+ "codex";
867
+ const cliCommand = allowCliList[toolPreset];
868
+ if (!cliCommand) {
869
+ throw new Error(`Unsupported tool preset: ${toolPreset}`);
870
+ }
871
+ const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
872
+ if (launchCommand !== cliCommand) {
873
+ log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
874
+ }
875
+ return {
876
+ entrypointType,
877
+ toolPreset,
878
+ command: preferredShell,
879
+ args: ["-lc", launchCommand],
880
+ shell: preferredShell,
881
+ cwd,
882
+ env,
883
+ cols,
884
+ rows,
885
+ };
886
+ }
887
+
888
+ if (entrypointType === "custom") {
889
+ const command = normalizeOptionalString(normalizedLaunchConfig.command);
890
+ if (!command) {
891
+ throw new Error("launch_config.command is required for custom entrypoint");
892
+ }
893
+ const args = Array.isArray(normalizedLaunchConfig.args)
894
+ ? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
895
+ : [];
896
+ return {
897
+ entrypointType,
898
+ toolPreset: null,
899
+ command,
900
+ args,
901
+ shell: preferredShell,
902
+ cwd,
903
+ env,
904
+ cols,
905
+ rows,
906
+ };
907
+ }
908
+
909
+ return {
910
+ entrypointType: "shell",
911
+ toolPreset: null,
912
+ command: preferredShell,
913
+ args: ["-l"],
914
+ shell: preferredShell,
915
+ cwd,
916
+ env,
917
+ cols,
918
+ rows,
919
+ };
920
+ }
921
+
922
+
724
923
  export function startDaemon(config = {}, deps = {}) {
725
924
  const exitFn = deps.exit || process.exit;
726
925
  const killFn = deps.kill || process.kill;
@@ -830,6 +1029,24 @@ export function startDaemon(config = {}, deps = {}) {
830
1029
  // warning and silently fall back to direct spawn rather than failing every
831
1030
  // create_task with ENOENT.
832
1031
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
1032
+ // RFC 0035: a guest daemon runs on someone else's machine as a different
1033
+ // account. It keeps the ordinary daemon code path but drops the capabilities
1034
+ // that would mutate state the machine's OWNER depends on.
1035
+ const IS_GUEST_DAEMON =
1036
+ userConfig.conductor_guest === true ||
1037
+ fileConfig?.conductorGuest === true ||
1038
+ Boolean(normalizeOptionalString(process.env.CONDUCTOR_GUEST_SHARE_ID));
1039
+ // Only set when the share carries an explicit `workspaceRoot`, i.e. the owner
1040
+ // deliberately scoped where the guest should work. Absent that, a guest binds
1041
+ // projects wherever any other daemon could -- inventing a confinement the
1042
+ // owner never asked for would make the guest gratuitously less capable, and
1043
+ // it was never a security boundary anyway (the grantee's agent runs a shell).
1044
+ const GUEST_ROOT = normalizeOptionalString(process.env.CONDUCTOR_GUEST_ROOT);
1045
+ // A guest resolves `remote_exec` from its own config exactly like any other
1046
+ // daemon. It is not withheld: the grantee already has a shell here through
1047
+ // AI tasks and `pty_task`'s custom entrypoint, so blocking the scriptable
1048
+ // path would cost function without removing reach (RFC 0034 makes the same
1049
+ // argument). An owner who wants it off can set `remote_exec: false`.
833
1050
  const remoteExecEnabled = getRemoteExecEnabled(userConfig);
834
1051
 
835
1052
  // Get allow_cli_list from config
@@ -860,6 +1077,17 @@ export function startDaemon(config = {}, deps = {}) {
860
1077
  readFileSync: deps.readFileSync || fs.readFileSync,
861
1078
  }));
862
1079
  const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
1080
+ // Fingerprint of *this* daemon instance, persisted into daemon.pid so that a
1081
+ // later `--force` can tell "restart myself" apart from "kill whoever happens
1082
+ // to hold the lock". WORKSPACE_ROOT defaults to $HOME/ws, so unrelated
1083
+ // instances collide on the same lock file by default.
1084
+ const daemonInstanceIdentity = buildDaemonInstanceIdentity({
1085
+ conductorHome: materializedConductorPathEnv.CONDUCTOR_HOME,
1086
+ configPath: effectiveConfigPath,
1087
+ workspaceRoot: WORKSPACE_ROOT,
1088
+ daemonName: AGENT_NAME,
1089
+ backendUrl: BACKEND_HTTP,
1090
+ });
863
1091
  const lockHandoffToken =
864
1092
  normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
865
1093
  normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
@@ -2402,6 +2630,79 @@ export function startDaemon(config = {}, deps = {}) {
2402
2630
  });
2403
2631
  }
2404
2632
 
2633
+ // In-flight worktree preparations keyed on the on-disk root. Group members
2634
+ // (worker + reviewers) start almost simultaneously and would otherwise all
2635
+ // pass the `.git` existence check below and race on `git worktree add -b`
2636
+ // for the same branch: one wins and the losers fail with
2637
+ // "Failed to prepare git worktree". Serializing per root also benefits the
2638
+ // pre-existing fork/branch sharing path.
2639
+ const taskWorktreePreparations = new Map();
2640
+
2641
+ // Readiness marker, written by the owner only after the worktree is FULLY
2642
+ // prepared. `.git` alone is not a readiness signal: it appears the moment
2643
+ // `git worktree add` returns, while submodules and symlinks are still
2644
+ // pending. An owner that dies in between (e.g. submodule sync hits its
2645
+ // timeout) would otherwise leave `.git` behind and let reuse-only members
2646
+ // walk into a half-built directory.
2647
+ //
2648
+ // It lives NEXT TO the worktree, not inside it: an untracked file inside
2649
+ // would make `git status --porcelain` report the worktree as dirty and block
2650
+ // non-forced cleanup.
2651
+ function taskWorktreeReadyMarkerPath(worktreeRoot) {
2652
+ return `${worktreeRoot}.ready`;
2653
+ }
2654
+
2655
+ function removeTaskWorktreeReadyMarker(worktreeRoot) {
2656
+ const markerPath = taskWorktreeReadyMarkerPath(worktreeRoot);
2657
+ if (!existsSyncFn(markerPath)) {
2658
+ return;
2659
+ }
2660
+ try {
2661
+ unlinkSyncFn(markerPath);
2662
+ } catch (error) {
2663
+ if (error?.code !== "ENOENT") {
2664
+ logError(
2665
+ `[worktree] failed to clear ready marker for ${worktreeRoot}: ${error?.message || error}`,
2666
+ );
2667
+ }
2668
+ }
2669
+ }
2670
+
2671
+ // Both must hold. Requiring `.git` too means a marker left behind by a
2672
+ // worktree that was removed outside our cleanup path cannot fake readiness.
2673
+ function isTaskWorktreeReady(worktreeRoot, gitMarkerPath) {
2674
+ return (
2675
+ existsSyncFn(gitMarkerPath) &&
2676
+ existsSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot))
2677
+ );
2678
+ }
2679
+
2680
+ async function waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath }) {
2681
+ const deadline = Date.now() + TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS;
2682
+ for (;;) {
2683
+ // When the owner is preparing in this same daemon process we await the
2684
+ // whole preparation (worktree add + submodules + symlinks). Polling the
2685
+ // marker additionally covers an owner that already finished, or one
2686
+ // running in a different process.
2687
+ const inflight = taskWorktreePreparations.get(worktreeRoot);
2688
+ if (inflight) {
2689
+ await inflight;
2690
+ }
2691
+ if (isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
2692
+ return;
2693
+ }
2694
+ if (Date.now() >= deadline) {
2695
+ throw new Error(
2696
+ `Timed out waiting for shared git worktree ${worktreeRoot} for ${taskId}. ` +
2697
+ `Its owner task may have failed midway through preparation.`,
2698
+ );
2699
+ }
2700
+ await new Promise((resolve) =>
2701
+ setTimeout(resolve, TASK_WORKTREE_REUSE_POLL_INTERVAL_MS),
2702
+ );
2703
+ }
2704
+ }
2705
+
2405
2706
  async function ensureTaskWorktree({ taskId, projectId, launchConfig }) {
2406
2707
  const worktreeConfig = parseTaskWorktreeLaunchConfig(launchConfig);
2407
2708
  if (!worktreeConfig) {
@@ -2414,7 +2715,50 @@ export function startDaemon(config = {}, deps = {}) {
2414
2715
  );
2415
2716
  const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
2416
2717
  const gitMarkerPath = path.join(worktreeRoot, ".git");
2718
+
2719
+ if (worktreeConfig.worktreeReuseOnly) {
2720
+ await waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath });
2721
+ mkdirSyncFn(finalCwd, { recursive: true });
2722
+ return finalCwd;
2723
+ }
2724
+
2725
+ const inflight = taskWorktreePreparations.get(worktreeRoot);
2726
+ if (inflight) {
2727
+ await inflight;
2728
+ }
2729
+ // Prepare when we are the first owner here, or when the preparation we
2730
+ // waited on did not leave a fully-prepared worktree (then we take over).
2731
+ if (!inflight || !isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
2732
+ const preparation = prepareTaskWorktree({
2733
+ taskId,
2734
+ worktreeConfig,
2735
+ worktreeRoot,
2736
+ gitMarkerPath,
2737
+ finalCwd,
2738
+ });
2739
+ // Waiters must never inherit our rejection; they re-check `.git` instead.
2740
+ taskWorktreePreparations.set(worktreeRoot, preparation.catch(() => {}));
2741
+ try {
2742
+ await preparation;
2743
+ } finally {
2744
+ taskWorktreePreparations.delete(worktreeRoot);
2745
+ }
2746
+ }
2747
+ return finalCwd;
2748
+ }
2749
+
2750
+ async function prepareTaskWorktree({
2751
+ taskId,
2752
+ worktreeConfig,
2753
+ worktreeRoot,
2754
+ gitMarkerPath,
2755
+ finalCwd,
2756
+ }) {
2417
2757
  if (!existsSyncFn(gitMarkerPath)) {
2758
+ // A worktree removed outside our cleanup path can leave a stale marker
2759
+ // behind. Clear it before recreating, so a reuse-only member cannot read
2760
+ // "ready" while `git worktree add` is still running.
2761
+ removeTaskWorktreeReadyMarker(worktreeRoot);
2418
2762
  const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
2419
2763
  if (syncBranch) {
2420
2764
  try {
@@ -2512,7 +2856,8 @@ export function startDaemon(config = {}, deps = {}) {
2512
2856
  projectWorkspacePath: worktreeConfig.projectWorkspacePath,
2513
2857
  finalCwd,
2514
2858
  });
2515
- return finalCwd;
2859
+ // Publish readiness last: every step above has to have succeeded.
2860
+ writeFileSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot), "");
2516
2861
  }
2517
2862
 
2518
2863
  const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
@@ -2545,6 +2890,17 @@ export function startDaemon(config = {}, deps = {}) {
2545
2890
  process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
2546
2891
  120_000,
2547
2892
  );
2893
+ // A reuse-only member waits for the owner to create the shared worktree.
2894
+ // The bound has to cover a cold clone's submodule sync, hence the generous
2895
+ // default; it only ever elapses when the owner never starts or hard-fails.
2896
+ const TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS = parsePositiveInt(
2897
+ process.env.CONDUCTOR_WORKTREE_REUSE_WAIT_TIMEOUT_MS,
2898
+ 180_000,
2899
+ );
2900
+ const TASK_WORKTREE_REUSE_POLL_INTERVAL_MS = parsePositiveInt(
2901
+ process.env.CONDUCTOR_WORKTREE_REUSE_POLL_INTERVAL_MS,
2902
+ 250,
2903
+ );
2548
2904
  const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
2549
2905
  process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
2550
2906
  1000,
@@ -2586,36 +2942,7 @@ export function startDaemon(config = {}, deps = {}) {
2586
2942
  DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
2587
2943
  );
2588
2944
 
2589
- const readLockState = () => {
2590
- const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
2591
- if (!raw) {
2592
- return null;
2593
- }
2594
-
2595
- const pid = Number.parseInt(raw, 10);
2596
- if (!Number.isNaN(pid) && pid > 0) {
2597
- return {
2598
- pid,
2599
- handoffFromPid: null,
2600
- handoffToken: null,
2601
- handoffExpiresAt: null,
2602
- };
2603
- }
2604
-
2605
- try {
2606
- const parsed = JSON.parse(raw);
2607
- const parsedPid = normalizePositiveInt(parsed?.pid, null);
2608
- const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
2609
- return {
2610
- pid: parsedPid ?? parsedHandoffFromPid,
2611
- handoffFromPid: parsedHandoffFromPid,
2612
- handoffToken: normalizeOptionalString(parsed?.handoff_token),
2613
- handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
2614
- };
2615
- } catch {
2616
- return null;
2617
- }
2618
- };
2945
+ const readLockState = () => parseDaemonLockState(readFileSyncFn(LOCK_FILE, "utf-8"));
2619
2946
 
2620
2947
  const hasMatchingLockHandoff = (lockState) => {
2621
2948
  if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
@@ -2654,7 +2981,7 @@ export function startDaemon(config = {}, deps = {}) {
2654
2981
  return exitAndReturn(1);
2655
2982
  }
2656
2983
 
2657
- const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
2984
+ const LOCK_FILE = path.join(WORKSPACE_ROOT, DAEMON_LOCK_FILE_NAME);
2658
2985
  try {
2659
2986
  if (skipPidLockCheck) {
2660
2987
  log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
@@ -2671,6 +2998,21 @@ export function startDaemon(config = {}, deps = {}) {
2671
2998
  const alive = isProcessAlive(pid);
2672
2999
  if (alive) {
2673
3000
  if (config.FORCE) {
3001
+ const forceRefusal = describeForceRestartRefusal({
3002
+ lockState,
3003
+ identity: daemonInstanceIdentity,
3004
+ lockFile: LOCK_FILE,
3005
+ env: process.env,
3006
+ });
3007
+ if (forceRefusal) {
3008
+ logError(forceRefusal);
3009
+ return exitAndReturn(1);
3010
+ }
3011
+ if (compareDaemonLockIdentity(lockState, daemonInstanceIdentity) === "unknown") {
3012
+ log(
3013
+ `${FORCE_KILL_UNKNOWN_OWNER_ENV_VAR} is set: stopping PID ${pid} even though the lock file carries no instance identity`,
3014
+ );
3015
+ }
2674
3016
  log(`Force enabled: stopping existing daemon PID ${pid}`);
2675
3017
  let alreadyExited = false;
2676
3018
  try {
@@ -2712,7 +3054,9 @@ export function startDaemon(config = {}, deps = {}) {
2712
3054
  unlinkSyncFn(LOCK_FILE);
2713
3055
  }
2714
3056
  } else {
2715
- logError(`Daemon already running with PID ${pid}`);
3057
+ logError(
3058
+ `Daemon already running with PID ${pid} — ${describeDaemonLockOwner(lockState)}. Use --force to restart it.`,
3059
+ );
2716
3060
  return exitAndReturn(1);
2717
3061
  }
2718
3062
  } else {
@@ -2733,7 +3077,10 @@ export function startDaemon(config = {}, deps = {}) {
2733
3077
  unlinkSyncFn(LOCK_FILE);
2734
3078
  }
2735
3079
  }
2736
- writeFileSyncFn(LOCK_FILE, process.pid.toString());
3080
+ writeFileSyncFn(
3081
+ LOCK_FILE,
3082
+ serializeDaemonLock({ pid: process.pid, identity: daemonInstanceIdentity }),
3083
+ );
2737
3084
  }
2738
3085
  } catch (err) {
2739
3086
  logError("Failed to acquire lock:", err);
@@ -2760,13 +3107,15 @@ export function startDaemon(config = {}, deps = {}) {
2760
3107
  if (skipPidLockCheck) {
2761
3108
  return;
2762
3109
  }
3110
+ // Keeps the instance identity attached while the handoff window is open so
3111
+ // an unrelated instance racing a `--force` during a restart still sees who
3112
+ // owns the lock.
2763
3113
  writeFileSyncFn(
2764
3114
  LOCK_FILE,
2765
- JSON.stringify({
3115
+ serializeDaemonLock({
2766
3116
  pid: handoffFromPid,
2767
- handoff_from_pid: handoffFromPid,
2768
- handoff_token: handoffToken,
2769
- handoff_expires_at: handoffExpiresAt,
3117
+ identity: daemonInstanceIdentity,
3118
+ handoff: { handoffFromPid, handoffToken, handoffExpiresAt },
2770
3119
  }),
2771
3120
  );
2772
3121
  };
@@ -2844,6 +3193,18 @@ export function startDaemon(config = {}, deps = {}) {
2844
3193
  removeProcessListener("unhandledRejection", onUnhandledRejection);
2845
3194
  };
2846
3195
 
3196
+ // RFC 0035: never leave guest daemons orphaned. They are children of this
3197
+ // process, so without this they would survive the host daemon and keep
3198
+ // serving a share the owner believes is stopped.
3199
+ const stopGuestsOnExit = () => {
3200
+ try {
3201
+ stopAllGuestDaemons("host daemon exiting");
3202
+ } catch {
3203
+ // best effort during shutdown
3204
+ }
3205
+ };
3206
+ process.on("exit", stopGuestsOnExit);
3207
+
2847
3208
  process.on("exit", cleanupLock);
2848
3209
  process.on("SIGINT", onSigInt);
2849
3210
  process.on("SIGTERM", onSigTerm);
@@ -2971,6 +3332,7 @@ export function startDaemon(config = {}, deps = {}) {
2971
3332
  };
2972
3333
  const advertisedCapabilities = [
2973
3334
  "project_path_validation",
3335
+ "project_path_create",
2974
3336
  "project_agents_registry",
2975
3337
  "restart_daemon",
2976
3338
  "refresh_session_inplace",
@@ -2985,10 +3347,31 @@ export function startDaemon(config = {}, deps = {}) {
2985
3347
  if (ptyTaskCapabilityEnabled) {
2986
3348
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
2987
3349
  }
2988
- if (advertisedCapabilities.length > 0) {
2989
- extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
3350
+ // RFC 0035: strip capabilities a guest must never offer. `ai_manager` stays
3351
+ // advertised on purpose -- the grantee needs to see how much AI quota is left
3352
+ // on the borrowed machine -- but its `switch_account` action is refused at
3353
+ // dispatch, because that one rewrites `~/.codex/auth.json` and would change
3354
+ // the machine owner's own active account.
3355
+ const effectiveCapabilities = IS_GUEST_DAEMON
3356
+ ? filterGuestCapabilities(advertisedCapabilities)
3357
+ : advertisedCapabilities;
3358
+ if (IS_GUEST_DAEMON) {
3359
+ log(
3360
+ `[guest] Running as a shared guest daemon; capabilities=${effectiveCapabilities.join(",")}` +
3361
+ (GUEST_ROOT ? `, root=${GUEST_ROOT}` : "")
3362
+ );
3363
+ // If the host daemon is SIGKILLed, nothing else would stop this process.
3364
+ startOrphanWatchdog({
3365
+ onOrphaned: () => {
3366
+ logError("[guest] Host daemon is gone; shutting down to avoid an orphaned share");
3367
+ exitAndReturn(0);
3368
+ },
3369
+ });
3370
+ }
3371
+ if (effectiveCapabilities.length > 0) {
3372
+ extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
2990
3373
  }
2991
- const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
3374
+ const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
2992
3375
  const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
2993
3376
  const remoteExecHandlers = remoteExecEnabled
2994
3377
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
@@ -3085,6 +3468,28 @@ export function startDaemon(config = {}, deps = {}) {
3085
3468
  if (typeof client?.setExtraHeaders === "function") {
3086
3469
  client.setExtraHeaders(extraHeaders);
3087
3470
  }
3471
+
3472
+ // Advertise best-effort runtime health so the backend can reject a task
3473
+ // whose backend is configured but cannot start (CLI missing/not signed in)
3474
+ // before creating any timeline activity. Fully additive and guarded: any
3475
+ // failure just omits the header and the preflight fails open.
3476
+ try {
3477
+ const runtimeHealth = await computeAdvertisedRuntimeHealth(
3478
+ aiManagerHandlers?.manager,
3479
+ SUPPORTED_BACKENDS,
3480
+ SUPPORTED_BACKEND_RUNTIME_MAP,
3481
+ );
3482
+ const serializedRuntimeHealth = serializeRuntimeHealth(runtimeHealth);
3483
+ if (serializedRuntimeHealth) {
3484
+ extraHeaders["x-conductor-runtime-health"] = serializedRuntimeHealth;
3485
+ if (typeof client?.setExtraHeaders === "function") {
3486
+ client.setExtraHeaders(extraHeaders);
3487
+ }
3488
+ }
3489
+ } catch (error) {
3490
+ logError(`Failed to probe runtime health: ${error?.message || error}`);
3491
+ }
3492
+
3088
3493
  if (daemonShuttingDown) {
3089
3494
  return;
3090
3495
  }
@@ -3100,6 +3505,12 @@ export function startDaemon(config = {}, deps = {}) {
3100
3505
  logError(`Failed to connect: ${err}`);
3101
3506
  });
3102
3507
 
3508
+ // RFC 0035: keep one child daemon per accepted share. A guest never
3509
+ // supervises anything itself, or a share chain could nest.
3510
+ if (!IS_GUEST_DAEMON) {
3511
+ void reconcileGuestDaemons();
3512
+ }
3513
+
3103
3514
  if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
3104
3515
  if (installMethod === "homebrew") {
3105
3516
  log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
@@ -3110,6 +3521,9 @@ export function startDaemon(config = {}, deps = {}) {
3110
3521
 
3111
3522
  const runMaintenanceTick = async () => {
3112
3523
  void runDaemonWatchdog();
3524
+ if (!IS_GUEST_DAEMON) {
3525
+ void reconcileGuestDaemons();
3526
+ }
3113
3527
  try {
3114
3528
  await checkForUpdate();
3115
3529
  } catch {
@@ -3610,6 +4024,165 @@ export function startDaemon(config = {}, deps = {}) {
3610
4024
  await restartDaemonProcess("auto-update");
3611
4025
  }
3612
4026
 
4027
+ // ---------------------------------------------------------------------
4028
+ // RFC 0035: guest daemon supervision.
4029
+ //
4030
+ // For each share the backend reports as active, run one child
4031
+ // `conductor daemon` that authenticates as the grantee. The child is an
4032
+ // ordinary daemon in every respect; what makes it a guest is its isolated
4033
+ // CONDUCTOR_HOME/CONDUCTOR_WS and the grantee's scoped token.
4034
+ // ---------------------------------------------------------------------
4035
+ /** shareId -> { child, guestHost, restartTimer, stopping } */
4036
+ const guestDaemons = new Map();
4037
+ // Failure counts must outlive the entry they describe. The restart path
4038
+ // deletes the entry and re-asks the backend (so a share revoked during
4039
+ // backoff stays dead), which means a per-entry counter resets to 0 on every
4040
+ // respawn and the exponential backoff degrades into a permanent 10s loop.
4041
+ const guestFailureCounts = new Map();
4042
+ let guestReconcileInFlight = false;
4043
+
4044
+ function stopGuestDaemon(shareId, reason) {
4045
+ const entry = guestDaemons.get(shareId);
4046
+ if (!entry) return;
4047
+ entry.stopping = true;
4048
+ if (entry.restartTimer) clearTimeout(entry.restartTimer);
4049
+ guestDaemons.delete(shareId);
4050
+ guestFailureCounts.delete(shareId);
4051
+ if (entry.child && entry.child.exitCode === null && !entry.child.killed) {
4052
+ log(`[guest] Stopping guest ${entry.guestHost} (${reason})`);
4053
+ try {
4054
+ entry.child.kill("SIGTERM");
4055
+ } catch {
4056
+ // already gone
4057
+ }
4058
+ }
4059
+ }
4060
+
4061
+ function stopAllGuestDaemons(reason) {
4062
+ for (const shareId of [...guestDaemons.keys()]) {
4063
+ stopGuestDaemon(shareId, reason);
4064
+ }
4065
+ }
4066
+
4067
+ function spawnGuestDaemon(share) {
4068
+ const paths = resolveGuestPaths(share.id, share.workspaceRoot, homeDir);
4069
+ try {
4070
+ writeGuestConfig(
4071
+ paths,
4072
+ buildGuestConfigYaml({
4073
+ agentToken: share.agentToken,
4074
+ backendUrl: BACKEND_HTTP,
4075
+ guestHost: share.guestHost,
4076
+ workspace: paths.workspace,
4077
+ allowCliList: RAW_ALLOW_CLI_LIST,
4078
+ })
4079
+ );
4080
+ } catch (error) {
4081
+ logError(`[guest] Failed to materialize config for ${share.guestHost}: ${error?.message || error}`);
4082
+ return;
4083
+ }
4084
+
4085
+ const env = buildGuestEnv(process.env, paths, {
4086
+ shareId: share.id,
4087
+ explicitRoot: Boolean(share.workspaceRoot),
4088
+ });
4089
+ const child = spawn(
4090
+ process.execPath,
4091
+ [DAEMON_LAUNCHER_PATH, "--config-file", paths.configPath],
4092
+ { env, stdio: "ignore", detached: false }
4093
+ );
4094
+
4095
+ const entry = {
4096
+ child,
4097
+ guestHost: share.guestHost,
4098
+ restartTimer: null,
4099
+ stopping: false,
4100
+ };
4101
+ guestDaemons.set(share.id, entry);
4102
+ log(`[guest] Started guest ${share.guestHost} for ${share.granteeLabel || "grantee"} (pid=${child.pid})`);
4103
+
4104
+ child.on("exit", (code, signal) => {
4105
+ const current = guestDaemons.get(share.id);
4106
+ // Either we asked it to stop, or a newer entry already replaced it.
4107
+ if (!current || current.child !== child || current.stopping) return;
4108
+ const failures = (guestFailureCounts.get(share.id) || 0) + 1;
4109
+ guestFailureCounts.set(share.id, failures);
4110
+ const delay = nextRestartDelayMs(failures);
4111
+ log(
4112
+ `[guest] Guest ${share.guestHost} exited (code=${code}, signal=${signal}); ` +
4113
+ `restarting in ${Math.round(delay / 1000)}s (failure ${failures})`
4114
+ );
4115
+ current.restartTimer = setTimeout(() => {
4116
+ // Don't resurrect from a stale timer: re-check with the backend
4117
+ // instead, so a share revoked while we were backing off stays dead.
4118
+ guestDaemons.delete(share.id);
4119
+ void reconcileGuestDaemons();
4120
+ }, delay);
4121
+ if (typeof current.restartTimer.unref === "function") current.restartTimer.unref();
4122
+ });
4123
+
4124
+ child.on("error", (error) => {
4125
+ // Node emits `error` without `exit` for e.g. ENOENT. Without this the
4126
+ // entry would sit in the map forever: reconcile counts it as running, so
4127
+ // it is never retried and never started.
4128
+ logError(`[guest] Guest ${share.guestHost} failed to spawn: ${error?.message || error}`);
4129
+ const current = guestDaemons.get(share.id);
4130
+ if (!current || current.child !== child || current.stopping) return;
4131
+ const failures = (guestFailureCounts.get(share.id) || 0) + 1;
4132
+ guestFailureCounts.set(share.id, failures);
4133
+ guestDaemons.delete(share.id);
4134
+ const timer = setTimeout(() => void reconcileGuestDaemons(), nextRestartDelayMs(failures));
4135
+ if (typeof timer.unref === "function") timer.unref();
4136
+ });
4137
+ }
4138
+
4139
+ async function fetchActiveShares() {
4140
+ const url = `${BACKEND_HTTP}/api/daemon-shares/mine?daemonHost=${encodeURIComponent(AGENT_NAME)}`;
4141
+ // Without a deadline a hung backend pins `guestReconcileInFlight` for
4142
+ // undici's 300s default, and nothing else clears it -- the supervisor goes
4143
+ // silently deaf for five minutes.
4144
+ const response = await fetch(url, {
4145
+ headers: { Authorization: `Bearer ${AGENT_TOKEN}` },
4146
+ signal: AbortSignal.timeout(15_000),
4147
+ });
4148
+ if (!response.ok) {
4149
+ // A backend that predates this feature returns 404; that is not an error
4150
+ // worth logging on every maintenance tick.
4151
+ if (response.status === 404) return null;
4152
+ throw new Error(`HTTP ${response.status}`);
4153
+ }
4154
+ const body = await response.json();
4155
+ return Array.isArray(body?.shares) ? body.shares : [];
4156
+ }
4157
+
4158
+ async function reconcileGuestDaemons() {
4159
+ if (guestReconcileInFlight || daemonShuttingDown) return;
4160
+ guestReconcileInFlight = true;
4161
+ try {
4162
+ const shares = await fetchActiveShares();
4163
+ if (shares === null) return;
4164
+ const running = new Set(guestDaemons.keys());
4165
+ const { start, stop, skipped } = reconcileGuests(shares, running, MAX_GUEST_DAEMONS);
4166
+ for (const shareId of stop) {
4167
+ stopGuestDaemon(shareId, "share no longer active");
4168
+ }
4169
+ for (const share of start) {
4170
+ spawnGuestDaemon(share);
4171
+ }
4172
+ if (skipped > 0) {
4173
+ // Never drop guests silently -- a share that looks accepted in the UI
4174
+ // but never runs is the hardest kind of bug to report.
4175
+ log(
4176
+ `[guest] ${skipped} share(s) not started: at the ${MAX_GUEST_DAEMONS}-guest limit for this daemon`
4177
+ );
4178
+ }
4179
+ } catch (error) {
4180
+ logError(`[guest] Failed to reconcile guest daemons: ${error?.message || error}`);
4181
+ } finally {
4182
+ guestReconcileInFlight = false;
4183
+ }
4184
+ }
4185
+
3613
4186
  async function handleRestartDaemon(payload) {
3614
4187
  const requestId = payload?.request_id ? String(payload.request_id) : "";
3615
4188
  const targetVersionRaw = payload?.target_version
@@ -3635,6 +4208,22 @@ export function startDaemon(config = {}, deps = {}) {
3635
4208
  return;
3636
4209
  }
3637
4210
 
4211
+ // RFC 0035: a versioned restart runs a *global* `npm install -g`, which
4212
+ // would swap the CLI binary out from under the machine owner's own daemon
4213
+ // and every one of their fire processes. A guest may restart itself, but
4214
+ // it may not change the machine's installed version.
4215
+ if (IS_GUEST_DAEMON && !isGuestRestartAllowed(payload)) {
4216
+ log(
4217
+ `[restart_daemon] Refused (${requestId}): guest daemons cannot install version ${targetVersionRaw}`
4218
+ );
4219
+ sendAgentCommandAck({
4220
+ requestId,
4221
+ eventType: "restart_daemon",
4222
+ accepted: false,
4223
+ }).catch(() => {});
4224
+ return;
4225
+ }
4226
+
3638
4227
  autoUpdateInProgress = true;
3639
4228
  try {
3640
4229
  log(
@@ -4242,89 +4831,12 @@ export function startDaemon(config = {}, deps = {}) {
4242
4831
  }
4243
4832
 
4244
4833
  function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
4245
- const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
4246
- const entrypointType =
4247
- normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
4248
- normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
4249
- (normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
4250
- normalizeOptionalString(normalizedLaunchConfig.toolPreset)
4251
- ? "tool_preset"
4252
- : "shell");
4253
- const preferredShell = resolveDefaultPtyShell({
4254
- explicitShell: normalizedLaunchConfig.shell,
4255
- envShell: process.env.SHELL,
4256
- comspec: process.env.COMSPEC,
4257
- platform: process.platform,
4834
+ return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
4835
+ allowCliList: ALLOW_CLI_LIST,
4836
+ supportedBackends: SUPPORTED_BACKENDS,
4258
4837
  existsSync: existsSyncFn,
4838
+ log,
4259
4839
  });
4260
- const cwd =
4261
- normalizeOptionalString(normalizedLaunchConfig.cwd) ||
4262
- fallbackCwd;
4263
- const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
4264
- const cols = normalizePositiveInt(
4265
- normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
4266
- DEFAULT_TERMINAL_COLS,
4267
- );
4268
- const rows = normalizePositiveInt(
4269
- normalizedLaunchConfig.rows,
4270
- DEFAULT_TERMINAL_ROWS,
4271
- );
4272
-
4273
- if (entrypointType === "tool_preset") {
4274
- const toolPreset =
4275
- normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
4276
- normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
4277
- SUPPORTED_BACKENDS[0] ||
4278
- "codex";
4279
- const cliCommand = ALLOW_CLI_LIST[toolPreset];
4280
- if (!cliCommand) {
4281
- throw new Error(`Unsupported tool preset: ${toolPreset}`);
4282
- }
4283
- return {
4284
- entrypointType,
4285
- toolPreset,
4286
- command: preferredShell,
4287
- args: ["-lc", cliCommand],
4288
- shell: preferredShell,
4289
- cwd,
4290
- env,
4291
- cols,
4292
- rows,
4293
- };
4294
- }
4295
-
4296
- if (entrypointType === "custom") {
4297
- const command = normalizeOptionalString(normalizedLaunchConfig.command);
4298
- if (!command) {
4299
- throw new Error("launch_config.command is required for custom entrypoint");
4300
- }
4301
- const args = Array.isArray(normalizedLaunchConfig.args)
4302
- ? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
4303
- : [];
4304
- return {
4305
- entrypointType,
4306
- toolPreset: null,
4307
- command,
4308
- args,
4309
- shell: preferredShell,
4310
- cwd,
4311
- env,
4312
- cols,
4313
- rows,
4314
- };
4315
- }
4316
-
4317
- return {
4318
- entrypointType: "shell",
4319
- toolPreset: null,
4320
- command: preferredShell,
4321
- args: ["-l"],
4322
- shell: preferredShell,
4323
- cwd,
4324
- env,
4325
- cols,
4326
- rows,
4327
- };
4328
4840
  }
4329
4841
 
4330
4842
  function getTerminalChunkByteLength(data) {
@@ -4840,6 +5352,10 @@ export function startDaemon(config = {}, deps = {}) {
4840
5352
  { cwd: worktreeConfig.projectRepoRoot },
4841
5353
  );
4842
5354
  }
5355
+ // The marker lives outside the worktree, so `git worktree remove` does
5356
+ // not take it with the directory. Leaving it behind would let a worktree
5357
+ // later recreated on this path look ready before it is prepared.
5358
+ removeTaskWorktreeReadyMarker(worktreeRoot);
4843
5359
 
4844
5360
  await reportTaskWorktreeCleanupResult({
4845
5361
  requestId,
@@ -5211,6 +5727,26 @@ export function startDaemon(config = {}, deps = {}) {
5211
5727
  return;
5212
5728
  }
5213
5729
  if (event.type === "ai_manager_request") {
5730
+ // RFC 0035: reads are fine and genuinely useful to a guest (it needs to
5731
+ // know how much quota the borrowed machine has left). `switch_account` is
5732
+ // not: `AiManager` resolves the codex auth path from `homedir()`, so a
5733
+ // guest switching accounts renames over `~/.codex/auth.json` and changes
5734
+ // the machine owner's own daemon, running fires, and interactive `codex`.
5735
+ const aiAction = event.payload?.action;
5736
+ if (IS_GUEST_DAEMON && !isGuestAiManagerActionAllowed(aiAction)) {
5737
+ log(`[guest] Refused ai_manager action '${aiAction}' on a shared daemon`);
5738
+ client
5739
+ .send({
5740
+ type: "ai_manager_response",
5741
+ payload: {
5742
+ request_id: event.payload?.request_id,
5743
+ error: "Account switching is disabled on a shared daemon",
5744
+ error_code: "guest_forbidden",
5745
+ },
5746
+ })
5747
+ .catch(() => {});
5748
+ return;
5749
+ }
5214
5750
  handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
5215
5751
  logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
5216
5752
  });
@@ -5333,6 +5869,7 @@ export function startDaemon(config = {}, deps = {}) {
5333
5869
  async function handleValidateProjectPath(payload) {
5334
5870
  const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
5335
5871
  const rawWorkspacePath = payload?.workspace_path ? String(payload.workspace_path).trim() : "";
5872
+ const createIfMissing = payload?.create_if_missing === true;
5336
5873
  const validatedAt = new Date().toISOString();
5337
5874
 
5338
5875
  if (!requestId || !rawWorkspacePath) {
@@ -5340,6 +5877,27 @@ export function startDaemon(config = {}, deps = {}) {
5340
5877
  return;
5341
5878
  }
5342
5879
 
5880
+ // RFC 0035: keep a guest inside the directory its owner set aside. This is
5881
+ // misuse prevention, not a security boundary -- the guest's AI agent runs a
5882
+ // shell and can reach anything the OS user can. It matters because neither
5883
+ // handler is otherwise bounded: this one will `mkdirSync(recursive)` any
5884
+ // absolute path it is handed.
5885
+ if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
5886
+ await client.sendJson({
5887
+ type: "project_path_validated",
5888
+ payload: {
5889
+ request_id: requestId,
5890
+ daemon_host: AGENT_NAME,
5891
+ workspace_path: rawWorkspacePath,
5892
+ error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
5893
+ error_code: "outside_guest_root",
5894
+ validated_at: new Date().toISOString(),
5895
+ },
5896
+ });
5897
+ return;
5898
+ }
5899
+
5900
+
5343
5901
  let result = {
5344
5902
  workspacePath: null,
5345
5903
  repoRoot: null,
@@ -5356,7 +5914,22 @@ export function startDaemon(config = {}, deps = {}) {
5356
5914
 
5357
5915
  try {
5358
5916
  const resolvedPath = path.resolve(rawWorkspacePath);
5359
- if (!existsSyncFn(resolvedPath)) {
5917
+ let createError = null;
5918
+ if (createIfMissing && !existsSyncFn(resolvedPath)) {
5919
+ try {
5920
+ mkdirSyncFn(resolvedPath, { recursive: true });
5921
+ log(`Created workspace path on request: ${resolvedPath}`);
5922
+ } catch (error) {
5923
+ createError = error;
5924
+ }
5925
+ }
5926
+ if (createError) {
5927
+ result = {
5928
+ ...result,
5929
+ error: `Failed to create workspace path on daemon ${AGENT_NAME}: ${createError?.message || createError}`,
5930
+ errorCode: "workspace_create_failed",
5931
+ };
5932
+ } else if (!existsSyncFn(resolvedPath)) {
5360
5933
  result = {
5361
5934
  ...result,
5362
5935
  error: `Workspace path does not exist on daemon ${AGENT_NAME}: ${rawWorkspacePath}`,
@@ -5449,6 +6022,27 @@ export function startDaemon(config = {}, deps = {}) {
5449
6022
  return;
5450
6023
  }
5451
6024
 
6025
+ // RFC 0035: keep a guest inside the directory its owner set aside. This is
6026
+ // misuse prevention, not a security boundary -- the guest's AI agent runs a
6027
+ // shell and can reach anything the OS user can. It matters because neither
6028
+ // handler is otherwise bounded: this one will `mkdirSync(recursive)` any
6029
+ // absolute path it is handed.
6030
+ if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
6031
+ await client.sendJson({
6032
+ type: "project_agents_resolved",
6033
+ payload: {
6034
+ request_id: requestId,
6035
+ daemon_host: AGENT_NAME,
6036
+ workspace_path: rawWorkspacePath,
6037
+ error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
6038
+ error_code: "outside_guest_root",
6039
+ resolved_at: new Date().toISOString(),
6040
+ },
6041
+ });
6042
+ return;
6043
+ }
6044
+
6045
+
5452
6046
  let result = {
5453
6047
  workspacePath: null,
5454
6048
  agents: [],