omp-conductor 0.4.4 → 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 +53 -269
- 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 +49 -406
- package/src/failure-class.ts +44 -0
- 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 +1 -42
- package/src/session-host.ts +12 -19
- package/src/setup-host.ts +1 -157
- package/src/setup.ts +6 -41
- package/src/store.ts +36 -2
- package/src/tracker/github.ts +1 -1
- package/src/types.ts +1 -85
- package/src/upgrade.ts +0 -88
- 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 -94
- package/src/credentials.ts +0 -2029
package/src/briefs/worker.md
CHANGED
|
@@ -17,25 +17,24 @@ inside your own worktree.
|
|
|
17
17
|
- **Your branch:** `{{BRANCH}}` — already created for you off the repo's default
|
|
18
18
|
branch. Never switch branches and never touch a path outside the worktree (write/edit/read/grep/glob are also blocked mechanically outside this checkout; `bash` is still a must-not — do not use it to escape).
|
|
19
19
|
|
|
20
|
-
**You
|
|
21
|
-
|
|
22
|
-
dispatcher
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
**You have this host's GitHub credentials, and you must not publish with them.**
|
|
21
|
+
Every push and every PR goes through the four `conductor_*` tools below, because
|
|
22
|
+
the dispatcher owns the settlement record: work it did not perform is work it
|
|
23
|
+
cannot account for, and the checks that would have refused a bad push never ran.
|
|
24
|
+
Reading with `gh` is fine; `git push`, `gh pr create` and `gh pr merge` are not
|
|
25
|
+
yours to run.
|
|
26
26
|
|
|
27
27
|
Read the issue first. **This brief is your copy of it**: the acceptance criteria
|
|
28
28
|
below were rendered from the issue at dispatch, so you already have what you
|
|
29
|
-
need.
|
|
30
|
-
|
|
29
|
+
need. One `gh` read is still worth the call, for the discussion the brief did
|
|
30
|
+
not copy down:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
gh issue view {{ISSUE_NUMBER}} --repo {{TRACKER_REPO}} --comments
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
If that command
|
|
37
|
-
|
|
38
|
-
blocker.
|
|
36
|
+
If that command fails, work from this brief and say so in your report rather
|
|
37
|
+
than treating it as a blocker.
|
|
39
38
|
|
|
40
39
|
Then read the repo's own guidance before writing anything: `AGENTS.md`,
|
|
41
40
|
`CLAUDE.md`, `CONTEXT.md`, and any `docs/adr/` entry the issue touches. Those
|
|
@@ -106,7 +105,8 @@ or the full test suite on this host. It is shared, and CI owns the heavy gates.
|
|
|
106
105
|
## Push and get to green
|
|
107
106
|
|
|
108
107
|
Your checkout is a git repository of its own. Committing is local and always
|
|
109
|
-
works; **publishing is the dispatcher's job**,
|
|
108
|
+
works; **publishing is the dispatcher's job**, and that is a rule, not a missing
|
|
109
|
+
credential.
|
|
110
110
|
|
|
111
111
|
1. One review pass over your **whole** diff (`git diff origin/HEAD...HEAD`).
|
|
112
112
|
Collect every finding, apply them all, then publish **once**.
|
|
@@ -115,7 +115,8 @@ works; **publishing is the dispatcher's job**, because you hold no credential.
|
|
|
115
115
|
git add -A && git commit -m "<type>: <what changed>"
|
|
116
116
|
```
|
|
117
117
|
No AI or co-author attribution. Never force-push. Never `git add -f`.
|
|
118
|
-
Do not run `git push
|
|
118
|
+
Do not run `git push`. It is not how your work ships, and a push the
|
|
119
|
+
dispatcher did not make is one it cannot account for.
|
|
119
120
|
3. Publish the branch with the `conductor_push` tool. The dispatcher fetches
|
|
120
121
|
your commits out of this repository and pushes them to GitHub fast-forward
|
|
121
122
|
only. If it reports a rejection, read the git error it hands back: a
|
|
@@ -142,7 +143,8 @@ works; **publishing is the dispatcher's job**, because you hold no credential.
|
|
|
142
143
|
|
|
143
144
|
Editing, building and testing are yours: ordinary `bash` inside your worktree,
|
|
144
145
|
unmediated. What leaves this machine is not. Four tools are the *only* route to
|
|
145
|
-
GitHub
|
|
146
|
+
GitHub you may take — another one exists on this host, and taking it is a
|
|
147
|
+
reportable breach rather than a shortcut:
|
|
146
148
|
|
|
147
149
|
| Tool | What it does |
|
|
148
150
|
| --- | --- |
|
|
@@ -162,8 +164,8 @@ Three things follow, and they are worth reading once rather than rediscovering:
|
|
|
162
164
|
remote moved: fetch, rebase in your worktree, push again.
|
|
163
165
|
- **A refusal is the real answer.** These tools decide in the dispatcher, not in
|
|
164
166
|
this prompt, and every call is recorded. When one refuses, it says exactly
|
|
165
|
-
why. Read it and act on it — do not retry it unchanged, and do not
|
|
166
|
-
|
|
167
|
+
why. Read it and act on it — do not retry it unchanged, and do not go round it
|
|
168
|
+
with `gh` or `git push`. Going round a refusal is itself reportable.
|
|
167
169
|
|
|
168
170
|
## You do not merge, release, or deploy
|
|
169
171
|
|
package/src/cli.ts
CHANGED
|
@@ -21,9 +21,8 @@ import {
|
|
|
21
21
|
proposeRetrofit,
|
|
22
22
|
repairPolicyBannerCrumbs,
|
|
23
23
|
} from "./brief-upgrade.ts";
|
|
24
|
-
import { findProject, loadConfig, resolveCaps,
|
|
24
|
+
import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
25
25
|
import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
|
|
26
|
-
import { boundarySetupScript } from "./credentials.ts";
|
|
27
26
|
import { runDaemon, setPaused } from "./daemon.ts";
|
|
28
27
|
import {
|
|
29
28
|
armTicks,
|
|
@@ -105,7 +104,6 @@ usage:
|
|
|
105
104
|
omp-conductor pause
|
|
106
105
|
omp-conductor resume
|
|
107
106
|
omp-conductor graph-setup [--project NAME] [--write]
|
|
108
|
-
omp-conductor boundary-setup [--slots N] [--daemon-user USER] [--shared-root PATH]
|
|
109
107
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
110
108
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
111
109
|
omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
|
|
@@ -202,14 +200,6 @@ usage:
|
|
|
202
200
|
current. --write writes the two units and the script they run, and
|
|
203
201
|
prints the systemctl line to run — it never runs systemctl itself.
|
|
204
202
|
Exits 1 when no repo in the project has graphProject configured.
|
|
205
|
-
boundary-setup
|
|
206
|
-
print the root commands that provision the credential boundary
|
|
207
|
-
(#125): the per-run slot accounts, the conductor-daemon and
|
|
208
|
-
conductor-runs groups, the 0711 shared root, and the daemon home's
|
|
209
|
-
mode. Prints; never runs them — it creates system accounts, so it is
|
|
210
|
-
your sudo and your chance to read it first. Needs no config, and
|
|
211
|
-
must run BEFORE setup, which writes worktree and mirror paths into
|
|
212
|
-
the root this creates. --slots defaults to maxConcurrentWorkers.
|
|
213
203
|
brief-upgrade
|
|
214
204
|
inspect the brief overlay (package floor + POLICY.md). Reports by
|
|
215
205
|
default. --migrate lifts a bannered ORCHESTRATOR.md owned half into
|
|
@@ -554,43 +544,6 @@ try {
|
|
|
554
544
|
break;
|
|
555
545
|
}
|
|
556
546
|
|
|
557
|
-
case "boundary-setup": {
|
|
558
|
-
// Printed, never executed: it creates system accounts, so it is the
|
|
559
|
-
// operator's `sudo` and their chance to read it first — same posture as
|
|
560
|
-
// `graph-setup`. Generated from the same constants the probe checks, so
|
|
561
|
-
// the instructions cannot drift from what the daemon then demands (#125).
|
|
562
|
-
//
|
|
563
|
-
// Nothing is read from the config unless a flag was omitted, and that is
|
|
564
|
-
// the point rather than an optimisation: per-run provisioning has to
|
|
565
|
-
// happen BEFORE `setup`, because setup writes worktree and mirror paths
|
|
566
|
-
// into the privileged shared root this command creates. Loading the
|
|
567
|
-
// config unconditionally would be a bootstrap cycle — provision needs a
|
|
568
|
-
// config, the config needs the provisioned root.
|
|
569
|
-
const slotsFlag = flag(argv, "slots");
|
|
570
|
-
let slots: number;
|
|
571
|
-
if (slotsFlag === undefined) {
|
|
572
|
-
const cfg = loadConfig();
|
|
573
|
-
slots = resolveCaps(findProject(cfg, flag(argv, "project")), cfg.defaults).maxConcurrentWorkers;
|
|
574
|
-
} else if (/^\d+$/.test(slotsFlag) && Number.parseInt(slotsFlag, 10) >= 1) {
|
|
575
|
-
// Whole string, not a prefix: `parseInt("2workers")` is 2, and silently
|
|
576
|
-
// provisioning a different pool than the operator typed is worse than
|
|
577
|
-
// refusing.
|
|
578
|
-
slots = Number.parseInt(slotsFlag, 10);
|
|
579
|
-
} else {
|
|
580
|
-
console.error(`boundary-setup: --slots must be a positive integer, got ${JSON.stringify(slotsFlag)}`);
|
|
581
|
-
process.exitCode = 2;
|
|
582
|
-
break;
|
|
583
|
-
}
|
|
584
|
-
// Both explicit where given, because neither can be inferred safely here.
|
|
585
|
-
// Under `sudo` the invoking user is root, so `userInfo()` would provision
|
|
586
|
-
// the wrong account and hand the daemon group to root; and the shared
|
|
587
|
-
// root must be the *same* string the daemon later resolves, not one
|
|
588
|
-
// derived from however this process happened to be invoked.
|
|
589
|
-
const daemonUser = flag(argv, "daemon-user") ?? process.env["SUDO_USER"] ?? userInfo().username;
|
|
590
|
-
const root = flag(argv, "shared-root") ?? sharedRoot();
|
|
591
|
-
process.stdout.write(boundarySetupScript({ slots, sharedRoot: root, daemonUser }));
|
|
592
|
-
break;
|
|
593
|
-
}
|
|
594
547
|
/**
|
|
595
548
|
* The action ledger (#126): every conductor-verb call, what it asked for,
|
|
596
549
|
* and how the daemon decided it.
|
package/src/config.ts
CHANGED
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
BASE_FRESHNESS,
|
|
21
21
|
BEHIND_BASE_ACTIONS,
|
|
22
22
|
CONFIG_VERSION,
|
|
23
|
-
CREDENTIAL_ISOLATIONS,
|
|
24
23
|
DEFAULT_AUTHORITY,
|
|
25
24
|
DEFAULT_CAPS,
|
|
26
25
|
DEFAULT_PROJECT_POLICY,
|
|
@@ -36,8 +35,6 @@ import {
|
|
|
36
35
|
REPORT_SCOPES,
|
|
37
36
|
type Caps,
|
|
38
37
|
type ConductorConfig,
|
|
39
|
-
type CredentialConfig,
|
|
40
|
-
type CredentialIsolation,
|
|
41
38
|
type MergePreconditions,
|
|
42
39
|
type PlanUsageCap,
|
|
43
40
|
type ProjectConfig,
|
|
@@ -70,7 +67,6 @@ const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
|
70
67
|
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
71
68
|
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
72
69
|
const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
|
|
73
|
-
const CREDENTIAL_ISOLATION_LIST = quoteList(CREDENTIAL_ISOLATIONS);
|
|
74
70
|
const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS);
|
|
75
71
|
const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES);
|
|
76
72
|
const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS);
|
|
@@ -107,45 +103,6 @@ export function stateDir(): string {
|
|
|
107
103
|
return dirname(configPath());
|
|
108
104
|
}
|
|
109
105
|
|
|
110
|
-
/**
|
|
111
|
-
* Root of everything a run principal must reach: worktrees, mirrors, per-run
|
|
112
|
-
* session transcripts and per-run boundary homes.
|
|
113
|
-
*
|
|
114
|
-
* **Outside the daemon's home, not a sibling of the state directory**, and both
|
|
115
|
-
* halves of that are load-bearing:
|
|
116
|
-
*
|
|
117
|
-
* - It cannot live *inside* {@link stateDir}, which stays `0700` because it
|
|
118
|
-
* holds `config.json`, `conductor.db` and the WAL/SHM files SQLite recreates
|
|
119
|
-
* at runtime with the process umask. Making it searchable — all a slot needs
|
|
120
|
-
* — would publish fleet, run and report history to every local account, and
|
|
121
|
-
* chmod-ing the leaves once would not hold.
|
|
122
|
-
* - It cannot live under the daemon's **home** either, which the boundary
|
|
123
|
-
* requires to be `0700` so a slot cannot reach `gh` config, `~/.ssh` or
|
|
124
|
-
* `~/.npmrc`. A shared root at `~/.omp/conductor-shared` is unreachable for
|
|
125
|
-
* exactly the same reason, and the two hardening steps would silently cancel
|
|
126
|
-
* each other out — the boundary would look correct and no run could start.
|
|
127
|
-
*
|
|
128
|
-
* So it is a system path: `0711` — traversable by a slot that already knows its
|
|
129
|
-
* own path, listable by nobody, writable by nobody but the daemon. Only used
|
|
130
|
-
* when a per-run boundary is active; an `isolation: "none"` fleet keeps every
|
|
131
|
-
* path exactly where it was (#125).
|
|
132
|
-
*
|
|
133
|
-
* `$OMP_CONDUCTOR_SHARED` overrides it, which is how tests and non-FHS hosts
|
|
134
|
-
* relocate it. Uninstall is two paths instead of one, and the README says so.
|
|
135
|
-
*/
|
|
136
|
-
export function sharedRoot(): string {
|
|
137
|
-
const override = process.env["OMP_CONDUCTOR_SHARED"];
|
|
138
|
-
if (override !== undefined && override.length > 0) return expandHome(override);
|
|
139
|
-
// Deliberately NOT derived from whether `$OMP_CONDUCTOR_HOME` happens to be
|
|
140
|
-
// set: generated systemd units set it even for a default install, so keying
|
|
141
|
-
// the security topology off it lets the CLI, setup and the daemon each pick a
|
|
142
|
-
// different root depending on how they were invoked. One explicit override,
|
|
143
|
-
// or the platform default.
|
|
144
|
-
// Not `/tmp`: a shared root a slot could rename or replace would hand one run
|
|
145
|
-
// control of another's checkout path.
|
|
146
|
-
return process.platform === "linux" ? "/var/lib/omp-conductor" : `${stateDir()}-shared`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
106
|
/**
|
|
150
107
|
* Where per-run worktrees and bare mirrors live when a project names neither.
|
|
151
108
|
*
|
|
@@ -154,14 +111,6 @@ export function sharedRoot(): string {
|
|
|
154
111
|
* worker checkouts even when the config was too broken to load and say where
|
|
155
112
|
* they are. Two copies of this default would mean a jail guarding the wrong
|
|
156
113
|
* directory on exactly the day the config is broken.
|
|
157
|
-
*
|
|
158
|
-
* `base` is what makes these usable under a per-run boundary. A slot principal
|
|
159
|
-
* cannot traverse the `0700` state directory, and dispatch now refuses rather
|
|
160
|
-
* than widening it — so an isolated fleet whose roots defaulted under the state
|
|
161
|
-
* dir would provision a repository and then fail before launching anything.
|
|
162
|
-
* Setup passes {@link sharedRoot} for those fleets and `stateDir()` for the
|
|
163
|
-
* rest, so an `isolation: "none"` install keeps every path exactly where it was
|
|
164
|
-
* (#125).
|
|
165
114
|
*/
|
|
166
115
|
export function defaultWorkspaceRoot(base: string = stateDir()): string {
|
|
167
116
|
return join(base, "worktrees");
|
|
@@ -170,18 +119,6 @@ export function defaultWorkspaceRoot(base: string = stateDir()): string {
|
|
|
170
119
|
export function defaultMirrorRoot(base: string = stateDir()): string {
|
|
171
120
|
return join(base, "mirrors");
|
|
172
121
|
}
|
|
173
|
-
/**
|
|
174
|
-
* The root a project's slot-accessible trees belong under, given its isolation.
|
|
175
|
-
*
|
|
176
|
-
* Only `per-run` moves. `group-mode` runs sessions as the daemon's *own* uid —
|
|
177
|
-
* it separates checkouts by group and mode, not by principal — so it can reach
|
|
178
|
-
* the state directory perfectly well, and sending it to a system path would
|
|
179
|
-
* demand privileged provisioning a group-mode host may deliberately not have.
|
|
180
|
-
* `none` never moves either.
|
|
181
|
-
*/
|
|
182
|
-
export function projectTreeBase(isolation: CredentialIsolation): string {
|
|
183
|
-
return isolation === "per-run" ? sharedRoot() : stateDir();
|
|
184
|
-
}
|
|
185
122
|
|
|
186
123
|
/**
|
|
187
124
|
* Reads, validates and normalises the config. Throws an `Error` naming the
|
|
@@ -481,7 +418,6 @@ function normalizeProject(
|
|
|
481
418
|
const escalation = normalizeEscalation(raw["escalation"], label, problems);
|
|
482
419
|
const authority = normalizeAuthority(raw["authority"], label, problems);
|
|
483
420
|
const releasePolicy = normalizeReleasePolicy(raw["releasePolicy"], label, problems);
|
|
484
|
-
const credentials = normalizeCredentials(raw["credentials"], label, problems);
|
|
485
421
|
const policy = normalizeProjectPolicy(raw["policy"], label, problems);
|
|
486
422
|
|
|
487
423
|
const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
|
|
@@ -510,18 +446,9 @@ function normalizeProject(
|
|
|
510
446
|
authority,
|
|
511
447
|
releasePolicy,
|
|
512
448
|
policy,
|
|
513
|
-
credentials,
|
|
514
449
|
reporting,
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
// otherwise resolve under the 0700 private tree and be refused at dispatch,
|
|
518
|
-
// having never gone through the setup wizard that knows better (#125).
|
|
519
|
-
workspaceRoot: expandHome(
|
|
520
|
-
pickString(raw["workspaceRoot"], defaultWorkspaceRoot(projectTreeBase(credentials.isolation))),
|
|
521
|
-
),
|
|
522
|
-
mirrorRoot: expandHome(
|
|
523
|
-
pickString(raw["mirrorRoot"], defaultMirrorRoot(projectTreeBase(credentials.isolation))),
|
|
524
|
-
),
|
|
450
|
+
workspaceRoot: expandHome(pickString(raw["workspaceRoot"], defaultWorkspaceRoot())),
|
|
451
|
+
mirrorRoot: expandHome(pickString(raw["mirrorRoot"], defaultMirrorRoot())),
|
|
525
452
|
};
|
|
526
453
|
}
|
|
527
454
|
|
|
@@ -841,159 +768,15 @@ function normalizeNameList(parsed: unknown, at: string, problems: string[]): str
|
|
|
841
768
|
return names;
|
|
842
769
|
}
|
|
843
770
|
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* The credential boundary, and the one migration that must never brick a fleet.
|
|
847
|
-
*
|
|
848
|
-
* `omp/systemd/omp-conductor.service.example` runs the daemon as an
|
|
849
|
-
* unprivileged account. A naive "fail closed unless per-run uids are available"
|
|
850
|
-
* release would therefore stop every *existing* fleet from dispatching on
|
|
851
|
-
* upgrade — a production outage caused by a security feature, which is a worse
|
|
852
|
-
* outcome than the day before. The issue's rule ("never the silent default")
|
|
853
|
-
* still holds; it is satisfied by a **migration** rather than by a default:
|
|
854
|
-
*
|
|
855
|
-
* 1. Key present → honoured exactly.
|
|
856
|
-
* 2. Key absent → resolves to `"none"`, and `migrateCredentialsOnDisk` rewrites
|
|
857
|
-
* the file so the answer is explicit on disk, exactly the way the v1→v2 caps
|
|
858
|
-
* migration normalises on load. The operator ends up with
|
|
859
|
-
* `"isolation": "none"` in their config plus one log line naming it, and
|
|
860
|
-
* nothing stops dispatching.
|
|
861
|
-
* 3. A new config from `setup` asks, and only offers `per-run` as the default
|
|
862
|
-
* when the host capability probe passes.
|
|
863
|
-
*
|
|
864
|
-
* A malformed value is rejected rather than folded to `"none"`, for the reason
|
|
865
|
-
* every other vocabulary here is: a misspelt `"per_run"` that silently resolved
|
|
866
|
-
* to unprotected would read as configured in the file while the fleet ran open.
|
|
867
|
-
*/
|
|
868
|
-
function normalizeCredentials(parsed: unknown, label: string, problems: string[]): CredentialConfig {
|
|
869
|
-
if (parsed === undefined) return { isolation: "none" };
|
|
870
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
871
|
-
problems.push(
|
|
872
|
-
`${label}: credentials must be an object with an "isolation" of ${CREDENTIAL_ISOLATION_LIST}, ` +
|
|
873
|
-
`found ${JSON.stringify(parsed)}`,
|
|
874
|
-
);
|
|
875
|
-
return { isolation: "none" };
|
|
876
|
-
}
|
|
877
|
-
const raw = parsed as Raw;
|
|
878
|
-
const isolation = pickLiteral(
|
|
879
|
-
raw["isolation"],
|
|
880
|
-
CREDENTIAL_ISOLATIONS,
|
|
881
|
-
"none",
|
|
882
|
-
`${label}: credentials.isolation`,
|
|
883
|
-
CREDENTIAL_ISOLATION_LIST,
|
|
884
|
-
problems,
|
|
885
|
-
);
|
|
886
|
-
const rawToken = raw["readToken"];
|
|
887
|
-
if (rawToken !== undefined && !nonEmptyString(rawToken)) {
|
|
888
|
-
problems.push(
|
|
889
|
-
`${label}: credentials.readToken must be a non-empty string when present — an empty token is not ` +
|
|
890
|
-
`"no token", it is a token that fails every call`,
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
return {
|
|
894
|
-
isolation,
|
|
895
|
-
...(nonEmptyString(rawToken) ? { readToken: rawToken } : {}),
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/**
|
|
900
|
-
* The effective boundary for a project. `loadConfig` always writes one, so this
|
|
901
|
-
* only matters for a `ProjectConfig` some caller hand-built — and it resolves
|
|
902
|
-
* the same way the loader does rather than inventing a second answer.
|
|
903
|
-
*/
|
|
904
|
-
export function resolveCredentials(p: ProjectConfig): CredentialConfig {
|
|
905
|
-
return p.credentials ?? { isolation: "none" };
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
/**
|
|
909
|
-
* Named projects whose on-disk config predates `credentials` — the input to the
|
|
910
|
-
* migration, and pure so it can be tested against a raw object.
|
|
911
|
-
*/
|
|
912
|
-
export function projectsMissingCredentials(parsed: unknown): string[] {
|
|
913
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
914
|
-
const projects = (parsed as Raw)["projects"];
|
|
915
|
-
if (!Array.isArray(projects)) return [];
|
|
916
|
-
const missing: string[] = [];
|
|
917
|
-
projects.forEach((p: unknown, i) => {
|
|
918
|
-
if (typeof p !== "object" || p === null || Array.isArray(p)) return;
|
|
919
|
-
const raw = p as Raw;
|
|
920
|
-
const credentials = raw["credentials"];
|
|
921
|
-
const explicit =
|
|
922
|
-
typeof credentials === "object" &&
|
|
923
|
-
credentials !== null &&
|
|
924
|
-
!Array.isArray(credentials) &&
|
|
925
|
-
(credentials as Raw)["isolation"] !== undefined;
|
|
926
|
-
if (!explicit) missing.push(nonEmptyString(raw["name"]) ? raw["name"] : `projects[${String(i)}]`);
|
|
927
|
-
});
|
|
928
|
-
return missing;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Write the migration through, once. Returns the projects that gained an
|
|
933
|
-
* explicit key, so the caller can log it by name.
|
|
934
|
-
*
|
|
935
|
-
* Separate from `loadConfig` on purpose: `loadConfig` is read-only and is called
|
|
936
|
-
* by every CLI surface, and a loader with a write side-effect is one an
|
|
937
|
-
* operator cannot run against a config they are only inspecting. A failure to
|
|
938
|
-
* persist is logged by the caller and is **not** fatal — the in-memory value is
|
|
939
|
-
* already `"none"`, and refusing to boot because the config file is read-only
|
|
940
|
-
* would be exactly the outage this migration exists to avoid.
|
|
941
|
-
*/
|
|
942
|
-
export function migrateCredentialsOnDisk(): { migrated: string[]; path: string } {
|
|
943
|
-
const path = configPath();
|
|
944
|
-
let parsed: unknown;
|
|
945
|
-
try {
|
|
946
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
947
|
-
} catch {
|
|
948
|
-
return { migrated: [], path };
|
|
949
|
-
}
|
|
950
|
-
const migrated = projectsMissingCredentials(parsed);
|
|
951
|
-
if (migrated.length === 0) return { migrated: [], path };
|
|
952
|
-
|
|
953
|
-
// A SURGICAL edit: add the one missing key to the projects that lack it and
|
|
954
|
-
// leave every other byte exactly as the operator wrote it.
|
|
955
|
-
//
|
|
956
|
-
// This was `saveConfig(loadConfig())` once, and that round-trip took a live
|
|
957
|
-
// fleet down. Normalising on the way out rewrites the whole file into the
|
|
958
|
-
// current dialect — `defaults.planUsage`, `releasePolicy` as a per-shape map
|
|
959
|
-
// — and the previous release rejects both. The daemon performing the
|
|
960
|
-
// migration is started *by* an upgrade still running the previous CLI, so
|
|
961
|
-
// that CLI's next `loadConfig` failed, and its rollback reinstalled a version
|
|
962
|
-
// which then could not read the file either. A migration must never make a
|
|
963
|
-
// config unreadable by the release you may have to roll back to.
|
|
964
|
-
// Verified against the real previous release, not just against this parser:
|
|
965
|
-
// omp-conductor@0.3.25 loads a config carrying `credentials` and prints
|
|
966
|
-
// `status` normally, because it ignores unknown *project* keys while
|
|
967
|
-
// rejecting unknown `defaults` keys and a non-string `releasePolicy` — the
|
|
968
|
-
// two things the old round-trip wrote. Re-check that with `npm install
|
|
969
|
-
// omp-conductor@<previous>` before widening what this writes.
|
|
970
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { migrated: [], path };
|
|
971
|
-
const projects = (parsed as Raw)["projects"];
|
|
972
|
-
if (!Array.isArray(projects)) return { migrated: [], path };
|
|
973
|
-
for (const entry of projects) {
|
|
974
|
-
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
975
|
-
const raw = entry as Raw;
|
|
976
|
-
const credentials = raw["credentials"];
|
|
977
|
-
const explicit =
|
|
978
|
-
typeof credentials === "object" &&
|
|
979
|
-
credentials !== null &&
|
|
980
|
-
!Array.isArray(credentials) &&
|
|
981
|
-
(credentials as Raw)["isolation"] !== undefined;
|
|
982
|
-
if (!explicit) (raw as Record<string, unknown>)["credentials"] = { isolation: "none" };
|
|
983
|
-
}
|
|
984
|
-
writeConfigFile(parsed);
|
|
985
|
-
return { migrated, path };
|
|
986
|
-
}
|
|
987
|
-
|
|
988
771
|
/**
|
|
989
|
-
* A clone URL carrying
|
|
772
|
+
* A clone URL carrying a password or token is rejected at load.
|
|
990
773
|
*
|
|
991
774
|
* `git clone` persists whatever is in the URL into the mirror's config, and the
|
|
992
775
|
* run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
* now rejected at load with the field named.
|
|
776
|
+
* writes the operator's credential into a file on disk that every later run
|
|
777
|
+
* reads, where nobody is looking for it. This used to be a `ponytail` note on
|
|
778
|
+
* `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it
|
|
779
|
+
* is now rejected at load with the field named.
|
|
997
780
|
*
|
|
998
781
|
* An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
|
|
999
782
|
* is not a credential and is left alone — that is how nearly every fleet is
|
package/src/confinement.ts
CHANGED
|
@@ -20,9 +20,8 @@
|
|
|
20
20
|
* `bash` is deliberately not confined here either: its input is an opaque shell
|
|
21
21
|
* string, and parsing it is a false sense of security. This gate does not
|
|
22
22
|
* contain a determined session, and no comment or doc here should claim
|
|
23
|
-
* otherwise — closing that gap
|
|
24
|
-
*
|
|
25
|
-
* README section, not a regex over `rm -rf`.
|
|
23
|
+
* otherwise — closing that gap would take a least-privilege OS principal for
|
|
24
|
+
* the worker, not a regex over `rm -rf`.
|
|
26
25
|
*
|
|
27
26
|
* What it does buy is a *legible* refusal that holds on hosts where no uid
|
|
28
27
|
* split will ever be deployed: a structured `edit` outside the assigned
|