omp-conductor 0.5.2 → 0.5.4
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 +35 -8
- package/package.json +1 -1
- package/src/config.ts +1 -1
- package/src/daemon.ts +26 -6
- package/src/gitops.ts +13 -10
- package/src/omp.ts +18 -5
- package/src/orchestrator.ts +5 -7
- package/src/release-policy.ts +6 -5
- package/src/tracker/github.ts +10 -11
- package/src/types.ts +3 -3
- package/src/verbs/actions.ts +12 -12
- package/src/verbs/client.ts +5 -6
- package/src/verbs/server.ts +66 -13
- package/src/verbs/socket.ts +90 -19
- package/src/worker.ts +3 -0
- package/src/worktree.ts +16 -14
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ tiers: first to an orchestrator session that can re-brief the worker, then to yo
|
|
|
8
8
|
|
|
9
9
|
You label an issue. Within one tick the conductor claims it on the tracker, cuts a
|
|
10
10
|
worktree, hands one omp worker a self-contained brief, and watches it to a green
|
|
11
|
-
PR. The worker then stops: it
|
|
11
|
+
PR. The worker then stops: it does not merge, tag, publish or deploy.
|
|
12
12
|
|
|
13
13
|
### Scope
|
|
14
14
|
|
|
@@ -2028,7 +2028,7 @@ dispatcher. The brief is explicit about the boundary:
|
|
|
2028
2028
|
| Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
|
|
2029
2029
|
| Run the repo's configured cheap gates, each from its listed `cwd`, over the whole tree. | Run docker or image builds, production builds, browser/e2e suites, or the full test suite on the shared host — CI owns the heavy gates. |
|
|
2030
2030
|
| Review its whole diff, then commit and publish once with `conductor_push`. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. There is no force path to reach: `conductor_push` publishes that run's branch fast-forward only and takes no other ref. Red twice means stop and report, not push a third time. |
|
|
2031
|
-
| Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker never
|
|
2031
|
+
| Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker is never authorised to merge**, whoever else holds the authority, so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. The verb refusal is mechanical, and so is the channel: each one is bound to the pid the daemon spawned, so reaching for the orchestrator's socket is refused rather than honoured (see [The transport](#the-transport)). Shelling out to `gh` remains a prohibition, not an impossibility — a session shares the daemon's credentials. |
|
|
2032
2032
|
| Escalate: ambiguity, a cross-repo contract, a needed credential, a product or data-migration decision, a blocking existing test, CI red twice, or most of the wall-clock budget burned. | Cut a release, push a tag, publish to npm, edit a deployment pin, deploy, or touch infrastructure or secrets. `conductor_release` and `conductor_label` refuse a worker whatever `releasePolicy` says, because the check compares the caller against the configured holder rather than ruling one value out. The in-session tripwire still blocks recognised release/deploy tool calls early and audits the attempt, but it is [defence in depth](#the-mediated-verbs-126), not the gate. |
|
|
2033
2033
|
|
|
2034
2034
|
The worker ends with a seven-line evidence report (issue, PR, observed head SHA,
|
|
@@ -2149,9 +2149,30 @@ every release shape under the most permissive config there is.
|
|
|
2149
2149
|
### The transport
|
|
2150
2150
|
|
|
2151
2151
|
Identity is never an argument. `project`, `run`, `issue` and the caller's role
|
|
2152
|
-
come from **which socket the call arrived on
|
|
2153
|
-
|
|
2154
|
-
|
|
2152
|
+
come from **which socket the call arrived on**, and a request carrying any of
|
|
2153
|
+
those field names is refused outright, named. So a worker on run X cannot *ask*
|
|
2154
|
+
to merge run Y's PR — on its own channel that request is unexpressible.
|
|
2155
|
+
|
|
2156
|
+
**Each channel is bound to one process, because the modes cannot tell sessions
|
|
2157
|
+
apart.** Every session runs as the daemon's own uid, so it matches the *owner*
|
|
2158
|
+
class here: it can list this directory and connect to any socket in it, including
|
|
2159
|
+
the orchestrator's. Authorisation and the ledger both read the role from the
|
|
2160
|
+
channel, so a worker doing that would have been authorised as the orchestrator
|
|
2161
|
+
(under `authority.merge: "orchestrator"`) *and recorded as* the orchestrator. No
|
|
2162
|
+
file mode closes that — the owner bits belong to the uid the session already has.
|
|
2163
|
+
|
|
2164
|
+
So the daemon binds each channel to the **pid it spawned for that session**, and
|
|
2165
|
+
refuses a connection from anything else without answering it, logged the way an
|
|
2166
|
+
impersonation is. Until a channel is bound it refuses everything, because the
|
|
2167
|
+
socket necessarily exists before the child that connects to it. The kernel
|
|
2168
|
+
supplies the pid: `SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS. A host where
|
|
2169
|
+
neither can be asked refuses every connection and says so at startup rather than
|
|
2170
|
+
falling back to the uid, which would be no check at all.
|
|
2171
|
+
|
|
2172
|
+
The residual is narrow, real, and worth stating: one uid can `ptrace` and signal
|
|
2173
|
+
its siblings, so a determined session can still interfere with the process that
|
|
2174
|
+
*is* bound. That is a far higher bar than connecting to a socket, and closing it
|
|
2175
|
+
needs separate OS principals.
|
|
2155
2176
|
|
|
2156
2177
|
```
|
|
2157
2178
|
<state dir>/verbs/ daemon-owned, mode 0711
|
|
@@ -2294,9 +2315,15 @@ Known and deliberate in this version:
|
|
|
2294
2315
|
than a silently dropped page. `delivered` means Telegram accepted an attempt,
|
|
2295
2316
|
not that exactly one message exists. See
|
|
2296
2317
|
[Report delivery](#report-delivery-the-outbox).
|
|
2297
|
-
- **Workers stop at green PRs.** They never merge, release or
|
|
2298
|
-
actions default to a human,
|
|
2299
|
-
`authority` never grants them to a worker or the dispatch
|
|
2318
|
+
- **Workers stop at green PRs.** They are never authorised to merge, release or
|
|
2319
|
+
deploy: those actions default to a human, and while setup may grant either to
|
|
2320
|
+
the orchestrator, `authority` never grants them to a worker or the dispatch
|
|
2321
|
+
daemon. The verbs refuse a worker mechanically, and a worker reaching for
|
|
2322
|
+
another session's channel is refused too — each channel is bound to the pid the
|
|
2323
|
+
daemon spawned for it. What remains a prohibition rather than a gate is shelling
|
|
2324
|
+
out to `gh` directly: sessions inherit the daemon's credentials. That shows up as
|
|
2325
|
+
a mutation with no matching ledger entry, which is a mismatch an operator can
|
|
2326
|
+
find.
|
|
2300
2327
|
- **The worker gate is partial, and the orchestrator has none.** A worker's
|
|
2301
2328
|
structured `write` / `edit` / `read` / `grep` / `glob` calls are gated to its
|
|
2302
2329
|
worktree by an inline harness extension; `bash` is not, so a shell one-liner
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/config.ts
CHANGED
|
@@ -816,7 +816,7 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
|
|
|
816
816
|
const credentialInUrl = cloneUrlCredentialProblem(cloneUrl);
|
|
817
817
|
if (credentialInUrl !== undefined) {
|
|
818
818
|
problems.push(
|
|
819
|
-
`${label}: routing.repos.${key}.cloneUrl ${credentialInUrl}
|
|
819
|
+
`${label}: routing.repos.${key}.cloneUrl ${credentialInUrl}. Use an SSH URL, or an https URL ` +
|
|
820
820
|
`backed by the daemon's own credential helper.`,
|
|
821
821
|
);
|
|
822
822
|
continue;
|
package/src/daemon.ts
CHANGED
|
@@ -140,7 +140,9 @@ interface Deps {
|
|
|
140
140
|
* Reads the connecting uid off a verb socket (#126). Resolved once at startup
|
|
141
141
|
* so the mechanism is logged before the first socket exists; absent on a host
|
|
142
142
|
* exposing no peer-credential call, where the `0600` socket under the
|
|
143
|
-
* daemon-owned `0711` parent is
|
|
143
|
+
* daemon-owned `0711` parent is all that keeps other local accounts out, and
|
|
144
|
+
* `status` says so. It never told two sessions apart — they share the daemon's
|
|
145
|
+
* uid.
|
|
144
146
|
*/
|
|
145
147
|
verbPeerReader?: PeerReader;
|
|
146
148
|
/**
|
|
@@ -564,7 +566,7 @@ export async function settleWorktree(
|
|
|
564
566
|
branch: string;
|
|
565
567
|
/**
|
|
566
568
|
* Publishes the run branch on the privileged side. Required rather than
|
|
567
|
-
* optional:
|
|
569
|
+
* optional: the run's commits live in a repository of its own,
|
|
568
570
|
* so a removal that did not publish first would delete the only copy —
|
|
569
571
|
* which is #121's data loss with one extra step. `undefined` is a visible
|
|
570
572
|
* decision at the call site, never an omission.
|
|
@@ -974,6 +976,14 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
974
976
|
// a child process of the daemon reaches it directly.
|
|
975
977
|
socketPath: join(sessionDir, "ipc.sock"),
|
|
976
978
|
verbSocketPath: verbListener.path,
|
|
979
|
+
// Names this run's channel the instant the child exists. Until this
|
|
980
|
+
// fires the channel refuses every connection, because a session that
|
|
981
|
+
// reached it first would be one nobody had identified — and every
|
|
982
|
+
// session shares this daemon's uid, so the socket alone cannot tell
|
|
983
|
+
// them apart (#163).
|
|
984
|
+
onSpawn: (pid) => {
|
|
985
|
+
verbListener?.bindPid(pid);
|
|
986
|
+
},
|
|
977
987
|
onChildLog: (line) => {
|
|
978
988
|
log(`#${issue} ${line}`);
|
|
979
989
|
},
|
|
@@ -3043,10 +3053,14 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3043
3053
|
const orchCwd = join(orchTreeRoot, "orchestrator");
|
|
3044
3054
|
mkdirSync(orchCwd, { recursive: true });
|
|
3045
3055
|
// A third socket, distinct from every run's, in the same daemon-owned
|
|
3046
|
-
// 0711 parent.
|
|
3047
|
-
//
|
|
3048
|
-
//
|
|
3049
|
-
//
|
|
3056
|
+
// 0711 parent. It makes the orchestrator's authority a property of the
|
|
3057
|
+
// channel rather than of a payload: no argument list can move a worker's
|
|
3058
|
+
// call onto this one (#126).
|
|
3059
|
+
//
|
|
3060
|
+
// It does NOT authenticate the session. Sessions share this daemon's uid,
|
|
3061
|
+
// so one could list this directory and connect here, and the ledger would
|
|
3062
|
+
// record the orchestrator's role because that is the channel's. See
|
|
3063
|
+
// conductor#163 for the pid binding that would close it.
|
|
3050
3064
|
orchestratorVerbs = await listenVerbChannel(
|
|
3051
3065
|
verbDeps({ project, store, tracker, verbActions }),
|
|
3052
3066
|
{
|
|
@@ -3063,6 +3077,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3063
3077
|
releaseGrants,
|
|
3064
3078
|
socketPath: join(orchCwd, "ipc.sock"),
|
|
3065
3079
|
verbSocketPath: orchestratorVerbs.path,
|
|
3080
|
+
// The orchestrator's channel is the one that matters most: it is the
|
|
3081
|
+
// session authorised to merge, so an unbound channel here is a worker's
|
|
3082
|
+
// route to that authority (#163).
|
|
3083
|
+
onSpawn: (pid) => {
|
|
3084
|
+
orchestratorVerbs?.bindPid(pid);
|
|
3085
|
+
},
|
|
3066
3086
|
onChildLog: (line) => {
|
|
3067
3087
|
log(`orchestrator ${line}`);
|
|
3068
3088
|
},
|
package/src/gitops.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The daemon's own git and `gh` operations.
|
|
3
3
|
*
|
|
4
|
-
* Everything here runs as the daemon, with the operator's credentials, and it
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
7
|
-
* answered by this file's call sites
|
|
8
|
-
* in the tree.
|
|
4
|
+
* Everything here runs as the daemon, with the operator's credentials, and it is
|
|
5
|
+
* the only place this package **constructs** credential material for a git or
|
|
6
|
+
* `gh` call of its own. That is the organising idea: "what does the conductor
|
|
7
|
+
* itself do with the operator's credential" is answered by this file's call sites
|
|
8
|
+
* rather than by auditing every `Bun.spawn` in the tree.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* daemon and
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* It is emphatically **not** a claim that nothing else can reach that credential.
|
|
11
|
+
* Sessions are child processes of the daemon and inherit its environment, so a
|
|
12
|
+
* session can reach whatever the daemon can — `omp.ts` spawns them and does not
|
|
13
|
+
* scrub anything. What keeps a run's publication accountable is that the
|
|
14
|
+
* dispatcher performs it: a push or a pull request the daemon did not make is a
|
|
15
|
+
* run it cannot account for, and is absent from the verb ledger. The mediated
|
|
16
|
+
* verbs route a worker's intent through here so the settlement record and the
|
|
17
|
+
* branch cannot disagree.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { join } from "node:path";
|
package/src/omp.ts
CHANGED
|
@@ -111,12 +111,13 @@ const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
|
|
|
111
111
|
* Start one omp coding session **in this process**, rooted at `cwd`, with a
|
|
112
112
|
* private agent registry and a file-backed transcript of its own.
|
|
113
113
|
*
|
|
114
|
-
* This is the far side of the boundary, not the dispatcher's entry point.
|
|
115
114
|
* Production callers want {@link createSession}, which runs this same function
|
|
116
|
-
* inside a child process
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
115
|
+
* inside a **child process**; the only caller of this one is `session-host.ts`,
|
|
116
|
+
* plus the tests that pin what it builds. Calling it directly from the daemon
|
|
117
|
+
* puts the harness back inside the dispatcher, where a crash, a hang or an
|
|
118
|
+
* exhausted heap takes the whole fleet down with it and no `MemoryMax` bounds
|
|
119
|
+
* it — that is what the child process buys. It is not a security boundary: the
|
|
120
|
+
* child runs as the daemon's own user.
|
|
120
121
|
*
|
|
121
122
|
* `sessionDir` chooses the directory the harness writes that transcript into;
|
|
122
123
|
* omitted, the harness picks its default location for `cwd`. Either way the
|
|
@@ -387,6 +388,14 @@ export interface CreateSessionOptions {
|
|
|
387
388
|
* session plumbing an attack surface for verbs.
|
|
388
389
|
*/
|
|
389
390
|
verbSocketPath?: string;
|
|
391
|
+
/**
|
|
392
|
+
* The child's pid, the instant it exists.
|
|
393
|
+
*
|
|
394
|
+
* Called synchronously after `Bun.spawn` and before the child could have
|
|
395
|
+
* connected to anything, because that ordering is the point: the daemon binds
|
|
396
|
+
* its verb channel to this pid, and a channel accepts nothing until it is bound.
|
|
397
|
+
*/
|
|
398
|
+
onSpawn?: (pid: number) => void;
|
|
390
399
|
/** Child stderr, line by line. Defaults to the process's own stderr. */
|
|
391
400
|
onChildLog?: (line: string) => void;
|
|
392
401
|
startupTimeoutMs?: number;
|
|
@@ -482,6 +491,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
482
491
|
stderr: "pipe",
|
|
483
492
|
});
|
|
484
493
|
|
|
494
|
+
// Synchronously, before a single await: the child cannot have connected yet, so
|
|
495
|
+
// the caller's channel is bound before it can be reached.
|
|
496
|
+
if (child.pid !== undefined) opts.onSpawn?.(child.pid);
|
|
497
|
+
|
|
485
498
|
let stderrTail = "";
|
|
486
499
|
const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
|
|
487
500
|
if (stream === undefined) return;
|
package/src/orchestrator.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type CreateSessionFn = (opts: {
|
|
|
47
47
|
role: SessionRole;
|
|
48
48
|
releaseGrants?: ResolvedGrants;
|
|
49
49
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
50
|
+
onSpawn?: (pid: number) => void;
|
|
50
51
|
socketPath?: string;
|
|
51
52
|
verbSocketPath?: string;
|
|
52
53
|
onChildLog?: (line: string) => void;
|
|
@@ -84,13 +85,9 @@ export interface OrchestratorOpts {
|
|
|
84
85
|
model?: string;
|
|
85
86
|
releaseGrants?: ResolvedGrants;
|
|
86
87
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
* must have no read or write access to any run checkout — a property the
|
|
91
|
-
* adversarial probe asserts rather than assumes.
|
|
92
|
-
*/
|
|
93
|
-
/** Control socket for the session child. See {@link OrchestratorOpts.boundary}. */
|
|
88
|
+
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
89
|
+
onSpawn?: (pid: number) => void;
|
|
90
|
+
/** Control socket for the session child, beside its own working directory. */
|
|
94
91
|
socketPath?: string;
|
|
95
92
|
/**
|
|
96
93
|
* The orchestrator's own verb socket (#126) — a third, distinct one, never
|
|
@@ -186,6 +183,7 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
|
|
|
186
183
|
role: "orchestrator",
|
|
187
184
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
188
185
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
186
|
+
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
189
187
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
190
188
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
191
189
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
package/src/release-policy.ts
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* It was the only mechanism there was, and it was never the one anybody should
|
|
9
9
|
* have had to rely on.
|
|
10
10
|
*
|
|
11
|
-
* What actually stops a release now is that the
|
|
12
|
-
*
|
|
13
|
-
* verbs,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* What actually stops a release now is that the daemon performs every one, and
|
|
12
|
+
* checks it first: the mediated verbs run their checks against the configured
|
|
13
|
+
* holder (`verbs/server.ts`), on the daemon's side of the socket. A worker that
|
|
14
|
+
* talks its way past every regex below has still not cut a release — and if it
|
|
15
|
+
* reaches for `gh` directly instead, the release is not in the ledger, which is
|
|
16
|
+
* the discrepancy an operator reads.
|
|
16
17
|
*
|
|
17
18
|
* It stays installed anyway, for the two things a tripwire is good at and a
|
|
18
19
|
* boundary is not: it refuses *early*, in the session, with an explanation the
|
package/src/tracker/github.ts
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* daemon never handles a token itself: credentials stay in the user's keychain
|
|
6
6
|
* or `gh` config and are never passed as argv, written to a file, or logged.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* The environment this adapter hands `gh` comes from `credentialedEnv()` — the
|
|
9
|
+
* one named construction site of credential material in the daemon — so "what in
|
|
10
|
+
* this package reaches the operator's GitHub credential" is answered by that
|
|
11
|
+
* function's call sites rather than by auditing every spawn in the tree. It is a
|
|
12
|
+
* legibility property, not a boundary: sessions run as the daemon's own user and
|
|
13
|
+
* can reach the same credential. What keeps a run's mutations accountable is
|
|
14
|
+
* that the daemon performs them, and records them.
|
|
15
15
|
*
|
|
16
16
|
* ponytail: shelling out to `gh` is the deliberate simplification. The ceiling
|
|
17
17
|
* is per-call cost (one process spawn plus one TLS handshake per operation,
|
|
@@ -151,10 +151,9 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
|
151
151
|
stdin: new Blob([stdin ?? ""]),
|
|
152
152
|
stdout: "pipe",
|
|
153
153
|
stderr: "pipe",
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// `credentialedEnv` finds this call.
|
|
154
|
+
// Explicit rather than inherited, so that a future change to this process's
|
|
155
|
+
// own environment could never silently break the tracker, and so that the
|
|
156
|
+
// grep for `credentialedEnv` finds this call.
|
|
158
157
|
env: credentialedEnv(),
|
|
159
158
|
});
|
|
160
159
|
|
package/src/types.ts
CHANGED
|
@@ -1324,9 +1324,9 @@ export const VERB_NAMES = [
|
|
|
1324
1324
|
"conductor_pr_merge",
|
|
1325
1325
|
"conductor_label",
|
|
1326
1326
|
"conductor_release",
|
|
1327
|
-
/** The one read verb.
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1327
|
+
/** The one read verb. It answers with the merge gate's own verdict, so
|
|
1328
|
+
* "pushed-green" is the dispatcher's reading of the PR rather than a claim the
|
|
1329
|
+
* worker makes about itself from whatever it happened to run. */
|
|
1330
1330
|
"conductor_pr_status",
|
|
1331
1331
|
] as const;
|
|
1332
1332
|
|
package/src/verbs/actions.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The privileged half of the verbs, wired to real commands (#126).
|
|
3
3
|
*
|
|
4
|
-
* This is the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* This is the only module in the verb path that performs a mutation, and it runs
|
|
5
|
+
* in the daemon. Push and pull-request creation are delegated to `gitops.ts`
|
|
6
|
+
* rather than re-shelled here, so there is one implementation of "how does a
|
|
7
|
+
* run's work reach the remote" — fast-forward only, no force path. The remaining
|
|
8
|
+
* verbs shell `gh` through {@link credentialedEnv} for the same reason: one
|
|
9
|
+
* named construction site, so its call sites are the audit.
|
|
10
10
|
*
|
|
11
11
|
* Kept apart from `server.ts` deliberately: every policy check above it is
|
|
12
12
|
* testable against the {@link VerbActions} interface with no repository, no
|
|
@@ -23,8 +23,8 @@ import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
|
23
23
|
*
|
|
24
24
|
* The three GitHub-shaped ones, and deliberately not `package-publish` or
|
|
25
25
|
* `deploy`: the daemon holds a GitHub credential and nothing else — no npm
|
|
26
|
-
* token, no registry login, no deploy key — and
|
|
27
|
-
*
|
|
26
|
+
* token, no registry login, no deploy key — and that is deliberate. A shape
|
|
27
|
+
* outside this list is refused by name in `server.ts` rather than
|
|
28
28
|
* attempted and reported as a command failure, because "we tried and it did not
|
|
29
29
|
* work" and "we were never able to do this" are different answers and only one
|
|
30
30
|
* of them tells an operator to go and do it themselves.
|
|
@@ -87,10 +87,10 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
|
|
|
87
87
|
return {
|
|
88
88
|
releasableShapes: GITHUB_RELEASABLE_SHAPES,
|
|
89
89
|
|
|
90
|
-
// Both
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
90
|
+
// Both live in `gitops.ts`, not here: they know the run's own repository
|
|
91
|
+
// layout, and they are the two paths whose fast-forward-only rule must have
|
|
92
|
+
// exactly one implementation. Re-shelling `git push` here would be a second
|
|
93
|
+
// one, and the second one is where a `--force` eventually appears.
|
|
94
94
|
push: (target) => pushRunBranch(project, target),
|
|
95
95
|
|
|
96
96
|
createPr: (target, opts) => openRunPr(project, target, opts),
|
package/src/verbs/client.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* The child-side half of the mutation verbs (#126): a thin client, and nothing
|
|
3
3
|
* else.
|
|
4
4
|
*
|
|
5
|
-
* This module runs **inside the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* can read, and on a bad day rewrite — so it decides nothing:
|
|
5
|
+
* This module runs **inside the session process**, as the daemon's own user, so
|
|
6
|
+
* it is code the model can read and on a bad day rewrite — and it can reach the
|
|
7
|
+
* same credentials the daemon can. That is exactly why it decides nothing:
|
|
9
8
|
*
|
|
10
9
|
* - **No policy branch.** It does not know what `authority.merge` says, which
|
|
11
10
|
* release shapes are granted, or whether the fleet is paused. Every answer
|
|
@@ -15,8 +14,8 @@
|
|
|
15
14
|
* There is nothing here to steal.
|
|
16
15
|
* - **No local fallback.** No socket means the verb fails, loudly, with an
|
|
17
16
|
* explanation. A client that fell back to `git push` when the daemon was
|
|
18
|
-
* unreachable would
|
|
19
|
-
*
|
|
17
|
+
* unreachable would leave the settlement record silently incomplete, and it
|
|
18
|
+
* would do so precisely when something had already gone wrong.
|
|
20
19
|
*
|
|
21
20
|
* The identity fields are absent by construction rather than stripped: this
|
|
22
21
|
* client has no project, run or role to send, because it was never told one.
|
package/src/verbs/server.ts
CHANGED
|
@@ -2,18 +2,32 @@
|
|
|
2
2
|
* The privileged half of the mutation verbs (#126): every check, in the daemon.
|
|
3
3
|
*
|
|
4
4
|
* The child-side tool handler forwards arguments and renders an answer. It
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* other side of the socket.
|
|
5
|
+
* evaluates no policy and writes no ledger entry, because it runs inside the
|
|
6
|
+
* session process — anything it decided would be decided by code the model can
|
|
7
|
+
* rewrite, and a session runs as the daemon's own user. Everything below runs on
|
|
8
|
+
* the other side of the socket, which is what makes the checks real.
|
|
9
9
|
*
|
|
10
10
|
* Four rules shape the order of the checks, and each is here because prose
|
|
11
11
|
* alone did not hold it on `main`:
|
|
12
12
|
*
|
|
13
|
-
* - **Identity is the channel's
|
|
14
|
-
* {@link VerbChannel} — which
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* - **Identity is the channel's, and the channel is bound to one process.**
|
|
14
|
+
* `project`, `run`, `issue` and role come from {@link VerbChannel} — which
|
|
15
|
+
* socket the call arrived on. No payload can name them, so a *request* to act
|
|
16
|
+
* as another run is unexpressible.
|
|
17
|
+
*
|
|
18
|
+
* That alone was never authentication: every session runs as the daemon's own
|
|
19
|
+
* uid, so any of them could list the socket directory and connect to another
|
|
20
|
+
* channel — including the orchestrator's, which holds merge under
|
|
21
|
+
* `authority.merge: "orchestrator"` — and the ledger would have recorded the
|
|
22
|
+
* channel's role, attributing it to the orchestrator. A file mode cannot fix
|
|
23
|
+
* that, because the owner bits belong to the uid the session already has.
|
|
24
|
+
*
|
|
25
|
+
* So each channel is **bound to the pid the daemon spawned for it**, and a
|
|
26
|
+
* connection from any other process is refused unanswered and logged. Until it
|
|
27
|
+
* is bound it refuses everything, because the socket necessarily exists before
|
|
28
|
+
* the child does. The residual is narrow and real: one uid can still `ptrace`
|
|
29
|
+
* or signal its siblings, so a determined session can interfere with the
|
|
30
|
+
* process that *is* bound — a much higher bar than connecting to a socket.
|
|
17
31
|
* - **Compared against the configured holder, never against one forbidden
|
|
18
32
|
* value.** `authority` has exactly two holders, so `!== "human"` would let a
|
|
19
33
|
* worker release. Merge and release both ask "is the caller the holder".
|
|
@@ -53,6 +67,7 @@ import {
|
|
|
53
67
|
unlinkStaleSocket,
|
|
54
68
|
validateSocketPath,
|
|
55
69
|
type PeerReader,
|
|
70
|
+
type PeerVerdict,
|
|
56
71
|
type SocketOwnership,
|
|
57
72
|
} from "./socket.ts";
|
|
58
73
|
|
|
@@ -819,7 +834,7 @@ async function releaseVerb(
|
|
|
819
834
|
return refuse(
|
|
820
835
|
"release-shape-not-executable",
|
|
821
836
|
`refused: this daemon cannot cut a ${shape}. It holds a GitHub credential and nothing else — no npm token, ` +
|
|
822
|
-
"no deploy key — and
|
|
837
|
+
"no deploy key — and that is deliberate. It can do " +
|
|
823
838
|
`${deps.actions.releasableShapes.join(", ")}. Escalate this one to your operator.`,
|
|
824
839
|
);
|
|
825
840
|
}
|
|
@@ -843,8 +858,8 @@ async function releaseVerb(
|
|
|
843
858
|
|
|
844
859
|
|
|
845
860
|
/**
|
|
846
|
-
* The one read verb, and the reason a
|
|
847
|
-
*
|
|
861
|
+
* The one read verb, and the reason a worker reports a PR's real state rather
|
|
862
|
+
* than its own recollection.
|
|
848
863
|
*
|
|
849
864
|
* Reuses `Tracker.verifyPr`, so the answer is literally the merge gate's own
|
|
850
865
|
* verdict rather than a second opinion assembled here — including the
|
|
@@ -915,6 +930,17 @@ async function prStatusVerb(
|
|
|
915
930
|
export interface VerbListener {
|
|
916
931
|
path: string;
|
|
917
932
|
ownership: SocketOwnership;
|
|
933
|
+
/**
|
|
934
|
+
* Name the process this channel belongs to.
|
|
935
|
+
*
|
|
936
|
+
* Called with the child's pid the moment the daemon has spawned it, and the
|
|
937
|
+
* channel refuses every connection until it is: the socket has to exist before
|
|
938
|
+
* the child can connect to it, so there is a window between `listen` and the
|
|
939
|
+
* spawn, and a session that connected in that window would be one nobody had
|
|
940
|
+
* identified. `Bun.spawn` returns the pid synchronously, so the window closes
|
|
941
|
+
* before the child has finished booting.
|
|
942
|
+
*/
|
|
943
|
+
bindPid(pid: number): void;
|
|
918
944
|
close(): Promise<void>;
|
|
919
945
|
}
|
|
920
946
|
|
|
@@ -964,8 +990,13 @@ export async function listenVerbChannel(
|
|
|
964
990
|
// here and a race in a world-writable directory.
|
|
965
991
|
unlinkStaleSocket(channel.path);
|
|
966
992
|
|
|
993
|
+
// Unbound until the daemon names the child. Read through a closure rather than
|
|
994
|
+
// captured by value so the connection handler sees the binding that exists when
|
|
995
|
+
// a connection arrives, not the one that existed when the server was created.
|
|
996
|
+
let boundPid: number | undefined;
|
|
997
|
+
|
|
967
998
|
const server: Server = createServer((socket) => {
|
|
968
|
-
handleConnection(deps, channel, socket, opts.peerReader);
|
|
999
|
+
handleConnection(deps, channel, socket, opts.peerReader, () => boundPid);
|
|
969
1000
|
});
|
|
970
1001
|
server.on("error", (err) => {
|
|
971
1002
|
deps.log(`verb socket ${channel.path} errored: ${err.message}`);
|
|
@@ -985,6 +1016,9 @@ export async function listenVerbChannel(
|
|
|
985
1016
|
return {
|
|
986
1017
|
path: channel.path,
|
|
987
1018
|
ownership,
|
|
1019
|
+
bindPid: (pid) => {
|
|
1020
|
+
boundPid = pid;
|
|
1021
|
+
},
|
|
988
1022
|
close: async () => {
|
|
989
1023
|
await new Promise<void>((resolve) => {
|
|
990
1024
|
server.close(() => {
|
|
@@ -1001,10 +1035,29 @@ function handleConnection(
|
|
|
1001
1035
|
channel: VerbChannel,
|
|
1002
1036
|
socket: Socket,
|
|
1003
1037
|
peerReader: PeerReader | undefined,
|
|
1038
|
+
boundPid: () => number | undefined,
|
|
1004
1039
|
): void {
|
|
1005
1040
|
const fd = socketFd(socket);
|
|
1006
1041
|
const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
|
|
1007
|
-
const
|
|
1042
|
+
const expectedPid = boundPid();
|
|
1043
|
+
// Refused until the daemon has named the process this channel belongs to.
|
|
1044
|
+
//
|
|
1045
|
+
// The socket must exist before the child can connect to it, so there is a real
|
|
1046
|
+
// window between `listen` and the spawn — and any session already running shares
|
|
1047
|
+
// this daemon's uid, so one of them could reach a freshly bound orchestrator
|
|
1048
|
+
// channel in that window and be authorised as the orchestrator. Nothing about
|
|
1049
|
+
// the socket distinguishes them; only the pid does (#163).
|
|
1050
|
+
const verdict: PeerVerdict =
|
|
1051
|
+
expectedPid === undefined
|
|
1052
|
+
? {
|
|
1053
|
+
ok: false,
|
|
1054
|
+
peerUid: peer?.uid ?? -1,
|
|
1055
|
+
expectedUid: process.getuid?.() ?? 0,
|
|
1056
|
+
detail:
|
|
1057
|
+
"this channel has not been bound to a process yet, so the caller cannot be identified — " +
|
|
1058
|
+
"every session on this host shares the daemon's uid",
|
|
1059
|
+
}
|
|
1060
|
+
: peerVerdict({ uid: process.getuid?.() ?? 0, pid: expectedPid }, peer);
|
|
1008
1061
|
if (!verdict.ok) {
|
|
1009
1062
|
// Not a client error, and deliberately not answered: a caller who is not
|
|
1010
1063
|
// who the socket was allocated to gets no reply to calibrate against.
|
package/src/verbs/socket.ts
CHANGED
|
@@ -236,6 +236,18 @@ export function secureBoundSocket(
|
|
|
236
236
|
export interface PeerCredentials {
|
|
237
237
|
uid: number;
|
|
238
238
|
gid: number;
|
|
239
|
+
/**
|
|
240
|
+
* The connecting process, as the kernel reports it.
|
|
241
|
+
*
|
|
242
|
+
* The uid alone stopped meaning anything the moment sessions began sharing the
|
|
243
|
+
* daemon's account: every session satisfies it. The pid is what distinguishes
|
|
244
|
+
* them, because the daemon spawned each one and knows which is which.
|
|
245
|
+
*
|
|
246
|
+
* `undefined` only when the platform call failed. It is not optional in the
|
|
247
|
+
* sense of "nice to have" — {@link peerVerdict} refuses a channel whose pid it
|
|
248
|
+
* expected and could not read.
|
|
249
|
+
*/
|
|
250
|
+
pid?: number;
|
|
239
251
|
}
|
|
240
252
|
|
|
241
253
|
/**
|
|
@@ -243,9 +255,9 @@ export interface PeerCredentials {
|
|
|
243
255
|
* `undefined` where the platform (or this runtime's socket object) does not
|
|
244
256
|
* expose them.
|
|
245
257
|
*
|
|
246
|
-
* `
|
|
247
|
-
* which is the point: the answer cannot be forged by the peer,
|
|
248
|
-
* the peer could put in a payload.
|
|
258
|
+
* `SO_PEERCRED` on Linux, `getpeereid` plus `LOCAL_PEERPID` on darwin — all asked
|
|
259
|
+
* of the kernel, which is the point: the answer cannot be forged by the peer,
|
|
260
|
+
* unlike anything the peer could put in a payload.
|
|
249
261
|
*/
|
|
250
262
|
export type PeerReader = (fd: number) => PeerCredentials | undefined;
|
|
251
263
|
|
|
@@ -258,6 +270,10 @@ interface LibcSymbols {
|
|
|
258
270
|
const SOL_SOCKET_LINUX = 1;
|
|
259
271
|
const SO_PEERCRED_LINUX = 17;
|
|
260
272
|
|
|
273
|
+
/** `SOL_LOCAL` / `LOCAL_PEERPID` from darwin's `sys/un.h`. Also ABI constants. */
|
|
274
|
+
const SOL_LOCAL_DARWIN = 0;
|
|
275
|
+
const LOCAL_PEERPID_DARWIN = 0x002;
|
|
276
|
+
|
|
261
277
|
/**
|
|
262
278
|
* The peer reader for this host, or `undefined` when there is none.
|
|
263
279
|
*
|
|
@@ -271,7 +287,14 @@ const SO_PEERCRED_LINUX = 17;
|
|
|
271
287
|
export function peerCredentialReader(): PeerReader | undefined {
|
|
272
288
|
const declarations: Record<string, { args: FFIType[]; returns: FFIType }> =
|
|
273
289
|
process.platform === "darwin"
|
|
274
|
-
? {
|
|
290
|
+
? {
|
|
291
|
+
getpeereid: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
292
|
+
// For LOCAL_PEERPID: `getpeereid` answers uid and gid, and nothing else.
|
|
293
|
+
getsockopt: {
|
|
294
|
+
args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
|
295
|
+
returns: FFIType.i32,
|
|
296
|
+
},
|
|
297
|
+
}
|
|
275
298
|
: {
|
|
276
299
|
getsockopt: {
|
|
277
300
|
args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
|
@@ -302,12 +325,21 @@ export function peerCredentialReader(): PeerReader | undefined {
|
|
|
302
325
|
|
|
303
326
|
if (process.platform === "darwin") {
|
|
304
327
|
const getpeereid = symbols.getpeereid;
|
|
328
|
+
const getsockoptDarwin = symbols.getsockopt;
|
|
305
329
|
if (getpeereid === undefined) return undefined;
|
|
306
330
|
return (fd) => {
|
|
307
331
|
const uid = new Uint32Array(1);
|
|
308
332
|
const gid = new Uint32Array(1);
|
|
309
333
|
if (getpeereid(fd, ptr(uid), ptr(gid)) !== 0) return undefined;
|
|
310
|
-
|
|
334
|
+
let pid: number | undefined;
|
|
335
|
+
if (getsockoptDarwin !== undefined) {
|
|
336
|
+
const out = new Uint32Array(1);
|
|
337
|
+
const len = new Uint32Array([out.byteLength]);
|
|
338
|
+
if (getsockoptDarwin(fd, SOL_LOCAL_DARWIN, LOCAL_PEERPID_DARWIN, ptr(out), ptr(len)) === 0) {
|
|
339
|
+
pid = out[0];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { uid: uid[0] ?? -1, gid: gid[0] ?? -1, ...(pid === undefined ? {} : { pid }) };
|
|
311
343
|
};
|
|
312
344
|
}
|
|
313
345
|
|
|
@@ -320,7 +352,8 @@ export function peerCredentialReader(): PeerReader | undefined {
|
|
|
320
352
|
if (getsockopt(fd, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, ptr(cred), ptr(len)) !== 0) {
|
|
321
353
|
return undefined;
|
|
322
354
|
}
|
|
323
|
-
|
|
355
|
+
// struct ucred's first field is the pid, and it has been read all along.
|
|
356
|
+
return { uid: cred[1] ?? -1, gid: cred[2] ?? -1, ...(cred[0] === undefined ? {} : { pid: cred[0] }) };
|
|
324
357
|
};
|
|
325
358
|
}
|
|
326
359
|
|
|
@@ -339,36 +372,51 @@ export function socketFd(socket: unknown): number | undefined {
|
|
|
339
372
|
}
|
|
340
373
|
|
|
341
374
|
export type PeerVerdict =
|
|
375
|
+
| { ok: true; basis: "peer-pid"; uid: number; pid: number }
|
|
342
376
|
| { ok: true; basis: "peer-uid"; uid: number }
|
|
343
377
|
| { ok: true; basis: "socket-ownership"; why: string }
|
|
344
378
|
| { ok: false; detail: string; peerUid: number; expectedUid: number };
|
|
345
379
|
|
|
346
380
|
/**
|
|
347
|
-
* Compare the kernel-reported peer against the
|
|
381
|
+
* Compare the kernel-reported peer against the session the daemon launched.
|
|
382
|
+
*
|
|
383
|
+
* The three `ok` bases are different guarantees and are deliberately not
|
|
384
|
+
* collapsed:
|
|
385
|
+
*
|
|
386
|
+
* - `peer-pid` — the kernel says this connection comes from the exact process the
|
|
387
|
+
* daemon spawned for this channel. This is the only basis that distinguishes one
|
|
388
|
+
* session from another, because they all share the daemon's uid.
|
|
389
|
+
* - `peer-uid` — the uid matched but no pid was expected for this channel. True of
|
|
390
|
+
* a channel nothing was bound to, which in production is a bug and in a test is
|
|
391
|
+
* the case under test.
|
|
392
|
+
* - `socket-ownership` — nobody could vouch at all, and the whole guarantee is the
|
|
393
|
+
* `0600` socket under an unlistable, daemon-owned parent. Real against other
|
|
394
|
+
* accounts, and no help between sessions.
|
|
395
|
+
*
|
|
396
|
+
* A caller that printed "verified" for all three would be claiming something it
|
|
397
|
+
* never checked, which is exactly how the channel came to be described as
|
|
398
|
+
* authentication when it was only ever context.
|
|
348
399
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* socket under an unlistable, daemon-owned, non-writable parent — which is
|
|
353
|
-
* real, but it does not distinguish two runs sharing one uid. A caller that
|
|
354
|
-
* printed "verified" for both would be claiming something it never checked.
|
|
400
|
+
* **Fail closed on doubt.** If a pid was expected and the kernel would not answer,
|
|
401
|
+
* that is a refusal: on a host where the check normally works, "could not ask"
|
|
402
|
+
* arriving on the one connection that matters is not a reason to allow it.
|
|
355
403
|
*/
|
|
356
404
|
export function peerVerdict(
|
|
357
|
-
expected: { uid: number } | undefined,
|
|
405
|
+
expected: { uid: number; pid?: number } | undefined,
|
|
358
406
|
peer: PeerCredentials | undefined,
|
|
359
407
|
): PeerVerdict {
|
|
360
408
|
if (expected === undefined) {
|
|
361
409
|
return {
|
|
362
410
|
ok: true,
|
|
363
411
|
basis: "socket-ownership",
|
|
364
|
-
why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is
|
|
412
|
+
why: "no uid was named for this socket, so the 0600 mode under the daemon-owned 0711 parent is all that keeps other local accounts out",
|
|
365
413
|
};
|
|
366
414
|
}
|
|
367
415
|
if (peer === undefined) {
|
|
368
416
|
return {
|
|
369
417
|
ok: true,
|
|
370
418
|
basis: "socket-ownership",
|
|
371
|
-
why: "this host exposes no peer
|
|
419
|
+
why: "this host exposes no peer-credential call, so the 0600 socket under the daemon-owned 0711 parent is all that keeps other local accounts out",
|
|
372
420
|
};
|
|
373
421
|
}
|
|
374
422
|
if (peer.uid !== expected.uid) {
|
|
@@ -379,6 +427,29 @@ export function peerVerdict(
|
|
|
379
427
|
detail: `uid ${peer.uid} connected to a socket allocated to uid ${expected.uid}`,
|
|
380
428
|
};
|
|
381
429
|
}
|
|
430
|
+
if (expected.pid !== undefined) {
|
|
431
|
+
if (peer.pid === undefined) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
peerUid: peer.uid,
|
|
435
|
+
expectedUid: expected.uid,
|
|
436
|
+
detail:
|
|
437
|
+
`this channel belongs to pid ${expected.pid} and the kernel would not report the caller's pid, ` +
|
|
438
|
+
`so the session could not be identified — every session shares this uid`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
if (peer.pid !== expected.pid) {
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
peerUid: peer.uid,
|
|
445
|
+
expectedUid: expected.uid,
|
|
446
|
+
detail:
|
|
447
|
+
`pid ${peer.pid} connected to a channel belonging to pid ${expected.pid} — same uid, ` +
|
|
448
|
+
`different session, so this is another session assuming this one's authority`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, basis: "peer-pid", uid: peer.uid, pid: peer.pid };
|
|
452
|
+
}
|
|
382
453
|
return { ok: true, basis: "peer-uid", uid: peer.uid };
|
|
383
454
|
}
|
|
384
455
|
|
|
@@ -386,10 +457,10 @@ export function peerVerdict(
|
|
|
386
457
|
export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
|
|
387
458
|
const peers =
|
|
388
459
|
peerReader === undefined
|
|
389
|
-
? `no peer-credential call on ${process.platform}`
|
|
460
|
+
? `no peer-credential call on ${process.platform} — channels REFUSE every connection`
|
|
390
461
|
: process.platform === "darwin"
|
|
391
|
-
? "
|
|
392
|
-
: "
|
|
462
|
+
? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched"
|
|
463
|
+
: "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched";
|
|
393
464
|
return (
|
|
394
465
|
`verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
|
|
395
466
|
`each socket 0600 under the daemon's own uid; ${peers}`
|
package/src/worker.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface WorkerOpts {
|
|
|
53
53
|
releaseGrants?: ResolvedGrants;
|
|
54
54
|
/** Durable audit sink for rejected release/deploy calls. */
|
|
55
55
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
56
|
+
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
57
|
+
onSpawn?: (pid: number) => void;
|
|
56
58
|
/** Control socket for that child, beside the run's own session directory. */
|
|
57
59
|
socketPath?: string;
|
|
58
60
|
/**
|
|
@@ -195,6 +197,7 @@ export async function runWorker(
|
|
|
195
197
|
role: "worker",
|
|
196
198
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
197
199
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
200
|
+
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
198
201
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
199
202
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
200
203
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
package/src/worktree.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* once and refreshed, rather than a fresh full clone per issue: a module repo's
|
|
7
7
|
* history is fetched one time and every later run pays only for the delta.
|
|
8
8
|
*
|
|
9
|
-
* **A run gets its own repository, not a linked worktree of the mirror, and
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* **A run gets its own repository, not a linked worktree of the mirror, and that
|
|
10
|
+
* is load-bearing rather than a preference.** A linked worktree shares the
|
|
11
|
+
* mirror's common dir — refs, objects, index locks — so every run would be
|
|
12
|
+
* writing into one store: concurrent runs contend on the same index lock, a
|
|
13
|
+
* `git gc` in one can pull objects from under another, and a run's refs are one
|
|
14
|
+
* `git update-ref` away from a sibling's. Here the objects arrive read-only through git's alternates
|
|
14
15
|
* mechanism and the run's refs, index and commits live in its own git dir. The
|
|
15
16
|
* documented residual is that a run can still *read* another run's objects out
|
|
16
17
|
* of the shared store: bounded, same source, no write path, no credential, and
|
|
@@ -61,9 +62,9 @@ async function runGit(
|
|
|
61
62
|
stdin: "ignore",
|
|
62
63
|
stdout: "pipe",
|
|
63
64
|
stderr: "pipe",
|
|
64
|
-
// Every git call in this module
|
|
65
|
-
//
|
|
66
|
-
//
|
|
65
|
+
// Every git call in this module is the daemon's own — provisioning,
|
|
66
|
+
// salvaging or publishing — so it goes through the one named construction
|
|
67
|
+
// site of credential material. It also carries
|
|
67
68
|
// GIT_TERMINAL_PROMPT=0, because an unattended dispatcher must fail loudly
|
|
68
69
|
// rather than block forever on a prompt nobody is there to answer.
|
|
69
70
|
env: credentialedEnv(),
|
|
@@ -287,8 +288,8 @@ export async function ensureMirror(
|
|
|
287
288
|
* git's own read-only borrowing mechanism — so provisioning still costs one
|
|
288
289
|
* fetch of the delta rather than a full clone, exactly as the linked worktree
|
|
289
290
|
* it replaces did. What changed is who can write what: the run's refs, index
|
|
290
|
-
* and commits are its own, and
|
|
291
|
-
* or the mirror
|
|
291
|
+
* and commits are its own, and no ordinary git operation in the checkout reaches
|
|
292
|
+
* a sibling run's refs or the mirror.
|
|
292
293
|
*
|
|
293
294
|
* `origin` is deliberately the real clone URL and not the mirror path. Pointing
|
|
294
295
|
* it at the mirror would make a worker's ordinary `git push origin HEAD`
|
|
@@ -553,9 +554,9 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
|
|
|
553
554
|
* commit and re-runs its checks. That is the price of work outliving its host.
|
|
554
555
|
*
|
|
555
556
|
* `publish` is a **required** parameter rather than an optional one with a
|
|
556
|
-
* no-op default, and that is the whole design. The salvage commit
|
|
557
|
-
*
|
|
558
|
-
*
|
|
557
|
+
* no-op default, and that is the whole design. The salvage commit does not push
|
|
558
|
+
* for itself: the network hop is the daemon's, through `pushRunBranch`, so the
|
|
559
|
+
* sha that reaches the remote is the sha the dispatcher recorded. A defaulted parameter
|
|
559
560
|
* is how a future call site silently stops publishing salvaged work and nobody
|
|
560
561
|
* finds out until the host that held it is gone — which is #121's failure with
|
|
561
562
|
* one extra step. Passing `undefined` is allowed, and is a visible decision at
|
|
@@ -676,7 +677,8 @@ export async function removeWorktree(
|
|
|
676
677
|
if (existsSync(worktreePath)) {
|
|
677
678
|
// A run repo is a plain directory now, so removal is a plain removal — but
|
|
678
679
|
// the `worktree remove` is still attempted first, because a fleet upgrading
|
|
679
|
-
//
|
|
680
|
+
// from a release that used linked worktrees has live trees that ARE linked
|
|
681
|
+
// worktrees of this mirror, and
|
|
680
682
|
// deleting one of those without deregistering it leaves the mirror
|
|
681
683
|
// believing the branch is still checked out. It refuses `worktree add` and,
|
|
682
684
|
// worse, refuses to delete the branch at cleanup. The failure is expected
|