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/setup-host.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, userInfo } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { configPath,
|
|
4
|
+
import { configPath, stateDir } from "./config.ts";
|
|
5
5
|
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
6
6
|
import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
|
|
7
7
|
import {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
TICK_CONFIG_FILE,
|
|
19
19
|
type TickConfig,
|
|
20
20
|
} from "./orchestrator-tick.ts";
|
|
21
|
-
import {
|
|
21
|
+
import type { Caps, ProjectConfig } from "./types.ts";
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
24
24
|
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
@@ -110,39 +110,6 @@ export function renderDaemonService(
|
|
|
110
110
|
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
111
111
|
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
112
112
|
`Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
|
|
113
|
-
// Both of these are per-run only, and grouped so the managed set stays one
|
|
114
|
-
// idea. The shared root is baked in so a custom location survives systemd's
|
|
115
|
-
// clean environment — without it the daemon resolves the platform default
|
|
116
|
-
// while the operator provisioned somewhere else, and every dispatch is
|
|
117
|
-
// refused for a path nobody chose. It is meaningless under `none`, where
|
|
118
|
-
// nothing resolves it, and rendering it there would make every unit
|
|
119
|
-
// installed before 0.4.0 look drifted for no reason (#125).
|
|
120
|
-
//
|
|
121
|
-
// The capabilities exist to be DROPPED INTO run children by the
|
|
122
|
-
// privilege-dropping launcher, never inherited by them — see `setprivArgv`.
|
|
123
|
-
// Rendered only for `per-run`, because that is the only isolation that
|
|
124
|
-
// changes uid; granting them to a fleet that does not need them widens the
|
|
125
|
-
// daemon for nothing. Without them the probe honestly reports `group-mode`
|
|
126
|
-
// and a configured per-run fleet refuses every dispatch, which is the
|
|
127
|
-
// failure an operator following setup would otherwise hit first.
|
|
128
|
-
//
|
|
129
|
-
// `SupplementaryGroups=` is declared rather than left to initgroups, and that
|
|
130
|
-
// is not belt-and-braces. Supplementary groups are fixed when a process
|
|
131
|
-
// starts, so a daemon that was running when the accounts were provisioned can
|
|
132
|
-
// never join them — and a unit with no `User=` gets no initgroups call at
|
|
133
|
-
// all, so it comes up with `Groups: 0` even after the group database is
|
|
134
|
-
// correct. `probeHost` then reports `none` forever, the wizard cannot offer
|
|
135
|
-
// `uid-pool`, and the fleet quietly keeps the fail-closed value (#153).
|
|
136
|
-
// Declaring the membership makes it a property of the unit instead of a
|
|
137
|
-
// property of how the unit happened to be written.
|
|
138
|
-
...(resolveCredentials(project).isolation === "per-run"
|
|
139
|
-
? [
|
|
140
|
-
`Environment=${systemdQuote(`OMP_CONDUCTOR_SHARED=${sharedRoot()}`)}`,
|
|
141
|
-
`SupplementaryGroups=${CONDUCTOR_GROUPS.daemon} ${CONDUCTOR_GROUPS.runs}`,
|
|
142
|
-
"AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
|
|
143
|
-
"CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
|
|
144
|
-
]
|
|
145
|
-
: []),
|
|
146
113
|
`WorkingDirectory=${systemdQuote(stateDir())}`,
|
|
147
114
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
148
115
|
"Restart=on-failure",
|
|
@@ -321,160 +288,3 @@ export async function runSetupSmoke(
|
|
|
321
288
|
export function installedUnitPath(): string {
|
|
322
289
|
return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
|
|
323
290
|
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Why the *live* systemd unit no longer matches what this version would render.
|
|
327
|
-
*
|
|
328
|
-
* `planHostRuntime` compares against the **staged** copy under the state dir,
|
|
329
|
-
* which says nothing about the file systemd booted from. That gap is not
|
|
330
|
-
* cosmetic since 0.4.0: a fleet configured `per-run` whose installed unit
|
|
331
|
-
* predates the capability grant probes down to `group-mode` and then refuses
|
|
332
|
-
* every dispatch — and `upgrade` would happily verify package identities,
|
|
333
|
-
* services, pane and ticks around it, because none of those look at the unit.
|
|
334
|
-
*
|
|
335
|
-
* Checked by directive rather than by whole-file equality on purpose. An
|
|
336
|
-
* operator is entitled to hand-tune `MemoryMax`, add `After=`, or set an
|
|
337
|
-
* `Environment=` of their own, and failing an upgrade over that would teach
|
|
338
|
-
* them to stop running upgrades. What is reported is the set of directives this
|
|
339
|
-
* configuration *requires* and the live unit lacks.
|
|
340
|
-
*
|
|
341
|
-
* `undefined` means either "no drift that matters" or "no installed unit at
|
|
342
|
-
* all" — a host running `omp-conductor start` without systemd is not broken,
|
|
343
|
-
* and must not be told it is.
|
|
344
|
-
*/
|
|
345
|
-
/**
|
|
346
|
-
* What systemd has actually loaded for the unit, as `systemctl show` reports it.
|
|
347
|
-
*
|
|
348
|
-
* Deliberately not the file on disk. A unit installed without `daemon-reload`
|
|
349
|
-
* can match the rendered text byte for byte while the manager still runs the
|
|
350
|
-
* previous configuration, and a drop-in under `…service.d/` changes what runs
|
|
351
|
-
* without touching the file at all. Both read clean from the filesystem.
|
|
352
|
-
*/
|
|
353
|
-
export interface EffectiveUnit {
|
|
354
|
-
/** `systemctl show -p AmbientCapabilities`; empty string when unset. */
|
|
355
|
-
ambientCapabilities: string;
|
|
356
|
-
capabilityBoundingSet: string;
|
|
357
|
-
/** `systemctl show -p SupplementaryGroups`; empty string when unset. */
|
|
358
|
-
supplementaryGroups: string;
|
|
359
|
-
/** `systemctl show -p Environment`, newline- or space-joined. */
|
|
360
|
-
environment: string;
|
|
361
|
-
needDaemonReload: boolean;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** Whitespace-separated names as a comparable set; systemd reorders them. */
|
|
365
|
-
function nameSet(text: string): Set<string> {
|
|
366
|
-
return new Set(text.split(/[\s,]+/).map((n) => n.trim()).filter((n) => n.length > 0));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Capability names as a comparable set: systemd reports them lowercased, `cap_`-prefixed and reordered. */
|
|
370
|
-
function capabilitySet(text: string): Set<string> {
|
|
371
|
-
return new Set(
|
|
372
|
-
text
|
|
373
|
-
.split(/[\s,]+/)
|
|
374
|
-
.map((name) => name.trim().toLowerCase().replace(/^cap_/, ""))
|
|
375
|
-
.filter((name) => name.length > 0),
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function renderedDirective(rendered: string, key: string): string | undefined {
|
|
380
|
-
for (const line of rendered.split("\n")) {
|
|
381
|
-
const trimmed = line.trim();
|
|
382
|
-
if (trimmed.startsWith(`${key}=`)) return trimmed.slice(key.length + 1);
|
|
383
|
-
}
|
|
384
|
-
return undefined;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Why what systemd loaded no longer matches what this version would render.
|
|
389
|
-
*
|
|
390
|
-
* Three asymmetries, each for a reason:
|
|
391
|
-
*
|
|
392
|
-
* - **Ambient capabilities are compared exactly, both ways.** Missing them
|
|
393
|
-
* breaks a per-run fleet; *leftover* ones are worse. Roll back to `none` and
|
|
394
|
-
* there is no `setpriv` launcher at all, so ambient capabilities on the unit
|
|
395
|
-
* are inherited straight into model-executed code, which can then `setuid()`
|
|
396
|
-
* to any account. `systemctl` reports an empty string when unset, so the
|
|
397
|
-
* rollback case is unambiguous.
|
|
398
|
-
* - **The bounding set is checked only when this version sets it.** systemd
|
|
399
|
-
* reports the full default set when a unit does not, so comparing both ways
|
|
400
|
-
* would flag every ordinary host.
|
|
401
|
-
* - **Only directives this configuration manages are considered**, so an
|
|
402
|
-
* operator's own `MemoryMax`, `After=` or drop-in never fails an upgrade.
|
|
403
|
-
*/
|
|
404
|
-
export function unitDriftReason(rendered: string, effective: EffectiveUnit | undefined): string | undefined {
|
|
405
|
-
if (effective === undefined) return undefined;
|
|
406
|
-
const problems: string[] = [];
|
|
407
|
-
|
|
408
|
-
// Missing groups only, never extra: an operator may legitimately add their own,
|
|
409
|
-
// and unlike a leftover capability a spare group grants nothing this package
|
|
410
|
-
// relies on. Missing ones are load-bearing — without them the probe resolves to
|
|
411
|
-
// `none` and a configured per-run fleet refuses every dispatch (#153).
|
|
412
|
-
const wantGroups = nameSet(renderedDirective(rendered, "SupplementaryGroups") ?? "");
|
|
413
|
-
const haveGroups = nameSet(effective.supplementaryGroups);
|
|
414
|
-
const missingGroups = [...wantGroups].filter((g) => !haveGroups.has(g));
|
|
415
|
-
if (missingGroups.length > 0) {
|
|
416
|
-
problems.push(
|
|
417
|
-
`SupplementaryGroups is missing ${missingGroups.join(", ")} — supplementary groups are fixed at ` +
|
|
418
|
-
`process start, so the running daemon cannot be a member and the credential boundary probe ` +
|
|
419
|
-
`resolves to none until the unit is reloaded and the service restarted`,
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const wantAmbient = capabilitySet(renderedDirective(rendered, "AmbientCapabilities") ?? "");
|
|
424
|
-
const haveAmbient = capabilitySet(effective.ambientCapabilities);
|
|
425
|
-
const missingAmbient = [...wantAmbient].filter((c) => !haveAmbient.has(c));
|
|
426
|
-
const extraAmbient = [...haveAmbient].filter((c) => !wantAmbient.has(c));
|
|
427
|
-
if (missingAmbient.length > 0) {
|
|
428
|
-
problems.push(`AmbientCapabilities is missing ${missingAmbient.join(", ")}`);
|
|
429
|
-
}
|
|
430
|
-
if (extraAmbient.length > 0) {
|
|
431
|
-
problems.push(
|
|
432
|
-
`AmbientCapabilities grants ${extraAmbient.join(", ")} that this configuration does not — with ` +
|
|
433
|
-
`isolation off there is no privilege-dropping launcher, so these are inherited by model-executed code`,
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const wantBounding = renderedDirective(rendered, "CapabilityBoundingSet");
|
|
438
|
-
if (wantBounding !== undefined) {
|
|
439
|
-
const missing = [...capabilitySet(wantBounding)].filter(
|
|
440
|
-
(c) => !capabilitySet(effective.capabilityBoundingSet).has(c),
|
|
441
|
-
);
|
|
442
|
-
if (missing.length > 0) problems.push(`CapabilityBoundingSet is missing ${missing.join(", ")}`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const wantShared = rendered
|
|
446
|
-
.split("\n")
|
|
447
|
-
.map((l) => l.trim())
|
|
448
|
-
.find((l) => l.includes("OMP_CONDUCTOR_SHARED="));
|
|
449
|
-
if (wantShared !== undefined) {
|
|
450
|
-
const value = wantShared.slice(wantShared.indexOf("OMP_CONDUCTOR_SHARED=")).replace(/"$/, "");
|
|
451
|
-
if (!effective.environment.includes(value)) {
|
|
452
|
-
problems.push(`Environment is missing ${value}`);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (effective.needDaemonReload) {
|
|
457
|
-
problems.push("systemd reports the unit needs a daemon-reload, so what runs is not what is on disk");
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (problems.length === 0) return undefined;
|
|
461
|
-
return (
|
|
462
|
-
`what systemd loaded for ${STAGED_SERVICE_NAME} does not match this configuration: ${problems.join("; ")}.`
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Parse `systemctl show -p …` key=value output into an {@link EffectiveUnit}. */
|
|
467
|
-
export function parseEffectiveUnit(stdout: string): EffectiveUnit {
|
|
468
|
-
const values = new Map<string, string>();
|
|
469
|
-
for (const line of stdout.split("\n")) {
|
|
470
|
-
const at = line.indexOf("=");
|
|
471
|
-
if (at > 0) values.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
|
|
472
|
-
}
|
|
473
|
-
return {
|
|
474
|
-
ambientCapabilities: values.get("AmbientCapabilities") ?? "",
|
|
475
|
-
capabilityBoundingSet: values.get("CapabilityBoundingSet") ?? "",
|
|
476
|
-
supplementaryGroups: values.get("SupplementaryGroups") ?? "",
|
|
477
|
-
environment: values.get("Environment") ?? "",
|
|
478
|
-
needDaemonReload: (values.get("NeedDaemonReload") ?? "no") === "yes",
|
|
479
|
-
};
|
|
480
|
-
}
|
package/src/setup.ts
CHANGED
|
@@ -35,11 +35,11 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
clonePolicy,
|
|
37
37
|
configPath,
|
|
38
|
+
defaultMirrorRoot,
|
|
39
|
+
defaultWorkspaceRoot,
|
|
38
40
|
resolveCaps,
|
|
39
|
-
resolveCredentials,
|
|
40
41
|
resolvePolicy,
|
|
41
42
|
resolveReleaseGrants,
|
|
42
|
-
projectTreeBase,
|
|
43
43
|
stateDir,
|
|
44
44
|
} from "./config.ts";
|
|
45
45
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
@@ -51,8 +51,6 @@ import {
|
|
|
51
51
|
DEFAULT_REPORT_SCOPE,
|
|
52
52
|
DENIED_RELEASE_GRANTS,
|
|
53
53
|
RELEASE_SHAPES,
|
|
54
|
-
type CredentialConfig,
|
|
55
|
-
type CredentialIsolation,
|
|
56
54
|
type BaseFreshness,
|
|
57
55
|
type BehindBaseAction,
|
|
58
56
|
type Caps,
|
|
@@ -103,13 +101,6 @@ export interface SetupAnswers {
|
|
|
103
101
|
* leave one to be defaulted by whichever reader gets there first.
|
|
104
102
|
*/
|
|
105
103
|
policy: ProjectPolicy;
|
|
106
|
-
/**
|
|
107
|
-
* Whether model-executed code runs under its own OS principal (#125). The
|
|
108
|
-
* wizard offers `per-run` only when the host probe says this box can build
|
|
109
|
-
* the boundary, because an answer the host cannot honour is a fleet that
|
|
110
|
-
* refuses to dispatch rather than a fleet that is protected.
|
|
111
|
-
*/
|
|
112
|
-
credentials: CredentialConfig;
|
|
113
104
|
/**
|
|
114
105
|
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
115
106
|
* part of the config — the brief is the operator's file, and the conductor
|
|
@@ -174,13 +165,6 @@ export const SETUP_DEFAULTS = {
|
|
|
174
165
|
releaseGrants: DENIED_RELEASE_GRANTS,
|
|
175
166
|
/** The strictest reading of the prose these conditions replaced (#129). */
|
|
176
167
|
policy: DEFAULT_PROJECT_POLICY,
|
|
177
|
-
/**
|
|
178
|
-
* No boundary until an operator asks for one on a host that can build it.
|
|
179
|
-
* `none` is the honest default rather than the safe-sounding one: writing
|
|
180
|
-
* `per-run` into a fresh config on a box with no mechanism would produce a
|
|
181
|
-
* conductor that installs cleanly and then declines every issue (#125).
|
|
182
|
-
*/
|
|
183
|
-
credentials: { isolation: "none" } as CredentialConfig,
|
|
184
168
|
/** The daemon runs its own triage session unless an operator already runs one. */
|
|
185
169
|
orchestratorMode: "embedded",
|
|
186
170
|
} as const;
|
|
@@ -520,11 +504,6 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
|
|
|
520
504
|
* with {@link answersFromProject} — the pair an amend's carry-through rests on.
|
|
521
505
|
*/
|
|
522
506
|
export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
523
|
-
// Under a per-run boundary the worktrees and mirrors have to sit where a slot
|
|
524
|
-
// principal can actually traverse to them. Defaulting them under the 0700
|
|
525
|
-
// state directory would provision a repository and then be refused at
|
|
526
|
-
// dispatch, which looks like a broken install rather than a layout choice.
|
|
527
|
-
const dir = projectTreeBase(a.credentials.isolation);
|
|
528
507
|
|
|
529
508
|
const repos: Record<string, RepoTarget> = {};
|
|
530
509
|
const graphRoot = a.graphRoot?.trim();
|
|
@@ -580,14 +559,13 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
580
559
|
// Written out in full for the same reason: the file then says what a merge
|
|
581
560
|
// and a release require without anyone having to know a default (#129).
|
|
582
561
|
policy: clonePolicy(a.policy),
|
|
583
|
-
// Written out even when it is the default, so
|
|
584
|
-
//
|
|
585
|
-
credentials: { ...a.credentials },
|
|
562
|
+
// Written out even when it is the default, so an operator amending the
|
|
563
|
+
// volume has a line in the file to point at.
|
|
586
564
|
reporting: { scope: a.reportScope },
|
|
587
565
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
588
566
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
589
|
-
workspaceRoot:
|
|
590
|
-
mirrorRoot:
|
|
567
|
+
workspaceRoot: defaultWorkspaceRoot(),
|
|
568
|
+
mirrorRoot: defaultMirrorRoot(),
|
|
591
569
|
};
|
|
592
570
|
}
|
|
593
571
|
|
|
@@ -641,7 +619,6 @@ export function defaultAnswers(projectName: string): SetupAnswers {
|
|
|
641
619
|
authority: { ...SETUP_DEFAULTS.authority },
|
|
642
620
|
releaseGrants: { ...SETUP_DEFAULTS.releaseGrants },
|
|
643
621
|
policy: clonePolicy(SETUP_DEFAULTS.policy),
|
|
644
|
-
credentials: { ...SETUP_DEFAULTS.credentials },
|
|
645
622
|
orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
|
|
646
623
|
reportScope: SETUP_DEFAULT_REPORT_SCOPE,
|
|
647
624
|
writeOrchestratorBrief: false,
|
|
@@ -689,7 +666,6 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
689
666
|
authority: { ...p.authority },
|
|
690
667
|
releaseGrants: resolveReleaseGrants(p),
|
|
691
668
|
policy: resolvePolicy(p),
|
|
692
|
-
credentials: resolveCredentials(p),
|
|
693
669
|
orchestratorMode: p.escalation.orchestrator,
|
|
694
670
|
reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
695
671
|
writeOrchestratorBrief: false,
|
|
@@ -808,46 +784,6 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
|
|
|
808
784
|
* that dialog's answer un-actionable — an operator who says "yes, overwrite it"
|
|
809
785
|
* must get an overwrite.
|
|
810
786
|
*/
|
|
811
|
-
/**
|
|
812
|
-
* Carry a fleet's own `POLICY.md` to a workspace root that just moved.
|
|
813
|
-
*
|
|
814
|
-
* `workspaceRoot` is derived from `credentials.isolation`: `none` puts it under
|
|
815
|
-
* the 0700 state directory, `per-run` under the shared root a slot principal can
|
|
816
|
-
* traverse. So switching a fleet to worker isolation *relocates* the briefs, and
|
|
817
|
-
* the operator's half must go with them.
|
|
818
|
-
*
|
|
819
|
-
* Without this, amending isolation on an external-orchestrator fleet was a
|
|
820
|
-
* deadlock with a data-loss escape hatch. The setup gate refused because neither
|
|
821
|
-
* file existed at the new root; the only way past it was approving a brief write
|
|
822
|
-
* in the same pass, and {@link writeOrchestratorBrief} renders a *fresh* POLICY.md
|
|
823
|
-
* scaffold — silently replacing the operator's release procedure and amendment
|
|
824
|
-
* log, and orphaning the real one at the old root.
|
|
825
|
-
*
|
|
826
|
-
* `ORCHESTRATOR.md` is recomposed rather than copied, because it is derived from
|
|
827
|
-
* the package floor plus that policy on every tick anyway. The source is left in
|
|
828
|
-
* place: this package does not delete an operator's file, and a stale copy under
|
|
829
|
-
* the old root is inert once nothing reads it.
|
|
830
|
-
*
|
|
831
|
-
* Returns the moved path, or undefined when nothing needed moving — the roots
|
|
832
|
-
* match, there was no policy to carry, or the destination already has one.
|
|
833
|
-
*/
|
|
834
|
-
export function relocateBriefIfMoved(
|
|
835
|
-
previous: ProjectConfig | undefined,
|
|
836
|
-
next: ProjectConfig,
|
|
837
|
-
): { from: string; to: string } | undefined {
|
|
838
|
-
if (previous === undefined) return undefined;
|
|
839
|
-
if (previous.workspaceRoot === next.workspaceRoot) return undefined;
|
|
840
|
-
const from = policyPathForProject(previous);
|
|
841
|
-
const to = policyPathForProject(next);
|
|
842
|
-
if (!existsSync(from) || existsSync(to)) return undefined;
|
|
843
|
-
|
|
844
|
-
const policy = readFileSync(from, "utf8");
|
|
845
|
-
mkdirSync(dirname(to), { recursive: true });
|
|
846
|
-
writeFileSync(to, policy);
|
|
847
|
-
writeFileSync(briefPathForProject(next), composeOrchestrator(renderFloorForProject(next), policy));
|
|
848
|
-
return { from, to };
|
|
849
|
-
}
|
|
850
|
-
|
|
851
787
|
export function writeOrchestratorBrief(a: SetupAnswers): string {
|
|
852
788
|
const project = buildProject(a);
|
|
853
789
|
const policyPath = policyPathForProject(project);
|
|
@@ -1177,7 +1113,6 @@ export const AMEND_AREA_IDS = [
|
|
|
1177
1113
|
"graph",
|
|
1178
1114
|
"authority",
|
|
1179
1115
|
"policy",
|
|
1180
|
-
"credentials",
|
|
1181
1116
|
"escalation",
|
|
1182
1117
|
"reporting",
|
|
1183
1118
|
"brief",
|
|
@@ -1283,16 +1218,6 @@ export const AMEND_AREAS: {
|
|
|
1283
1218
|
);
|
|
1284
1219
|
},
|
|
1285
1220
|
},
|
|
1286
|
-
credentials: {
|
|
1287
|
-
name: "credential isolation",
|
|
1288
|
-
asks: "whether worker and orchestrator sessions run under their own OS principal, or as the daemon's own user",
|
|
1289
|
-
describe: (p) => {
|
|
1290
|
-
const c = resolveCredentials(p);
|
|
1291
|
-
return c.isolation === "per-run"
|
|
1292
|
-
? `per-run principals${c.readToken === undefined ? "" : ", with a read-scoped token"}`
|
|
1293
|
-
: "none — sessions run as the daemon's user and can reach its credentials";
|
|
1294
|
-
},
|
|
1295
|
-
},
|
|
1296
1221
|
escalation: {
|
|
1297
1222
|
name: "escalation & triage",
|
|
1298
1223
|
asks: "the tier-2 Telegram chat, whether escalations also comment, and where the orchestrator session lives",
|
package/src/store.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { dirname, join } from "node:path";
|
|
|
15
15
|
|
|
16
16
|
import { stateDir } from "./config.ts";
|
|
17
17
|
import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
|
|
18
|
+
import { startFailure } from "./failure-class.ts";
|
|
18
19
|
import type {
|
|
19
20
|
DecisionDraft,
|
|
20
21
|
FailureClass,
|
|
@@ -678,6 +679,39 @@ export function openStore(dbPath: string): Store {
|
|
|
678
679
|
}
|
|
679
680
|
}
|
|
680
681
|
|
|
682
|
+
// One-time repair, and the reason it lives here rather than in the classifier:
|
|
683
|
+
// a turn-0 environment fault that was ALREADY classified `unknown` and
|
|
684
|
+
// escalated is never revisited. `runsNeedingClassification` only offers a row
|
|
685
|
+
// whose class is NULL, or one whose recovery has not run yet — an
|
|
686
|
+
// `unknown`/`escalate` row with `recoveredAt` set satisfies neither, so no
|
|
687
|
+
// upgrade and no later tick would ever correct it.
|
|
688
|
+
//
|
|
689
|
+
// That mattered on a real fleet. The per-run `$HOME` redirect this release
|
|
690
|
+
// deletes hid the harness's own credentials, so every dispatch died before it
|
|
691
|
+
// read its issue and each one still spent an implementation attempt. Deleting
|
|
692
|
+
// the redirect stops the bleeding; it does not give back attempts already
|
|
693
|
+
// charged, and an issue sitting at its cap for a fault that was never in its
|
|
694
|
+
// code stays undispatchable forever.
|
|
695
|
+
//
|
|
696
|
+
// Narrow by construction: `turns = 0` (the session never took a turn), an
|
|
697
|
+
// error the classifier itself recognises as a start failure, and a class of
|
|
698
|
+
// exactly `unknown` — a row a human or a later release classified as anything
|
|
699
|
+
// else is left alone. Idempotent for free: afterwards those rows read
|
|
700
|
+
// `env-start-failure`, which the WHERE no longer matches.
|
|
701
|
+
const misread = db
|
|
702
|
+
.query<
|
|
703
|
+
{ id: string; lastError: string | null },
|
|
704
|
+
[]
|
|
705
|
+
>(`SELECT id, lastError FROM runs WHERE failureClass = 'unknown' AND turns = 0 AND lastError IS NOT NULL`)
|
|
706
|
+
.all()
|
|
707
|
+
.filter((row) => startFailure(row.lastError ?? undefined) !== undefined);
|
|
708
|
+
if (misread.length > 0) {
|
|
709
|
+
const reclassify = db.query<unknown, [string]>(
|
|
710
|
+
`UPDATE runs SET failureClass = 'env-start-failure' WHERE id = ?`,
|
|
711
|
+
);
|
|
712
|
+
for (const row of misread) reclassify.run(row.id);
|
|
713
|
+
}
|
|
714
|
+
|
|
681
715
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
682
716
|
`INSERT INTO runs (
|
|
683
717
|
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
package/src/tracker/github.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* `gh auth token`; the eleven Tracker methods above it stay untouched.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { credentialedEnv } from "../
|
|
24
|
+
import { credentialedEnv } from "../gitops.ts";
|
|
25
25
|
import { parsePrDiff } from "../diff-flags.ts";
|
|
26
26
|
import type {
|
|
27
27
|
IssueState,
|
package/src/types.ts
CHANGED
|
@@ -457,76 +457,6 @@ export type ReleaseJustification = VerbJustification<ReleaseReason>;
|
|
|
457
457
|
|
|
458
458
|
export type LabelJustification = VerbJustification<LabelReason>;
|
|
459
459
|
|
|
460
|
-
/**
|
|
461
|
-
* How hard a boundary separates model-executed code from the operator's GitHub
|
|
462
|
-
* write credential (#125).
|
|
463
|
-
*
|
|
464
|
-
* Three values, ordered strongest first, because the difference between them is
|
|
465
|
-
* a difference in what anyone can honestly claim:
|
|
466
|
-
*
|
|
467
|
-
* - `per-run` — every session runs as its own OS principal that cannot read the
|
|
468
|
-
* daemon's `gh` config, keychain, `~/.ssh` or `~/.npmrc`, and cannot write a
|
|
469
|
-
* sibling run's checkout. Satisfied **only** by the `uid-pool` and
|
|
470
|
-
* `sandbox-exec` mechanisms.
|
|
471
|
-
* - `group-mode` — same uid as the daemon, cross-run separation by group and
|
|
472
|
-
* mode only. It bounds accidents and does **not** contain a determined bash
|
|
473
|
-
* escape. It exists as a config value rather than as a fallback so that an
|
|
474
|
-
* operator who takes it has said the weaker sentence out loud: a `per-run`
|
|
475
|
-
* request that quietly resolved to this would let somebody believe they had
|
|
476
|
-
* configured the full boundary and shipped the lesser one, which is precisely
|
|
477
|
-
* the "never the silent default" failure #125 exists to end.
|
|
478
|
-
* - `none` — the session runs as the daemon's own user and the env scrubbing in
|
|
479
|
-
* `credentials.ts` is *all* that stands between the model and the credential,
|
|
480
|
-
* which same-uid code defeats in one line.
|
|
481
|
-
*
|
|
482
|
-
* A request is never downgraded. If the host cannot build what was asked for,
|
|
483
|
-
* dispatch refuses and names the missing mechanism.
|
|
484
|
-
*/
|
|
485
|
-
export const CREDENTIAL_ISOLATIONS = ["per-run", "group-mode", "none"] as const;
|
|
486
|
-
|
|
487
|
-
export type CredentialIsolation = (typeof CREDENTIAL_ISOLATIONS)[number];
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* What the host can actually enforce, decided by the startup probe rather than
|
|
491
|
-
* by config. Kept separate from {@link CREDENTIAL_ISOLATIONS} on purpose: the
|
|
492
|
-
* operator asks for a boundary, the host says which one it can build, and
|
|
493
|
-
* `status` reports the difference instead of letting either side guess.
|
|
494
|
-
*
|
|
495
|
-
* - `uid-pool` — one unprivileged account per run slot, entered through a
|
|
496
|
-
* privilege-dropping launcher. The only mechanism that claims to contain a
|
|
497
|
-
* determined bash escape on Linux.
|
|
498
|
-
* - `group-mode` — same uid as the daemon, cross-run separation by group and
|
|
499
|
-
* mode only. Bounds accidents, does **not** contain an escape.
|
|
500
|
-
* - `sandbox-exec` — the macOS equivalent of `uid-pool` for a dev host.
|
|
501
|
-
* - `none` — no boundary at all.
|
|
502
|
-
*/
|
|
503
|
-
export const ISOLATION_MECHANISMS = ["uid-pool", "group-mode", "sandbox-exec", "none"] as const;
|
|
504
|
-
|
|
505
|
-
export type IsolationMechanism = (typeof ISOLATION_MECHANISMS)[number];
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* The two groups the filesystem model needs, named once so the probe, the
|
|
509
|
-
* provisioning docs and the adversarial tests cannot drift apart (#125).
|
|
510
|
-
*
|
|
511
|
-
* `daemon` holds the daemon account **only** and is what lets it fetch, salvage
|
|
512
|
-
* and reclaim every run repo. `runs` holds every slot principal and grants
|
|
513
|
-
* read-only access to the shared mirror. A slot principal in `daemon` would
|
|
514
|
-
* void the whole cross-run boundary, so the probe asserts the absence.
|
|
515
|
-
*/
|
|
516
|
-
export const CONDUCTOR_GROUPS = { daemon: "conductor-daemon", runs: "conductor-runs" } as const;
|
|
517
|
-
|
|
518
|
-
/** Per-project credential boundary settings. See {@link CREDENTIAL_ISOLATIONS}. */
|
|
519
|
-
export interface CredentialConfig {
|
|
520
|
-
isolation: CredentialIsolation;
|
|
521
|
-
/**
|
|
522
|
-
* Optional **read-scoped** token handed to sessions so they can look things
|
|
523
|
-
* up on GitHub. Absent is the default and the safe reading: a worker then
|
|
524
|
-
* works from its dispatch brief and the daemon's mediated verbs, which is
|
|
525
|
-
* why the brief must not tell it to run `gh pr view`.
|
|
526
|
-
*/
|
|
527
|
-
readToken?: string;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
460
|
/**
|
|
531
461
|
* Where the session that triages escalations lives. `embedded` is the daemon's
|
|
532
462
|
* own child session; `external` means an operator already runs one — a visible
|
|
@@ -601,16 +531,6 @@ export interface ProjectConfig {
|
|
|
601
531
|
* `resolveReportScope` rather than reaching for `.scope` directly.
|
|
602
532
|
*/
|
|
603
533
|
reporting?: { scope: ReportScope };
|
|
604
|
-
/**
|
|
605
|
-
* The credential boundary this project's sessions run behind (#125).
|
|
606
|
-
*
|
|
607
|
-
* Optional only on disk, and only for one release: a config written before
|
|
608
|
-
* this key existed is migrated to an explicit `{ isolation: "none" }` and
|
|
609
|
-
* rewritten, so the operator ends up with the answer in the file rather than
|
|
610
|
-
* inheriting a silent default. Never read directly — go through
|
|
611
|
-
* `resolveCredentials`, which is where that migration is spelled once.
|
|
612
|
-
*/
|
|
613
|
-
credentials?: CredentialConfig;
|
|
614
534
|
/** Parent directory for per-run worktrees. */
|
|
615
535
|
workspaceRoot: string;
|
|
616
536
|
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
@@ -1001,16 +921,6 @@ export type AdmissionHoldReason =
|
|
|
1001
921
|
| "unsalvaged-wip"
|
|
1002
922
|
| "daily-spend-cap"
|
|
1003
923
|
| "plan-usage-cap"
|
|
1004
|
-
/** `credentials.isolation: "per-run"` on a host whose probe found no
|
|
1005
|
-
* mechanism. The operator asked for a boundary this host cannot build, so
|
|
1006
|
-
* dispatch refuses rather than running unprotected under a config that says
|
|
1007
|
-
* otherwise (#125). */
|
|
1008
|
-
| "credential-boundary"
|
|
1009
|
-
/** No model credential is reachable from a session's redirected `$HOME`, so
|
|
1010
|
-
* every worker would die at turn 0 with "No model selected" and spend an issue
|
|
1011
|
-
* attempt doing it (#152). Held rather than dispatched: an environment fault
|
|
1012
|
-
* is not a failed implementation. */
|
|
1013
|
-
| "no-model-credential"
|
|
1014
924
|
| "unroutable:no-repo-label"
|
|
1015
925
|
| "unroutable:multiple-repo-labels"
|
|
1016
926
|
| "unroutable:unknown-repo";
|