omp-conductor 0.4.5 → 0.5.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/README.md +41 -278
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +0 -56
- package/skills/conductor-update/SKILL.md +4 -9
- package/src/briefs/orchestrator.md +13 -19
- package/src/briefs/worker.md +18 -16
- package/src/cli.ts +1 -48
- package/src/config.ts +7 -224
- package/src/confinement.ts +2 -3
- package/src/daemon.ts +23 -477
- package/src/failure-class.ts +6 -1
- package/src/fleet.ts +1 -85
- package/src/gitops.ts +189 -0
- package/src/omp.ts +11 -35
- package/src/orchestrator.ts +0 -4
- package/src/plugin.ts +0 -64
- package/src/session-host.ts +12 -19
- package/src/setup-host.ts +2 -192
- package/src/setup.ts +6 -81
- package/src/store.ts +34 -0
- package/src/tracker/github.ts +1 -1
- package/src/types.ts +0 -90
- package/src/upgrade.ts +0 -90
- package/src/verbs/actions.ts +1 -1
- package/src/verbs/server.ts +3 -28
- package/src/verbs/socket.ts +15 -23
- package/src/worker.ts +1 -13
- package/src/worktree.ts +4 -4
- package/systemd/omp-conductor.service.example +8 -102
- package/src/credentials.ts +0 -2203
package/src/daemon.ts
CHANGED
|
@@ -14,12 +14,9 @@ import {
|
|
|
14
14
|
configPath,
|
|
15
15
|
findProject,
|
|
16
16
|
loadConfig,
|
|
17
|
-
migrateCredentialsOnDisk,
|
|
18
17
|
resolveCaps,
|
|
19
|
-
resolveCredentials,
|
|
20
18
|
resolveReleaseGrants,
|
|
21
19
|
stateDir,
|
|
22
|
-
sharedRoot,
|
|
23
20
|
} from "./config.ts";
|
|
24
21
|
import {
|
|
25
22
|
analyseSettlement,
|
|
@@ -49,7 +46,6 @@ import type {
|
|
|
49
46
|
Escalation,
|
|
50
47
|
OpenCloser,
|
|
51
48
|
PrState,
|
|
52
|
-
CredentialIsolation,
|
|
53
49
|
ProjectConfig,
|
|
54
50
|
ReadyIssue,
|
|
55
51
|
RepoTarget,
|
|
@@ -64,7 +60,6 @@ import type {
|
|
|
64
60
|
Tracker,
|
|
65
61
|
VerbLedgerEntry,
|
|
66
62
|
} from "./types.ts";
|
|
67
|
-
import { CONDUCTOR_GROUPS, type IsolationMechanism } from "./types.ts";
|
|
68
63
|
import { type KilledBy, type WorkerResult, renderBrief, runWorker } from "./worker.ts";
|
|
69
64
|
import {
|
|
70
65
|
addRunRepo,
|
|
@@ -90,31 +85,7 @@ import {
|
|
|
90
85
|
} from "./verbs/socket.ts";
|
|
91
86
|
import { homedir } from "node:os";
|
|
92
87
|
|
|
93
|
-
import {
|
|
94
|
-
applyRunOwnership,
|
|
95
|
-
applyMirrorReadAccess,
|
|
96
|
-
auditMcpRoots,
|
|
97
|
-
mirrorTraversalRefusal,
|
|
98
|
-
credentialReadRefusal,
|
|
99
|
-
spawnCaptured,
|
|
100
|
-
boundaryRefusal,
|
|
101
|
-
buildSessionBoundary,
|
|
102
|
-
createSlotPool,
|
|
103
|
-
describeBoundary,
|
|
104
|
-
liveDaemonCredentials,
|
|
105
|
-
mcpRefusal,
|
|
106
|
-
mechanismSatisfies,
|
|
107
|
-
openRunPr,
|
|
108
|
-
probeHost,
|
|
109
|
-
pushRunBranch,
|
|
110
|
-
resolveModelCredential,
|
|
111
|
-
verifyDaemonAccess,
|
|
112
|
-
type HostProbe,
|
|
113
|
-
type ModelCredential,
|
|
114
|
-
type RunRepoRef,
|
|
115
|
-
type SessionBoundary,
|
|
116
|
-
type SlotPool,
|
|
117
|
-
} from "./credentials.ts";
|
|
88
|
+
import { pushRunBranch, type RunRepoRef } from "./gitops.ts";
|
|
118
89
|
import {
|
|
119
90
|
planUsageLine,
|
|
120
91
|
readPlanUsage,
|
|
@@ -152,44 +123,9 @@ export interface DaemonOpts {
|
|
|
152
123
|
|
|
153
124
|
/** Everything one tick touches, resolved once at startup so a tick never
|
|
154
125
|
* re-reads config mid-flight and changes its own limits underneath itself. */
|
|
155
|
-
/**
|
|
156
|
-
* The credential boundary this fleet is running behind (#125), resolved once at
|
|
157
|
-
* startup like every other dep so a tick cannot change its own protection.
|
|
158
|
-
*
|
|
159
|
-
* `paged` is the same one-page-per-episode gate the integrity tripwire uses: a
|
|
160
|
-
* host that cannot build the boundary the operator asked for holds *every*
|
|
161
|
-
* candidate on *every* tick, and paging per five minutes forever is paging
|
|
162
|
-
* nobody reads.
|
|
163
|
-
*/
|
|
164
|
-
export interface FleetBoundary {
|
|
165
|
-
isolation: CredentialIsolation;
|
|
166
|
-
probe: HostProbe;
|
|
167
|
-
slots: SlotPool;
|
|
168
|
-
paged: boolean;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* True when the operator asked for a boundary this host cannot build.
|
|
173
|
-
*
|
|
174
|
-
* Asks {@link mechanismSatisfies} rather than testing for `none`, because the
|
|
175
|
-
* interesting refusal is the near miss: a host offering `group-mode` against a
|
|
176
|
-
* `per-run` config used to dispatch happily under the weaker boundary, which is
|
|
177
|
-
* the silent downgrade #125 forbids.
|
|
178
|
-
*/
|
|
179
|
-
export function boundaryRefusesDispatch(boundary: FleetBoundary | undefined): boolean {
|
|
180
|
-
return boundary !== undefined && !mechanismSatisfies(boundary.isolation, boundary.probe.mechanism);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
126
|
interface Deps {
|
|
184
127
|
project: ProjectConfig;
|
|
185
128
|
caps: Caps;
|
|
186
|
-
/**
|
|
187
|
-
* Optional only so a test can build a `Deps` without a host probe; production
|
|
188
|
-
* always resolves one in `runDaemon`. Absent means the A1 shape — sessions
|
|
189
|
-
* are still child processes, but they run as the daemon's own user and no
|
|
190
|
-
* security claim is made for them.
|
|
191
|
-
*/
|
|
192
|
-
boundary?: FleetBoundary;
|
|
193
129
|
tracker: Tracker;
|
|
194
130
|
store: Store;
|
|
195
131
|
/** Provider-reported plan allowance, cached with a TTL. Resolved once at
|
|
@@ -200,17 +136,6 @@ interface Deps {
|
|
|
200
136
|
integrity: IntegrityGate;
|
|
201
137
|
stall: StallGate;
|
|
202
138
|
cleanup?: RetainedCleanupCursor;
|
|
203
|
-
/**
|
|
204
|
-
* How sessions authenticate a model, resolved once at startup so every child of
|
|
205
|
-
* one tick agrees — and so a fleet with none is refused at admission instead of
|
|
206
|
-
* discovering it at turn 0 (#152).
|
|
207
|
-
*
|
|
208
|
-
* Optional exactly as {@link Deps.boundary} is: absent means no resolution was
|
|
209
|
-
* made, which is the shape a unit test that does not exercise this gate wants.
|
|
210
|
-
* `runDaemon` always resolves one. `paged` is the page-once latch, so a fleet
|
|
211
|
-
* held for an environment fault says so once rather than every five minutes.
|
|
212
|
-
*/
|
|
213
|
-
model?: { credential?: ModelCredential; paged: boolean };
|
|
214
139
|
/**
|
|
215
140
|
* Reads the connecting uid off a verb socket (#126). Resolved once at startup
|
|
216
141
|
* so the mechanism is logged before the first socket exists; absent on a host
|
|
@@ -682,48 +607,6 @@ export async function settleWorktree(
|
|
|
682
607
|
};
|
|
683
608
|
}
|
|
684
609
|
|
|
685
|
-
/**
|
|
686
|
-
* The probe a session gets when no boundary was resolved at all — a test, or a
|
|
687
|
-
* `--once` run built without one. Named rather than inlined so the "no
|
|
688
|
-
* mechanism, no claim" shape is one object every call site shares.
|
|
689
|
-
*/
|
|
690
|
-
const NO_BOUNDARY_PROBE: HostProbe = {
|
|
691
|
-
mechanism: "none",
|
|
692
|
-
reasons: ["no host probe was run"],
|
|
693
|
-
residuals: [],
|
|
694
|
-
boundingSetDropped: false,
|
|
695
|
-
slots: [],
|
|
696
|
-
};
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Credential paths the macOS profile denies outright.
|
|
700
|
-
*
|
|
701
|
-
* Derived from the operator's real home rather than from the session's
|
|
702
|
-
* redirected one: the point is that the *operator's* `gh` config and keys stay
|
|
703
|
-
* unreachable even when a session names their absolute path, which is exactly
|
|
704
|
-
* the probe `GH_CONFIG_DIR=<operator home>/.config/gh gh auth status` in #125.
|
|
705
|
-
*/
|
|
706
|
-
function credentialDenyRoots(): string[] {
|
|
707
|
-
const home = homedir();
|
|
708
|
-
return [join(home, ".ssh"), join(home, ".config", "gh"), join(home, ".gnupg"), join(home, ".aws")];
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function credentialDenyFiles(): string[] {
|
|
712
|
-
const home = homedir();
|
|
713
|
-
return [
|
|
714
|
-
join(home, ".git-credentials"),
|
|
715
|
-
join(home, ".npmrc"),
|
|
716
|
-
join(home, ".netrc"),
|
|
717
|
-
// Both git config locations, because a token does not only live in
|
|
718
|
-
// `.git-credentials`: `url.https://<token>@github.com/.insteadOf` is a
|
|
719
|
-
// perfectly ordinary way to wire one up, and an ordinary `.gitconfig` is
|
|
720
|
-
// 0644. Hardening chmods it, but hardening is not the claim — this list is
|
|
721
|
-
// what the empirical recheck actually asks the slot principal about, so a
|
|
722
|
-
// file missing from here is a credential nobody verified was out of reach.
|
|
723
|
-
join(home, ".gitconfig"),
|
|
724
|
-
join(home, ".config", "git", "config"),
|
|
725
|
-
];
|
|
726
|
-
}
|
|
727
610
|
|
|
728
611
|
/**
|
|
729
612
|
* How a run's end is named — in the salvage commit, and to whoever reads it.
|
|
@@ -962,24 +845,21 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
962
845
|
const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
|
|
963
846
|
let worktreePath: string | undefined;
|
|
964
847
|
let turnLimit: TurnLimitController | undefined;
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
// catch and finally paths have to release the slot and publish the branch.
|
|
848
|
+
// The run's own repository. Hoisted for the same reason `worktreePath` is —
|
|
849
|
+
// the catch and finally paths have to publish the branch.
|
|
968
850
|
let runRepo: RunRepoRef | undefined;
|
|
969
|
-
|
|
970
|
-
let boundary: SessionBoundary | undefined;
|
|
971
|
-
// #126: the run's own verb socket. Hoisted like the slot, because the catch
|
|
851
|
+
// #126: the run's own verb socket. Hoisted like the run repo, because the catch
|
|
972
852
|
// and finally paths have to close it — a socket outliving its run is a
|
|
973
853
|
// channel nobody is authenticating any more.
|
|
974
854
|
let verbListener: VerbListener | undefined;
|
|
975
|
-
const credentials = resolveCredentials(project);
|
|
976
855
|
|
|
977
856
|
/**
|
|
978
857
|
* Publishes the run's branch on the privileged side: run repo → mirror →
|
|
979
|
-
* GitHub, fast-forward only. The
|
|
980
|
-
*
|
|
981
|
-
* route
|
|
982
|
-
* from being the *only* copy when the
|
|
858
|
+
* GitHub, fast-forward only. The dispatcher performs every network git
|
|
859
|
+
* operation for a run so the settlement record and the branch cannot
|
|
860
|
+
* disagree (#126); this is the only route a worker's commits take out, and it
|
|
861
|
+
* is also what stops a per-run repository from being the *only* copy when the
|
|
862
|
+
* tree is removed.
|
|
983
863
|
*/
|
|
984
864
|
const publish: RunPublisher = async () => {
|
|
985
865
|
if (runRepo === undefined) return { ok: false, stderr: "the run repository was never provisioned" };
|
|
@@ -1035,126 +915,12 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1035
915
|
// directory and learns the real path back from the result. Inventing one
|
|
1036
916
|
// here would put a file that never gets written into an escalation.
|
|
1037
917
|
//
|
|
1038
|
-
// Per run rather than one shared directory
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
// reads all of them — it owns the group.
|
|
1042
|
-
//
|
|
1043
|
-
// Under `uid-pool` the session runs as a *different uid*, so every tree it
|
|
1044
|
-
// has to reach lives outside the private state directory: that one stays
|
|
1045
|
-
// 0700 because it holds conductor.db and the WAL SQLite keeps recreating,
|
|
1046
|
-
// and making it searchable to let a slot in would publish fleet history to
|
|
1047
|
-
// every local account. `sandbox-exec` and `none` keep the old paths — same
|
|
1048
|
-
// uid, nothing to traverse into (#125).
|
|
1049
|
-
const runTreeRoot = d.boundary?.probe.mechanism === "uid-pool" ? sharedRoot() : stateDir();
|
|
1050
|
-
if (runTreeRoot !== stateDir()) {
|
|
1051
|
-
mkdirSync(runTreeRoot, { recursive: true });
|
|
1052
|
-
chmodSync(runTreeRoot, 0o711);
|
|
1053
|
-
}
|
|
918
|
+
// Per run rather than one shared directory, so one run's transcript cannot
|
|
919
|
+
// be truncated or replaced by the next.
|
|
920
|
+
const runTreeRoot = stateDir();
|
|
1054
921
|
const sessionDir = join(runTreeRoot, "sessions", `run-${String(runId)}`);
|
|
1055
|
-
const envRoot = join(runTreeRoot, "boundaries", `run-${String(runId)}`);
|
|
1056
922
|
mkdirSync(sessionDir, { recursive: true });
|
|
1057
923
|
|
|
1058
|
-
// ---- the credential boundary for this run (#125) --------------------
|
|
1059
|
-
slotIndex = d.boundary?.slots.acquire();
|
|
1060
|
-
boundary = buildSessionBoundary({
|
|
1061
|
-
probe: d.boundary?.probe ?? NO_BOUNDARY_PROBE,
|
|
1062
|
-
isolation: d.boundary?.isolation ?? "none",
|
|
1063
|
-
role: "worker",
|
|
1064
|
-
...(slotIndex === undefined ? {} : { slot: slotIndex }),
|
|
1065
|
-
envRoot,
|
|
1066
|
-
writeRoots: [worktreePath, sessionDir],
|
|
1067
|
-
// The workspace root, denied wholesale and then re-granted for this run's
|
|
1068
|
-
// own checkout by the trailing allow in `sandboxProfile`. On `uid-pool`
|
|
1069
|
-
// the modes already do this; on macOS the profile is the only thing that
|
|
1070
|
-
// does, because sandbox-exec does not change the uid.
|
|
1071
|
-
//
|
|
1072
|
-
// `mirrorRoot` is deliberately NOT denied. The run repository borrows its
|
|
1073
|
-
// objects from the mirror through git alternates, so denying it would
|
|
1074
|
-
// break every git command in the checkout — and a run being able to read
|
|
1075
|
-
// another run's objects out of that shared store is the residual this
|
|
1076
|
-
// design accepts and documents, not one it pretends to have closed. The
|
|
1077
|
-
// mirror stays unwritable, because it is not in `writeRoots`.
|
|
1078
|
-
denyReadRoots: [...credentialDenyRoots(), project.workspaceRoot],
|
|
1079
|
-
denyReadFiles: credentialDenyFiles(),
|
|
1080
|
-
...(credentials.readToken === undefined ? {} : { readToken: credentials.readToken }),
|
|
1081
|
-
// Resolved from the operator's own home at startup, because the session's
|
|
1082
|
-
// redirected $HOME cannot see it (#152). Admission already refused the whole
|
|
1083
|
-
// pass when it is absent, so undefined here is a fleet not dispatching.
|
|
1084
|
-
...(d.model?.credential === undefined ? {} : { model: d.model.credential }),
|
|
1085
|
-
});
|
|
1086
|
-
|
|
1087
|
-
if (boundary.principal !== undefined) {
|
|
1088
|
-
// Ownership handoff, then proof. `applyRunOwnership` makes the tree
|
|
1089
|
-
// `<slot>:conductor-daemon 2770`, which is also the point of no return:
|
|
1090
|
-
// if this daemon is not an *effective* member of that group it has just
|
|
1091
|
-
// locked itself out of push, salvage and reclaim, and the modes look
|
|
1092
|
-
// perfectly correct while it does. So it writes, reads and removes a
|
|
1093
|
-
// probe file rather than inspecting them (#125).
|
|
1094
|
-
// Both gids come from the probe's own `getent`, never from the daemon's
|
|
1095
|
-
// `egid`. They are different groups: the shipped unit runs
|
|
1096
|
-
// `User=fleet`/`Group=fleet` with `conductor-daemon` merely
|
|
1097
|
-
// supplementary, so `egid` is `fleet` — handing that to
|
|
1098
|
-
// `applyRunOwnership` would make every run repo `<slot>:fleet`, readable
|
|
1099
|
-
// by anyone else in `fleet`, while the probe had validated a group the
|
|
1100
|
-
// modes never used. A uid-pool probe always resolves both, so their
|
|
1101
|
-
// absence here is a contradiction rather than a default to paper over.
|
|
1102
|
-
const { daemonGid, runsGid } = d.boundary?.probe ?? {};
|
|
1103
|
-
if (daemonGid === undefined || runsGid === undefined) {
|
|
1104
|
-
throw new Error(
|
|
1105
|
-
`the credential boundary reported a per-run principal without resolving ` +
|
|
1106
|
-
`${CONDUCTOR_GROUPS.daemon}/${CONDUCTOR_GROUPS.runs} gids — refusing to hand the tree to a group ` +
|
|
1107
|
-
`nobody verified.`,
|
|
1108
|
-
);
|
|
1109
|
-
}
|
|
1110
|
-
applyRunOwnership(worktreePath, boundary.principal, daemonGid);
|
|
1111
|
-
applyRunOwnership(sessionDir, boundary.principal, daemonGid);
|
|
1112
|
-
// The session's own HOME, TMPDIR and gh-config tree.
|
|
1113
|
-
// `prepareSessionEnvRoot` creates them daemon-owned 0700, so without this
|
|
1114
|
-
// the slot principal cannot write its own home and the harness fails at
|
|
1115
|
-
// session startup — which reads like a broken install rather than a
|
|
1116
|
-
// permissions handoff that was one directory short.
|
|
1117
|
-
applyRunOwnership(envRoot, boundary.principal, daemonGid);
|
|
1118
|
-
// The claim, checked rather than assumed. On Linux the principal
|
|
1119
|
-
// separates by DAC alone, so whether the operator's credentials are
|
|
1120
|
-
// actually out of reach depends on modes this package never set. Ask the
|
|
1121
|
-
// slot itself, and refuse the dispatch if the answer is yes.
|
|
1122
|
-
const leaking = await credentialReadRefusal(
|
|
1123
|
-
spawnCaptured,
|
|
1124
|
-
boundary.principal,
|
|
1125
|
-
{ boundingSet: d.boundary?.probe.boundingSetDropped ?? false },
|
|
1126
|
-
[...credentialDenyRoots(), ...credentialDenyFiles()],
|
|
1127
|
-
);
|
|
1128
|
-
if (leaking !== undefined) throw new Error(leaking);
|
|
1129
|
-
// The other half of the layout, and it is not optional: the run's git dir
|
|
1130
|
-
// borrows the mirror's objects through `alternates`, so a mirror the slot
|
|
1131
|
-
// principal cannot read makes the checkout look corrupt rather than
|
|
1132
|
-
// forbidden.
|
|
1133
|
-
//
|
|
1134
|
-
// Both trees are checked first, and the check REFUSES rather than
|
|
1135
|
-
// widening the state directory — see `mirrorTraversalRefusal`. A slot
|
|
1136
|
-
// that cannot reach its own checkout is just as broken as one that cannot
|
|
1137
|
-
// read the mirror, and the shipped defaults put both under the private
|
|
1138
|
-
// root.
|
|
1139
|
-
const mirrorPath = mirrorPathFor(r.repo, project.mirrorRoot);
|
|
1140
|
-
for (const tree of [mirrorPath, worktreePath]) {
|
|
1141
|
-
const unreachable = mirrorTraversalRefusal(tree, stateDir());
|
|
1142
|
-
if (unreachable !== undefined) throw new Error(unreachable);
|
|
1143
|
-
}
|
|
1144
|
-
applyMirrorReadAccess(mirrorPath, runsGid);
|
|
1145
|
-
const denied = verifyDaemonAccess(worktreePath);
|
|
1146
|
-
if (denied !== undefined) throw new Error(denied);
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// An MCP server carrying its own PAT re-opens the hole the principal
|
|
1150
|
-
// closes, so this is a refusal and not a warning. Audited here, after
|
|
1151
|
-
// provisioning, because both roots the harness will discover — the run's
|
|
1152
|
-
// own checkout and the agent principal's config root — exist only now.
|
|
1153
|
-
const mcp = mcpRefusal(
|
|
1154
|
-
auditMcpRoots({ agentHome: boundary.env["HOME"] ?? homedir(), cwd: worktreePath }),
|
|
1155
|
-
);
|
|
1156
|
-
if (mcp !== undefined) throw new Error(mcp);
|
|
1157
|
-
|
|
1158
924
|
// ---- the run's mutation channel (#126) -------------------------------
|
|
1159
925
|
// A shared, daemon-owned 0711 parent with one 0600 socket per run, never a
|
|
1160
926
|
// per-run *directory*: a directory owned by the run principal would hand
|
|
@@ -1179,7 +945,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1179
945
|
repo: r.repo,
|
|
1180
946
|
runRepoPath: worktreePath,
|
|
1181
947
|
branch,
|
|
1182
|
-
...(boundary.principal === undefined ? {} : { principal: boundary.principal }),
|
|
1183
948
|
},
|
|
1184
949
|
{ ...(d.verbPeerReader === undefined ? {} : { peerReader: d.verbPeerReader }) },
|
|
1185
950
|
);
|
|
@@ -1205,13 +970,9 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1205
970
|
caps,
|
|
1206
971
|
maxTurns: () => turnLimit?.maxTurns() ?? caps.workerMaxTurns,
|
|
1207
972
|
sessionDir,
|
|
1208
|
-
|
|
1209
|
-
//
|
|
1210
|
-
|
|
1211
|
-
// unreachable to the very process it exists for (#125).
|
|
1212
|
-
// Derived from the hoisted envRoot for the same reason, and because
|
|
1213
|
-
// that tree has already been handed to the slot principal.
|
|
1214
|
-
socketPath: join(envRoot, "ipc.sock"),
|
|
973
|
+
// The session's control socket, under the daemon's own state directory —
|
|
974
|
+
// a child process of the daemon reaches it directly.
|
|
975
|
+
socketPath: join(sessionDir, "ipc.sock"),
|
|
1215
976
|
verbSocketPath: verbListener.path,
|
|
1216
977
|
onChildLog: (line) => {
|
|
1217
978
|
log(`#${issue} ${line}`);
|
|
@@ -1236,14 +997,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1236
997
|
// PR verification or terminal row writes can leave stale `running` state.
|
|
1237
998
|
turnLimit?.close();
|
|
1238
999
|
turnLimit = undefined;
|
|
1239
|
-
// The slot IS the principal, so holding it past the session means the
|
|
1240
|
-
// next run could be handed the same uid while this one's tree still
|
|
1241
|
-
// exists — two live runs able to write each other's checkout, which is
|
|
1242
|
-
// the cross-run property this whole change buys.
|
|
1243
|
-
if (slotIndex !== undefined) {
|
|
1244
|
-
d.boundary?.slots.release(slotIndex);
|
|
1245
|
-
slotIndex = undefined;
|
|
1246
|
-
}
|
|
1247
1000
|
}
|
|
1248
1001
|
|
|
1249
1002
|
// A configured model the harness could not honour means this run was done by
|
|
@@ -1459,13 +1212,6 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
1459
1212
|
turnLimit = undefined;
|
|
1460
1213
|
const detail = errText(err);
|
|
1461
1214
|
log(`#${issue} errored: ${detail}`);
|
|
1462
|
-
// Released here as well: a dispatch that failed before `runWorker` never
|
|
1463
|
-
// reached the `finally` above, and a leaked slot permanently shrinks the
|
|
1464
|
-
// pool until the daemon restarts.
|
|
1465
|
-
if (slotIndex !== undefined) {
|
|
1466
|
-
d.boundary?.slots.release(slotIndex);
|
|
1467
|
-
slotIndex = undefined;
|
|
1468
|
-
}
|
|
1469
1215
|
// A crash lands anywhere, including mid-edit in a tree holding the only
|
|
1470
1216
|
// copy of real work. Nothing else on this path so much as looks at it.
|
|
1471
1217
|
const settlement =
|
|
@@ -1948,7 +1694,7 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
1948
1694
|
* that grows a field has no business breaking these tests.
|
|
1949
1695
|
*/
|
|
1950
1696
|
export async function admitCandidates(
|
|
1951
|
-
d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage"
|
|
1697
|
+
d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage">,
|
|
1952
1698
|
routed: Routed[],
|
|
1953
1699
|
slots: number,
|
|
1954
1700
|
): Promise<AdmissionPass> {
|
|
@@ -1960,73 +1706,6 @@ export async function admitCandidates(
|
|
|
1960
1706
|
holds.push({ issue, reason });
|
|
1961
1707
|
};
|
|
1962
1708
|
|
|
1963
|
-
// The fail-closed the issue asks for, and it only ever fires because the
|
|
1964
|
-
// operator asked for a boundary by name. Placed above every other gate,
|
|
1965
|
-
// including the tracker calls: a fleet that must not dispatch must not spend
|
|
1966
|
-
// API budget discovering it, and it must not claim an issue it will refuse.
|
|
1967
|
-
//
|
|
1968
|
-
// Deliberately NOT a downgrade to group-mode. That is a different, weaker
|
|
1969
|
-
// claim, and silently substituting it is how an operator ends up believing
|
|
1970
|
-
// they have a boundary they do not have (#125).
|
|
1971
|
-
if (boundaryRefusesDispatch(d.boundary) && d.boundary !== undefined) {
|
|
1972
|
-
const refusal = boundaryRefusal(d.boundary.probe);
|
|
1973
|
-
for (const r of routed) hold(r.issue.number, "credential-boundary");
|
|
1974
|
-
log(`credential boundary holding ${String(routed.length)} candidate(s): ${refusal}`);
|
|
1975
|
-
if (!d.boundary.paged) {
|
|
1976
|
-
d.boundary.paged = true;
|
|
1977
|
-
await safeEscalate(d, {
|
|
1978
|
-
tier: 2,
|
|
1979
|
-
project: project.name,
|
|
1980
|
-
issue: NO_ISSUE,
|
|
1981
|
-
summary: `${project.name} cannot dispatch: credentials.isolation is "per-run" and this host offers no mechanism`,
|
|
1982
|
-
detail: [refusal, "", "Nothing is dispatched until this is resolved. No work has been claimed."].join("\n"),
|
|
1983
|
-
});
|
|
1984
|
-
}
|
|
1985
|
-
return { admitted: [], holds };
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
// One layer down from the boundary above: that asks whether this host can run a
|
|
1989
|
-
// session as its own principal, this asks whether a session can do anything at
|
|
1990
|
-
// all once started. Every worker on the reference fleet died at turn 0 with
|
|
1991
|
-
// "No model selected" and consumed an issue attempt for it — three issues in
|
|
1992
|
-
// eleven minutes, while the fleet looked busy (#152).
|
|
1993
|
-
//
|
|
1994
|
-
// Above the tracker calls for the same reason as the boundary: a fleet that
|
|
1995
|
-
// cannot dispatch must not claim an issue it will fail, and must not spend API
|
|
1996
|
-
// budget discovering that.
|
|
1997
|
-
if (d.model !== undefined && d.model.credential === undefined) {
|
|
1998
|
-
for (const r of routed) hold(r.issue.number, "no-model-credential");
|
|
1999
|
-
log(`no model credential holding ${String(routed.length)} candidate(s)`);
|
|
2000
|
-
if (!d.model.paged) {
|
|
2001
|
-
d.model.paged = true;
|
|
2002
|
-
await safeEscalate(d, {
|
|
2003
|
-
tier: 2,
|
|
2004
|
-
project: project.name,
|
|
2005
|
-
issue: NO_ISSUE,
|
|
2006
|
-
summary: `${project.name} cannot dispatch: no model credential is reachable from a worker session`,
|
|
2007
|
-
detail: [
|
|
2008
|
-
"Sessions run with $HOME redirected into a per-run tree, so the harness never reads the",
|
|
2009
|
-
"operator's own configuration — that is what keeps a credential-bearing MCP server out of",
|
|
2010
|
-
"every session. It also means a login recorded only in ~/.omp/agent/agent.db is invisible to",
|
|
2011
|
-
"a worker, which then dies at turn 0 with \"No model selected\".",
|
|
2012
|
-
"",
|
|
2013
|
-
"Two supported shapes, either of which fixes it:",
|
|
2014
|
-
" * a model API key in the daemon's environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, …)",
|
|
2015
|
-
" * the harness's auth broker — `omp auth-broker serve` plus `omp auth-broker token`, or",
|
|
2016
|
-
" auth.broker.url / auth.broker.token in ~/.omp/agent/config.yml. Refresh tokens stay in",
|
|
2017
|
-
" the broker and each session gets a short-lived access token, which is what per-run",
|
|
2018
|
-
" isolation wants.",
|
|
2019
|
-
"",
|
|
2020
|
-
"Copying agent.db into each run is deliberately NOT how this works: the harness rotates the",
|
|
2021
|
-
"OAuth credentials in it, so a copy goes stale and can invalidate the original.",
|
|
2022
|
-
"",
|
|
2023
|
-
"Nothing is dispatched until then. No work has been claimed, and no attempt was spent.",
|
|
2024
|
-
].join("\n"),
|
|
2025
|
-
});
|
|
2026
|
-
}
|
|
2027
|
-
return { admitted: [], holds };
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
1709
|
// The plan allowance is a fleet-wide question, so it is asked once per pass
|
|
2031
1710
|
// and answers for every candidate — unlike every gate below it, which is
|
|
2032
1711
|
// per-issue. It sits here rather than beside the spend cap in `tick` for one
|
|
@@ -2492,20 +2171,6 @@ export interface DaemonHealthSnapshot {
|
|
|
2492
2171
|
rssBytes: number;
|
|
2493
2172
|
dispatch?: DispatchSummary;
|
|
2494
2173
|
codeGraph?: CodeGraphHealth;
|
|
2495
|
-
/**
|
|
2496
|
-
* The boundary THIS daemon resolved at startup.
|
|
2497
|
-
*
|
|
2498
|
-
* Published because capabilities are a property of the process, not the host:
|
|
2499
|
-
* `omp-conductor status` runs in an interactive shell with none of the unit's
|
|
2500
|
-
* ambient capabilities and no conductor groups, so re-probing there reports
|
|
2501
|
-
* `group-mode` on a fleet that is genuinely `uid-pool`. The operator's
|
|
2502
|
-
* verification step would say the boundary failed while it was working.
|
|
2503
|
-
*
|
|
2504
|
-
* It is also the only answer that survives a `daemon-reload` without a
|
|
2505
|
-
* restart: the config on disk can say anything, this is what the running
|
|
2506
|
-
* process actually got.
|
|
2507
|
-
*/
|
|
2508
|
-
boundary?: { isolation: CredentialIsolation; mechanism: IsolationMechanism; headline: string };
|
|
2509
2174
|
}
|
|
2510
2175
|
|
|
2511
2176
|
export function daemonHealthSnapshot(
|
|
@@ -2514,7 +2179,6 @@ export function daemonHealthSnapshot(
|
|
|
2514
2179
|
paused = isPaused(),
|
|
2515
2180
|
rssBytes = process.memoryUsage().rss,
|
|
2516
2181
|
codeGraph?: CodeGraphHealth,
|
|
2517
|
-
boundary?: DaemonHealthSnapshot["boundary"],
|
|
2518
2182
|
): DaemonHealthSnapshot {
|
|
2519
2183
|
const dispatch = store.latestDispatch(project);
|
|
2520
2184
|
return {
|
|
@@ -2525,7 +2189,6 @@ export function daemonHealthSnapshot(
|
|
|
2525
2189
|
rssBytes,
|
|
2526
2190
|
...(dispatch === undefined ? {} : { dispatch }),
|
|
2527
2191
|
...(codeGraph?.configured === true ? { codeGraph } : {}),
|
|
2528
|
-
...(boundary === undefined ? {} : { boundary }),
|
|
2529
2192
|
};
|
|
2530
2193
|
}
|
|
2531
2194
|
|
|
@@ -3272,47 +2935,12 @@ export async function reconcileOrphanedRuns(
|
|
|
3272
2935
|
// ------------------------------------------------------------------- the daemon
|
|
3273
2936
|
|
|
3274
2937
|
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
3275
|
-
// Before anything reads the config for real: a file written before #125 gains
|
|
3276
|
-
// an explicit `credentials.isolation` here, so the answer lives on disk
|
|
3277
|
-
// rather than being inherited from a default nobody chose. Non-fatal by
|
|
3278
|
-
// design — a config the daemon cannot rewrite still loads as `"none"` and
|
|
3279
|
-
// still dispatches, because an outage caused by a security feature is worse
|
|
3280
|
-
// than the day before it shipped.
|
|
3281
|
-
try {
|
|
3282
|
-
const migration = migrateCredentialsOnDisk();
|
|
3283
|
-
if (migration.migrated.length > 0) {
|
|
3284
|
-
log(
|
|
3285
|
-
`migrated ${migration.migrated.join(", ")} to an explicit credentials.isolation: "none" in ` +
|
|
3286
|
-
`${migration.path} — this fleet is UNPROTECTED: sessions run as this daemon's user and can reach ` +
|
|
3287
|
-
`its GitHub credential. Set credentials.isolation to "per-run" once the host is provisioned (README).`,
|
|
3288
|
-
);
|
|
3289
|
-
}
|
|
3290
|
-
} catch (err) {
|
|
3291
|
-
log(`could not persist the credentials migration (${errText(err)}) — continuing with isolation "none"`);
|
|
3292
|
-
}
|
|
3293
|
-
|
|
3294
2938
|
const cfg = loadConfig();
|
|
3295
2939
|
const project = findProject(cfg, o.project);
|
|
3296
2940
|
const caps = resolveCaps(project, cfg.defaults);
|
|
3297
2941
|
const store = openStore(dbPath());
|
|
3298
2942
|
const tracker = makeTracker(project);
|
|
3299
2943
|
|
|
3300
|
-
// The host capability probe, once, at startup. It states the mechanism
|
|
3301
|
-
// rather than guessing, and it proves the launcher works against a trivial
|
|
3302
|
-
// child before reporting one available — a mechanism whose argv the host
|
|
3303
|
-
// rejects looks identical here and then fails every dispatch at run time.
|
|
3304
|
-
const credentials = resolveCredentials(project);
|
|
3305
|
-
const probe = await probeHost({ slots: caps.maxConcurrentWorkers });
|
|
3306
|
-
const boundary: FleetBoundary = {
|
|
3307
|
-
isolation: credentials.isolation,
|
|
3308
|
-
probe,
|
|
3309
|
-
slots: createSlotPool(caps.maxConcurrentWorkers),
|
|
3310
|
-
paged: false,
|
|
3311
|
-
};
|
|
3312
|
-
const described = describeBoundary(credentials.isolation, probe);
|
|
3313
|
-
log(`credential boundary: ${described.headline}`);
|
|
3314
|
-
for (const line of described.detail) log(`credential boundary: ${line}`);
|
|
3315
|
-
|
|
3316
2944
|
// #126's transport, stated at startup rather than guessed at first use. The
|
|
3317
2945
|
// banner names what this host can actually enforce — whether the kernel will
|
|
3318
2946
|
// vouch for a caller's uid, and whether runs get distinct principals at all —
|
|
@@ -3321,13 +2949,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3321
2949
|
const verbPeerReader = peerCredentialReader();
|
|
3322
2950
|
const verbActions = githubVerbActions(project);
|
|
3323
2951
|
let orchestratorVerbs: VerbListener | undefined;
|
|
3324
|
-
log(
|
|
3325
|
-
`verb transport: ${transportBanner(
|
|
3326
|
-
ensureVerbSocketDir(stateDir()),
|
|
3327
|
-
verbPeerReader,
|
|
3328
|
-
probe.mechanism === "uid-pool",
|
|
3329
|
-
)}`,
|
|
3330
|
-
);
|
|
2952
|
+
log(`verb transport: ${transportBanner(ensureVerbSocketDir(stateDir()), verbPeerReader)}`);
|
|
3331
2953
|
|
|
3332
2954
|
// Recorded here, before a single tick runs, so that the deploy an operator
|
|
3333
2955
|
// *means* to do never trips the tripwire: installing a new build and
|
|
@@ -3335,19 +2957,6 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3335
2957
|
// the other thing — the package changing while the daemon that dispatches
|
|
3336
2958
|
// work is holding it open, whether that is a worker that wandered out of its
|
|
3337
2959
|
// worktree or a human editing the live install "just to test something".
|
|
3338
|
-
// Resolved once, here, for the same reason the host probe above is: every
|
|
3339
|
-
// session of every tick must agree, and a fleet with no way to authenticate a
|
|
3340
|
-
// model must refuse at admission rather than burn an attempt per dispatch
|
|
3341
|
-
// (#152).
|
|
3342
|
-
const model: { credential?: ModelCredential; paged: boolean } = { paged: false };
|
|
3343
|
-
const resolvedModel = resolveModelCredential();
|
|
3344
|
-
if (resolvedModel !== undefined) model.credential = resolvedModel;
|
|
3345
|
-
log(
|
|
3346
|
-
resolvedModel === undefined
|
|
3347
|
-
? "model credential: NONE reachable from a session — dispatch is held until an API key or an auth broker is configured"
|
|
3348
|
-
: `model credential: ${resolvedModel.kind === "env" ? "from this daemon's environment" : "auth broker, injected per session"}`,
|
|
3349
|
-
);
|
|
3350
|
-
|
|
3351
2960
|
const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
|
|
3352
2961
|
log(`package integrity baseline: ${integrity.baseline.size} files under ${import.meta.dir}`);
|
|
3353
2962
|
|
|
@@ -3427,64 +3036,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3427
3036
|
log("orchestrator: external — tier-1 escalations post as issue comments for the external session's drain duty");
|
|
3428
3037
|
} else {
|
|
3429
3038
|
try {
|
|
3430
|
-
//
|
|
3431
|
-
//
|
|
3432
|
-
//
|
|
3433
|
-
|
|
3434
|
-
// orchestrator gets: the tool-layer jail was deleted in 0.4.3 (#143)
|
|
3435
|
-
// because it could only be installed in sessions this daemon spawns, and
|
|
3436
|
-
// the principal refuses the syscall `bash` would make anyway, which the
|
|
3437
|
-
// jail never did. It is also conditional, and honestly so: on a host with
|
|
3438
|
-
// no isolating mechanism there is no principal and no gate, which is what
|
|
3439
|
-
// `status` reports as `unprotected`.
|
|
3440
|
-
// Same rule as a worker's, for the same reason: under `uid-pool` the
|
|
3441
|
-
// orchestrator is its own uid, so every tree it must reach lives outside
|
|
3442
|
-
// the 0700 private state directory. Its principal is in `conductor-runs`
|
|
3443
|
-
// and deliberately NOT in the daemon group, so it could not traverse
|
|
3444
|
-
// there even once — and widening the state directory is the one fix that
|
|
3445
|
-
// is not available, because that is where conductor.db lives.
|
|
3446
|
-
const orchTreeRoot = probe.mechanism === "uid-pool" ? sharedRoot() : stateDir();
|
|
3447
|
-
if (orchTreeRoot !== stateDir()) {
|
|
3448
|
-
mkdirSync(orchTreeRoot, { recursive: true });
|
|
3449
|
-
chmodSync(orchTreeRoot, 0o711);
|
|
3450
|
-
}
|
|
3451
|
-
const orchEnvRoot = join(orchTreeRoot, "boundaries", "orchestrator");
|
|
3039
|
+
// The orchestrator is a child process of this daemon running as its own
|
|
3040
|
+
// user. Nothing mechanically stops it reading a run checkout — what holds
|
|
3041
|
+
// it is its brief and the verb ledger (#143).
|
|
3042
|
+
const orchTreeRoot = stateDir();
|
|
3452
3043
|
const orchCwd = join(orchTreeRoot, "orchestrator");
|
|
3453
3044
|
mkdirSync(orchCwd, { recursive: true });
|
|
3454
|
-
const orchestratorBoundary = buildSessionBoundary({
|
|
3455
|
-
probe,
|
|
3456
|
-
isolation: credentials.isolation,
|
|
3457
|
-
role: "orchestrator",
|
|
3458
|
-
envRoot: orchEnvRoot,
|
|
3459
|
-
writeRoots: [orchCwd],
|
|
3460
|
-
denyReadRoots: [...credentialDenyRoots(), project.workspaceRoot, project.mirrorRoot],
|
|
3461
|
-
denyReadFiles: credentialDenyFiles(),
|
|
3462
|
-
...(credentials.readToken === undefined ? {} : { readToken: credentials.readToken }),
|
|
3463
|
-
...(model.credential === undefined ? {} : { model: model.credential }),
|
|
3464
|
-
});
|
|
3465
|
-
if (orchestratorBoundary.principal !== undefined) {
|
|
3466
|
-
const gid = probe.daemonGid;
|
|
3467
|
-
if (gid === undefined) {
|
|
3468
|
-
throw new Error(
|
|
3469
|
-
`the orchestrator principal was allocated without a resolved ${CONDUCTOR_GROUPS.daemon} gid`,
|
|
3470
|
-
);
|
|
3471
|
-
}
|
|
3472
|
-
applyRunOwnership(orchEnvRoot, orchestratorBoundary.principal, gid);
|
|
3473
|
-
applyRunOwnership(orchCwd, orchestratorBoundary.principal, gid);
|
|
3474
|
-
// The same empirical gate a worker gets. The orchestrator is the
|
|
3475
|
-
// session with merge and release authority, so "it cannot reach the
|
|
3476
|
-
// credential" matters more here, not less — and it was the half that
|
|
3477
|
-
// was never checked. `sharedGroup: false` because it is launched with
|
|
3478
|
-
// no supplementary group at all, so the probe has to run under exactly
|
|
3479
|
-
// that identity rather than a worker's.
|
|
3480
|
-
const orchLeaking = await credentialReadRefusal(
|
|
3481
|
-
spawnCaptured,
|
|
3482
|
-
orchestratorBoundary.principal,
|
|
3483
|
-
{ boundingSet: probe.boundingSetDropped, sharedGroup: false },
|
|
3484
|
-
[...credentialDenyRoots(), ...credentialDenyFiles()],
|
|
3485
|
-
);
|
|
3486
|
-
if (orchLeaking !== undefined) throw new Error(orchLeaking);
|
|
3487
|
-
}
|
|
3488
3045
|
// A third socket, distinct from every run's, in the same daemon-owned
|
|
3489
3046
|
// 0711 parent. This is what makes "merge authority is the orchestrator's"
|
|
3490
3047
|
// a property of the channel: the daemon knows which session is speaking
|
|
@@ -3497,9 +3054,6 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3497
3054
|
path: verbSocketPath(ensureVerbSocketDir(orchTreeRoot), "orchestrator"),
|
|
3498
3055
|
project: project.name,
|
|
3499
3056
|
role: "orchestrator",
|
|
3500
|
-
...(orchestratorBoundary.principal === undefined
|
|
3501
|
-
? {}
|
|
3502
|
-
: { principal: orchestratorBoundary.principal }),
|
|
3503
3057
|
},
|
|
3504
3058
|
{ ...(verbPeerReader === undefined ? {} : { peerReader: verbPeerReader }) },
|
|
3505
3059
|
);
|
|
@@ -3507,8 +3061,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3507
3061
|
cwd: orchCwd,
|
|
3508
3062
|
brief,
|
|
3509
3063
|
releaseGrants,
|
|
3510
|
-
|
|
3511
|
-
socketPath: join(orchEnvRoot, "ipc.sock"),
|
|
3064
|
+
socketPath: join(orchCwd, "ipc.sock"),
|
|
3512
3065
|
verbSocketPath: orchestratorVerbs.path,
|
|
3513
3066
|
onChildLog: (line) => {
|
|
3514
3067
|
log(`orchestrator ${line}`);
|
|
@@ -3561,8 +3114,6 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3561
3114
|
const d: Deps = {
|
|
3562
3115
|
project,
|
|
3563
3116
|
caps,
|
|
3564
|
-
boundary,
|
|
3565
|
-
model,
|
|
3566
3117
|
tracker,
|
|
3567
3118
|
store,
|
|
3568
3119
|
// Process-wide, so a `status` served off this daemon's own HTTP surface
|
|
@@ -3669,12 +3220,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3669
3220
|
project: project.name,
|
|
3670
3221
|
store,
|
|
3671
3222
|
turnLimits,
|
|
3672
|
-
health: () =>
|
|
3673
|
-
daemonHealthSnapshot(store, project.name, undefined, undefined, codeGraph, {
|
|
3674
|
-
isolation: boundary.isolation,
|
|
3675
|
-
mechanism: boundary.probe.mechanism,
|
|
3676
|
-
headline: describeBoundary(boundary.isolation, boundary.probe).headline,
|
|
3677
|
-
}),
|
|
3223
|
+
health: () => daemonHealthSnapshot(store, project.name, undefined, undefined, codeGraph),
|
|
3678
3224
|
}),
|
|
3679
3225
|
});
|
|
3680
3226
|
log(`serving /healthz on :${server.port}, project ${project.name}`);
|