@love-moon/conductor-cli 0.10.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/CHANGELOG.md +102 -0
- package/bin/conductor-config.js +127 -12
- package/bin/conductor-daemon.js +53 -29
- package/bin/conductor-project.js +3 -1
- package/package.json +5 -5
- package/src/daemon-lock.js +240 -0
- package/src/daemon.js +644 -123
- package/src/guest-daemon.js +268 -0
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;
|
|
@@ -493,6 +523,18 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
|
|
|
493
523
|
return { helperPath, updated: true };
|
|
494
524
|
}
|
|
495
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
|
+
|
|
496
538
|
export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
|
|
497
539
|
const normalizedWorkspacePath =
|
|
498
540
|
typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
|
|
@@ -675,6 +717,12 @@ function parseTaskWorktreeLaunchConfig(launchConfig) {
|
|
|
675
717
|
projectRepoRoot,
|
|
676
718
|
projectWorkspacePath,
|
|
677
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
|
+
),
|
|
678
726
|
};
|
|
679
727
|
}
|
|
680
728
|
|
|
@@ -772,6 +820,106 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
|
|
|
772
820
|
};
|
|
773
821
|
}
|
|
774
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
|
+
|
|
775
923
|
export function startDaemon(config = {}, deps = {}) {
|
|
776
924
|
const exitFn = deps.exit || process.exit;
|
|
777
925
|
const killFn = deps.kill || process.kill;
|
|
@@ -881,6 +1029,24 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
881
1029
|
// warning and silently fall back to direct spawn rather than failing every
|
|
882
1030
|
// create_task with ENOENT.
|
|
883
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`.
|
|
884
1050
|
const remoteExecEnabled = getRemoteExecEnabled(userConfig);
|
|
885
1051
|
|
|
886
1052
|
// Get allow_cli_list from config
|
|
@@ -911,6 +1077,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
911
1077
|
readFileSync: deps.readFileSync || fs.readFileSync,
|
|
912
1078
|
}));
|
|
913
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
|
+
});
|
|
914
1091
|
const lockHandoffToken =
|
|
915
1092
|
normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
|
|
916
1093
|
normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
|
|
@@ -2453,6 +2630,79 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2453
2630
|
});
|
|
2454
2631
|
}
|
|
2455
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
|
+
|
|
2456
2706
|
async function ensureTaskWorktree({ taskId, projectId, launchConfig }) {
|
|
2457
2707
|
const worktreeConfig = parseTaskWorktreeLaunchConfig(launchConfig);
|
|
2458
2708
|
if (!worktreeConfig) {
|
|
@@ -2465,7 +2715,50 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2465
2715
|
);
|
|
2466
2716
|
const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
|
|
2467
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
|
+
}) {
|
|
2468
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);
|
|
2469
2762
|
const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
|
|
2470
2763
|
if (syncBranch) {
|
|
2471
2764
|
try {
|
|
@@ -2563,7 +2856,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2563
2856
|
projectWorkspacePath: worktreeConfig.projectWorkspacePath,
|
|
2564
2857
|
finalCwd,
|
|
2565
2858
|
});
|
|
2566
|
-
|
|
2859
|
+
// Publish readiness last: every step above has to have succeeded.
|
|
2860
|
+
writeFileSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot), "");
|
|
2567
2861
|
}
|
|
2568
2862
|
|
|
2569
2863
|
const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
|
|
@@ -2596,6 +2890,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2596
2890
|
process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
|
|
2597
2891
|
120_000,
|
|
2598
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
|
+
);
|
|
2599
2904
|
const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
|
|
2600
2905
|
process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
|
|
2601
2906
|
1000,
|
|
@@ -2637,36 +2942,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2637
2942
|
DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
|
|
2638
2943
|
);
|
|
2639
2944
|
|
|
2640
|
-
const readLockState = () =>
|
|
2641
|
-
const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
|
|
2642
|
-
if (!raw) {
|
|
2643
|
-
return null;
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
const pid = Number.parseInt(raw, 10);
|
|
2647
|
-
if (!Number.isNaN(pid) && pid > 0) {
|
|
2648
|
-
return {
|
|
2649
|
-
pid,
|
|
2650
|
-
handoffFromPid: null,
|
|
2651
|
-
handoffToken: null,
|
|
2652
|
-
handoffExpiresAt: null,
|
|
2653
|
-
};
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
try {
|
|
2657
|
-
const parsed = JSON.parse(raw);
|
|
2658
|
-
const parsedPid = normalizePositiveInt(parsed?.pid, null);
|
|
2659
|
-
const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
|
|
2660
|
-
return {
|
|
2661
|
-
pid: parsedPid ?? parsedHandoffFromPid,
|
|
2662
|
-
handoffFromPid: parsedHandoffFromPid,
|
|
2663
|
-
handoffToken: normalizeOptionalString(parsed?.handoff_token),
|
|
2664
|
-
handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
|
|
2665
|
-
};
|
|
2666
|
-
} catch {
|
|
2667
|
-
return null;
|
|
2668
|
-
}
|
|
2669
|
-
};
|
|
2945
|
+
const readLockState = () => parseDaemonLockState(readFileSyncFn(LOCK_FILE, "utf-8"));
|
|
2670
2946
|
|
|
2671
2947
|
const hasMatchingLockHandoff = (lockState) => {
|
|
2672
2948
|
if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
|
|
@@ -2705,7 +2981,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2705
2981
|
return exitAndReturn(1);
|
|
2706
2982
|
}
|
|
2707
2983
|
|
|
2708
|
-
const LOCK_FILE = path.join(WORKSPACE_ROOT,
|
|
2984
|
+
const LOCK_FILE = path.join(WORKSPACE_ROOT, DAEMON_LOCK_FILE_NAME);
|
|
2709
2985
|
try {
|
|
2710
2986
|
if (skipPidLockCheck) {
|
|
2711
2987
|
log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
|
|
@@ -2722,6 +2998,21 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2722
2998
|
const alive = isProcessAlive(pid);
|
|
2723
2999
|
if (alive) {
|
|
2724
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
|
+
}
|
|
2725
3016
|
log(`Force enabled: stopping existing daemon PID ${pid}`);
|
|
2726
3017
|
let alreadyExited = false;
|
|
2727
3018
|
try {
|
|
@@ -2763,7 +3054,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2763
3054
|
unlinkSyncFn(LOCK_FILE);
|
|
2764
3055
|
}
|
|
2765
3056
|
} else {
|
|
2766
|
-
logError(
|
|
3057
|
+
logError(
|
|
3058
|
+
`Daemon already running with PID ${pid} — ${describeDaemonLockOwner(lockState)}. Use --force to restart it.`,
|
|
3059
|
+
);
|
|
2767
3060
|
return exitAndReturn(1);
|
|
2768
3061
|
}
|
|
2769
3062
|
} else {
|
|
@@ -2784,7 +3077,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2784
3077
|
unlinkSyncFn(LOCK_FILE);
|
|
2785
3078
|
}
|
|
2786
3079
|
}
|
|
2787
|
-
writeFileSyncFn(
|
|
3080
|
+
writeFileSyncFn(
|
|
3081
|
+
LOCK_FILE,
|
|
3082
|
+
serializeDaemonLock({ pid: process.pid, identity: daemonInstanceIdentity }),
|
|
3083
|
+
);
|
|
2788
3084
|
}
|
|
2789
3085
|
} catch (err) {
|
|
2790
3086
|
logError("Failed to acquire lock:", err);
|
|
@@ -2811,13 +3107,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2811
3107
|
if (skipPidLockCheck) {
|
|
2812
3108
|
return;
|
|
2813
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.
|
|
2814
3113
|
writeFileSyncFn(
|
|
2815
3114
|
LOCK_FILE,
|
|
2816
|
-
|
|
3115
|
+
serializeDaemonLock({
|
|
2817
3116
|
pid: handoffFromPid,
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
handoff_expires_at: handoffExpiresAt,
|
|
3117
|
+
identity: daemonInstanceIdentity,
|
|
3118
|
+
handoff: { handoffFromPid, handoffToken, handoffExpiresAt },
|
|
2821
3119
|
}),
|
|
2822
3120
|
);
|
|
2823
3121
|
};
|
|
@@ -2895,6 +3193,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2895
3193
|
removeProcessListener("unhandledRejection", onUnhandledRejection);
|
|
2896
3194
|
};
|
|
2897
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
|
+
|
|
2898
3208
|
process.on("exit", cleanupLock);
|
|
2899
3209
|
process.on("SIGINT", onSigInt);
|
|
2900
3210
|
process.on("SIGTERM", onSigTerm);
|
|
@@ -3022,6 +3332,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3022
3332
|
};
|
|
3023
3333
|
const advertisedCapabilities = [
|
|
3024
3334
|
"project_path_validation",
|
|
3335
|
+
"project_path_create",
|
|
3025
3336
|
"project_agents_registry",
|
|
3026
3337
|
"restart_daemon",
|
|
3027
3338
|
"refresh_session_inplace",
|
|
@@ -3036,10 +3347,31 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3036
3347
|
if (ptyTaskCapabilityEnabled) {
|
|
3037
3348
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
3038
3349
|
}
|
|
3039
|
-
|
|
3040
|
-
|
|
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(",");
|
|
3041
3373
|
}
|
|
3042
|
-
const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
|
|
3374
|
+
const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
|
|
3043
3375
|
const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
|
|
3044
3376
|
const remoteExecHandlers = remoteExecEnabled
|
|
3045
3377
|
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
@@ -3173,6 +3505,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3173
3505
|
logError(`Failed to connect: ${err}`);
|
|
3174
3506
|
});
|
|
3175
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
|
+
|
|
3176
3514
|
if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
|
|
3177
3515
|
if (installMethod === "homebrew") {
|
|
3178
3516
|
log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
|
|
@@ -3183,6 +3521,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3183
3521
|
|
|
3184
3522
|
const runMaintenanceTick = async () => {
|
|
3185
3523
|
void runDaemonWatchdog();
|
|
3524
|
+
if (!IS_GUEST_DAEMON) {
|
|
3525
|
+
void reconcileGuestDaemons();
|
|
3526
|
+
}
|
|
3186
3527
|
try {
|
|
3187
3528
|
await checkForUpdate();
|
|
3188
3529
|
} catch {
|
|
@@ -3683,6 +4024,165 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3683
4024
|
await restartDaemonProcess("auto-update");
|
|
3684
4025
|
}
|
|
3685
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
|
+
|
|
3686
4186
|
async function handleRestartDaemon(payload) {
|
|
3687
4187
|
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
3688
4188
|
const targetVersionRaw = payload?.target_version
|
|
@@ -3708,6 +4208,22 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3708
4208
|
return;
|
|
3709
4209
|
}
|
|
3710
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
|
+
|
|
3711
4227
|
autoUpdateInProgress = true;
|
|
3712
4228
|
try {
|
|
3713
4229
|
log(
|
|
@@ -4315,89 +4831,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4315
4831
|
}
|
|
4316
4832
|
|
|
4317
4833
|
function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
|
|
4322
|
-
(normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
4323
|
-
normalizeOptionalString(normalizedLaunchConfig.toolPreset)
|
|
4324
|
-
? "tool_preset"
|
|
4325
|
-
: "shell");
|
|
4326
|
-
const preferredShell = resolveDefaultPtyShell({
|
|
4327
|
-
explicitShell: normalizedLaunchConfig.shell,
|
|
4328
|
-
envShell: process.env.SHELL,
|
|
4329
|
-
comspec: process.env.COMSPEC,
|
|
4330
|
-
platform: process.platform,
|
|
4834
|
+
return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
|
|
4835
|
+
allowCliList: ALLOW_CLI_LIST,
|
|
4836
|
+
supportedBackends: SUPPORTED_BACKENDS,
|
|
4331
4837
|
existsSync: existsSyncFn,
|
|
4838
|
+
log,
|
|
4332
4839
|
});
|
|
4333
|
-
const cwd =
|
|
4334
|
-
normalizeOptionalString(normalizedLaunchConfig.cwd) ||
|
|
4335
|
-
fallbackCwd;
|
|
4336
|
-
const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
|
|
4337
|
-
const cols = normalizePositiveInt(
|
|
4338
|
-
normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
|
|
4339
|
-
DEFAULT_TERMINAL_COLS,
|
|
4340
|
-
);
|
|
4341
|
-
const rows = normalizePositiveInt(
|
|
4342
|
-
normalizedLaunchConfig.rows,
|
|
4343
|
-
DEFAULT_TERMINAL_ROWS,
|
|
4344
|
-
);
|
|
4345
|
-
|
|
4346
|
-
if (entrypointType === "tool_preset") {
|
|
4347
|
-
const toolPreset =
|
|
4348
|
-
normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
4349
|
-
normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
|
|
4350
|
-
SUPPORTED_BACKENDS[0] ||
|
|
4351
|
-
"codex";
|
|
4352
|
-
const cliCommand = ALLOW_CLI_LIST[toolPreset];
|
|
4353
|
-
if (!cliCommand) {
|
|
4354
|
-
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
4355
|
-
}
|
|
4356
|
-
return {
|
|
4357
|
-
entrypointType,
|
|
4358
|
-
toolPreset,
|
|
4359
|
-
command: preferredShell,
|
|
4360
|
-
args: ["-lc", cliCommand],
|
|
4361
|
-
shell: preferredShell,
|
|
4362
|
-
cwd,
|
|
4363
|
-
env,
|
|
4364
|
-
cols,
|
|
4365
|
-
rows,
|
|
4366
|
-
};
|
|
4367
|
-
}
|
|
4368
|
-
|
|
4369
|
-
if (entrypointType === "custom") {
|
|
4370
|
-
const command = normalizeOptionalString(normalizedLaunchConfig.command);
|
|
4371
|
-
if (!command) {
|
|
4372
|
-
throw new Error("launch_config.command is required for custom entrypoint");
|
|
4373
|
-
}
|
|
4374
|
-
const args = Array.isArray(normalizedLaunchConfig.args)
|
|
4375
|
-
? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
|
|
4376
|
-
: [];
|
|
4377
|
-
return {
|
|
4378
|
-
entrypointType,
|
|
4379
|
-
toolPreset: null,
|
|
4380
|
-
command,
|
|
4381
|
-
args,
|
|
4382
|
-
shell: preferredShell,
|
|
4383
|
-
cwd,
|
|
4384
|
-
env,
|
|
4385
|
-
cols,
|
|
4386
|
-
rows,
|
|
4387
|
-
};
|
|
4388
|
-
}
|
|
4389
|
-
|
|
4390
|
-
return {
|
|
4391
|
-
entrypointType: "shell",
|
|
4392
|
-
toolPreset: null,
|
|
4393
|
-
command: preferredShell,
|
|
4394
|
-
args: ["-l"],
|
|
4395
|
-
shell: preferredShell,
|
|
4396
|
-
cwd,
|
|
4397
|
-
env,
|
|
4398
|
-
cols,
|
|
4399
|
-
rows,
|
|
4400
|
-
};
|
|
4401
4840
|
}
|
|
4402
4841
|
|
|
4403
4842
|
function getTerminalChunkByteLength(data) {
|
|
@@ -4913,6 +5352,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4913
5352
|
{ cwd: worktreeConfig.projectRepoRoot },
|
|
4914
5353
|
);
|
|
4915
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);
|
|
4916
5359
|
|
|
4917
5360
|
await reportTaskWorktreeCleanupResult({
|
|
4918
5361
|
requestId,
|
|
@@ -5284,6 +5727,26 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5284
5727
|
return;
|
|
5285
5728
|
}
|
|
5286
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
|
+
}
|
|
5287
5750
|
handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
|
|
5288
5751
|
logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
|
|
5289
5752
|
});
|
|
@@ -5406,6 +5869,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5406
5869
|
async function handleValidateProjectPath(payload) {
|
|
5407
5870
|
const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
|
|
5408
5871
|
const rawWorkspacePath = payload?.workspace_path ? String(payload.workspace_path).trim() : "";
|
|
5872
|
+
const createIfMissing = payload?.create_if_missing === true;
|
|
5409
5873
|
const validatedAt = new Date().toISOString();
|
|
5410
5874
|
|
|
5411
5875
|
if (!requestId || !rawWorkspacePath) {
|
|
@@ -5413,6 +5877,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5413
5877
|
return;
|
|
5414
5878
|
}
|
|
5415
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
|
+
|
|
5416
5901
|
let result = {
|
|
5417
5902
|
workspacePath: null,
|
|
5418
5903
|
repoRoot: null,
|
|
@@ -5429,7 +5914,22 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5429
5914
|
|
|
5430
5915
|
try {
|
|
5431
5916
|
const resolvedPath = path.resolve(rawWorkspacePath);
|
|
5432
|
-
|
|
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)) {
|
|
5433
5933
|
result = {
|
|
5434
5934
|
...result,
|
|
5435
5935
|
error: `Workspace path does not exist on daemon ${AGENT_NAME}: ${rawWorkspacePath}`,
|
|
@@ -5522,6 +6022,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5522
6022
|
return;
|
|
5523
6023
|
}
|
|
5524
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
|
+
|
|
5525
6046
|
let result = {
|
|
5526
6047
|
workspacePath: null,
|
|
5527
6048
|
agents: [],
|