@brutalsystems/muster 0.11.0 → 0.11.1
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 +22 -13
- package/dist/config.js +30 -1
- package/dist/identity/processes.js +10 -0
- package/dist/run.js +509 -308
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
|
|
|
120
120
|
|
|
121
121
|
`run` accepts an optional `--` followed by runtime arguments. The normal
|
|
122
122
|
allowlist is `--model` / `-m`, plus Claude's `--effort` and OpenCode's `--agent`
|
|
123
|
-
and `--variant`. For Claude and Codex a model named there is
|
|
123
|
+
and `--variant`. For Claude and Codex a model named there is _consumed_ rather
|
|
124
124
|
than forwarded — it reaches the child through the same path as `--model`, so it
|
|
125
125
|
appears once — and naming a model both ways is refused rather than resolved by
|
|
126
126
|
precedence. Unknown options, bundled short options, config injection and
|
|
@@ -212,7 +212,7 @@ and exit-status lifecycle as other tasks.
|
|
|
212
212
|
|
|
213
213
|
## Repeating a launch request
|
|
214
214
|
|
|
215
|
-
`--request-key KEY` (MCP: `requestKey`) names the launch
|
|
215
|
+
`--request-key KEY` (MCP: `requestKey`) names the launch _request_, not the
|
|
216
216
|
session it produces. Sending the same key again returns the launch it already
|
|
217
217
|
produced instead of starting a second agent:
|
|
218
218
|
|
|
@@ -233,12 +233,12 @@ computed before validation would disagree with itself.
|
|
|
233
233
|
|
|
234
234
|
What a repeat returns depends on what the first attempt did:
|
|
235
235
|
|
|
236
|
-
| First attempt
|
|
237
|
-
|
|
|
238
|
-
| Reachable session or running task | That session or task, unchanged
|
|
239
|
-
| Still launching
|
|
240
|
-
| Failed, exited or stopped
|
|
241
|
-
| Outcome unknown
|
|
236
|
+
| First attempt | A repeat gets |
|
|
237
|
+
| --------------------------------- | ------------------------------------------ |
|
|
238
|
+
| Reachable session or running task | That session or task, unchanged |
|
|
239
|
+
| Still launching | Refused — ask for its status instead |
|
|
240
|
+
| Failed, exited or stopped | Refused, naming the recorded outcome |
|
|
241
|
+
| Outcome unknown | Refused, and told to inspect first (below) |
|
|
242
242
|
|
|
243
243
|
A repeat never consumes launch capacity, including when the concurrency cap is
|
|
244
244
|
full of the very launch being asked about. Omitting the key leaves behaviour
|
|
@@ -329,7 +329,7 @@ Muster records the group the launched root leads and takes the union. A stop
|
|
|
329
329
|
reaches reparented workers, and a session whose root has died but whose group is
|
|
330
330
|
still busy is reported as running rather than ended.
|
|
331
331
|
|
|
332
|
-
Only a group the root
|
|
332
|
+
Only a group the root _leads_ is used. A process that merely belongs to someone
|
|
333
333
|
else's group shares it with whatever else is there — a shell, a tmux pane, the
|
|
334
334
|
terminal — and treating that as membership would reach far outside the session.
|
|
335
335
|
Both terminal hosts start their root in a new session, so in practice it does
|
|
@@ -339,6 +339,15 @@ nothing on macOS prevents that.
|
|
|
339
339
|
## Configuration and permissions
|
|
340
340
|
|
|
341
341
|
Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
|
|
342
|
+
|
|
343
|
+
A key this build does not know is reported and ignored, so a config written for
|
|
344
|
+
a newer Muster does not stop an older one from running — a strict top level is
|
|
345
|
+
what made every 0.10.0 command fail with `unrecognized_keys` the moment
|
|
346
|
+
`terminal` was added in 0.11.0. Values are still validated, and the nested
|
|
347
|
+
policy tables stay strict: a dropped `sandbox` value would launch under the
|
|
348
|
+
default rather than the pair that was written, and an unknown key inside a
|
|
349
|
+
requester profile is a policy statement that would not apply.
|
|
350
|
+
|
|
342
351
|
Missing configuration uses these defaults:
|
|
343
352
|
|
|
344
353
|
```toml
|
|
@@ -491,7 +500,7 @@ got.
|
|
|
491
500
|
`--level` names a legal `--permissions` / `--sandbox` pair. It is a spelling,
|
|
492
501
|
not a second policy path, and naming both a level and either flag is an error.
|
|
493
502
|
|
|
494
|
-
| Level | Equivalent to
|
|
503
|
+
| Level | Equivalent to |
|
|
495
504
|
| ------ | ------------------------------------------------------------------------------------- |
|
|
496
505
|
| `read` | `--permissions deny --sandbox read-only` |
|
|
497
506
|
| `work` | `--permissions auto --sandbox workspace-write` |
|
|
@@ -503,7 +512,7 @@ through the flags.
|
|
|
503
512
|
Every launch also records how strongly its containment was imposed:
|
|
504
513
|
|
|
505
514
|
| `enforcement` | Meaning |
|
|
506
|
-
| ------------- |
|
|
515
|
+
| ------------- | --------------------------------------------------------------------------- |
|
|
507
516
|
| `kernel` | Codex or Claude Code with an OS sandbox configured and active. |
|
|
508
517
|
| `tool-policy` | OpenCode — rules the agent is asked to respect, with no kernel enforcement. |
|
|
509
518
|
| `none` | `full-access` — the sandbox is deliberately disabled. |
|
|
@@ -755,7 +764,7 @@ configuration.
|
|
|
755
764
|
|
|
756
765
|
Five things worth stating plainly, because each is a sharp edge:
|
|
757
766
|
|
|
758
|
-
1. **`muster identities` reports
|
|
767
|
+
1. **`muster identities` reports _configured_, never _valid_.** A keychain
|
|
759
768
|
item, an auth file, a stored token or a set variable proves a credential
|
|
760
769
|
exists — not that it still works. Tokens expire and are revoked; there is
|
|
761
770
|
no way to check that without exercising the credential, and `identities`
|
|
@@ -793,7 +802,7 @@ Five things worth stating plainly, because each is a sharp edge:
|
|
|
793
802
|
about bounding.
|
|
794
803
|
|
|
795
804
|
A Claude launch under an identity also marks the launch's working directory
|
|
796
|
-
trusted
|
|
805
|
+
trusted _inside that launch's own copy_ of the configuration, so a brand-new
|
|
797
806
|
directory starts as a registered session without anyone accepting a trust
|
|
798
807
|
dialog. This is distinct from the ordinary Claude prerequisite described
|
|
799
808
|
above: it only ever writes to the per-launch copy, never to the operator's own
|
package/dist/config.js
CHANGED
|
@@ -105,7 +105,32 @@ export const configSchema = z
|
|
|
105
105
|
.default({}),
|
|
106
106
|
requesters: z.array(requesterEnrolmentSchema).default([]),
|
|
107
107
|
})
|
|
108
|
-
.
|
|
108
|
+
// Deliberately NOT strict, unlike the nested schemas above. A config written
|
|
109
|
+
// for a newer Muster must not brick an older one: when `terminal` arrived in
|
|
110
|
+
// 0.11.0 a strict top level made EVERY command on 0.10.0 fail with
|
|
111
|
+
// `unrecognized_keys` — not a warning, a dead CLI, `list` and `stop`
|
|
112
|
+
// included. Unknown keys here are reported by `unknownConfigKeys` and
|
|
113
|
+
// dropped.
|
|
114
|
+
//
|
|
115
|
+
// Values are still validated, which is the half that matters for safety: a
|
|
116
|
+
// silently ignored `sandbox = "workspce-write"` would launch under the
|
|
117
|
+
// default rather than the pair the operator wrote. The nested policy tables
|
|
118
|
+
// stay strict for the same reason — an unknown key inside a requester
|
|
119
|
+
// profile is a policy statement that would not apply.
|
|
120
|
+
.strip();
|
|
121
|
+
const knownConfigKeys = new Set(Object.keys(configSchema.shape));
|
|
122
|
+
/**
|
|
123
|
+
* Top-level keys this build does not know, sorted. Reported rather than
|
|
124
|
+
* refused, so a newer config degrades to "this Muster ignores that" instead of
|
|
125
|
+
* refusing to run at all.
|
|
126
|
+
*/
|
|
127
|
+
export function unknownConfigKeys(raw) {
|
|
128
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
129
|
+
return [];
|
|
130
|
+
return Object.keys(raw)
|
|
131
|
+
.filter((k) => !knownConfigKeys.has(k))
|
|
132
|
+
.sort();
|
|
133
|
+
}
|
|
109
134
|
export const musterHome = () => join(homedir(), ".muster");
|
|
110
135
|
export async function loadConfig(home = musterHome()) {
|
|
111
136
|
let value = {};
|
|
@@ -116,6 +141,10 @@ export async function loadConfig(home = musterHome()) {
|
|
|
116
141
|
if (e.code !== "ENOENT")
|
|
117
142
|
throw e;
|
|
118
143
|
}
|
|
144
|
+
const ignored = unknownConfigKeys(value);
|
|
145
|
+
if (ignored.length)
|
|
146
|
+
process.stderr.write(`[muster] ignoring unknown config key${ignored.length > 1 ? "s" : ""}: ` +
|
|
147
|
+
`${ignored.join(", ")} — written for a different version of muster?\n`);
|
|
119
148
|
const result = configSchema.parse(value);
|
|
120
149
|
const seen = new Set();
|
|
121
150
|
for (const e of result.requesters) {
|
|
@@ -126,6 +126,16 @@ function signal(ref, name) {
|
|
|
126
126
|
* Reparented descendants are reachable only through the process group, so pass
|
|
127
127
|
* one when the caller has recorded it; the parent chain alone cannot see them.
|
|
128
128
|
*/
|
|
129
|
+
/**
|
|
130
|
+
* Signals a process tree dead: SIGTERM deepest-first, a grace period, a second
|
|
131
|
+
* pass for anything forked during it, then SIGKILL.
|
|
132
|
+
*
|
|
133
|
+
* Resolves once the signals are SENT, not once the processes are gone. SIGKILL
|
|
134
|
+
* is asynchronous, so a pid may still be in the process table when this returns
|
|
135
|
+
* — callers that need the stronger guarantee have to poll. Nothing does today:
|
|
136
|
+
* `stop` marks the entry stopped, and `refreshed` re-probes only entries still
|
|
137
|
+
* marked running, so the window cannot flip a stopped session back to alive.
|
|
138
|
+
*/
|
|
129
139
|
export async function stopTree(ref, group) {
|
|
130
140
|
if (!(await isSame(ref)))
|
|
131
141
|
return;
|
package/dist/run.js
CHANGED
|
@@ -27,7 +27,7 @@ import { CodexRpc } from "./codex-rpc.js";
|
|
|
27
27
|
import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
|
|
28
28
|
import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
|
|
29
29
|
import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
|
|
30
|
-
import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
|
|
30
|
+
import { selectMcp, prepareMcp, mcpEnvironment, } from "./mcp.js";
|
|
31
31
|
import { pollInterval } from "./reap.js";
|
|
32
32
|
import { selectPlugins } from "./plugins.js";
|
|
33
33
|
function taskHandle(entry, pid) {
|
|
@@ -108,7 +108,14 @@ export class Muster {
|
|
|
108
108
|
this.active.add(operation);
|
|
109
109
|
return operation.finally(() => this.active.delete(operation));
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Phase 1 of a launch: decide whether this caller may ask for this at all,
|
|
113
|
+
* and settle what "this" is. Everything here refuses BEFORE anything is
|
|
114
|
+
* reserved, spawned or written, which is why it reads the raw input in places
|
|
115
|
+
* rather than the parsed request — once defaults have been applied, a value
|
|
116
|
+
* Muster chose is indistinguishable from one the caller sent.
|
|
117
|
+
*/
|
|
118
|
+
async authorize(input, requester) {
|
|
112
119
|
// Read before parsing, because cwd defaults during it and a default is then
|
|
113
120
|
// indistinguishable from a directory the caller asked for. Naming both a
|
|
114
121
|
// project and a directory is a contradiction worth reporting rather than
|
|
@@ -172,6 +179,15 @@ export class Muster {
|
|
|
172
179
|
const level = req.level ?? policy.level;
|
|
173
180
|
if (level)
|
|
174
181
|
assertLevelWithinCeiling(level, policy);
|
|
182
|
+
return { req, policy, level };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Phase 2: the read-only half of identity resolution. Validated before
|
|
186
|
+
* anything is reserved or spawned, so a bad identity leaves no registry entry
|
|
187
|
+
* behind. The per-launch COPY cannot be made here — it is named after the
|
|
188
|
+
* launch id, which only `reserve` produces — so that half happens later.
|
|
189
|
+
*/
|
|
190
|
+
async resolveIdentity(req, policy) {
|
|
175
191
|
// Validated here, before anything is reserved or spawned: a bad identity
|
|
176
192
|
// must leave no registry entry behind. The copy cannot be made yet — it is
|
|
177
193
|
// named after the launch id, which only `reserve` produces — so this is the
|
|
@@ -201,6 +217,15 @@ export class Muster {
|
|
|
201
217
|
: "has no credential — run `setup-identity --interactive` to log in and store a token");
|
|
202
218
|
}
|
|
203
219
|
}
|
|
220
|
+
return { identityName, identityMeta, credential };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Phase 3: everything the launch needs decided, and every refusal that can be
|
|
224
|
+
* made without side effects — host and terminal availability, the runtime's
|
|
225
|
+
* own preflight, the enforcement floor. Nothing here reserves, spawns or
|
|
226
|
+
* writes, so a launch refused at this point leaves no trace.
|
|
227
|
+
*/
|
|
228
|
+
async plan(req, policy, requester, level) {
|
|
204
229
|
/**
|
|
205
230
|
* The one request every permission- and argv-deriving helper is given.
|
|
206
231
|
*
|
|
@@ -248,7 +273,6 @@ export class Muster {
|
|
|
248
273
|
if (req.open &&
|
|
249
274
|
(!driver?.open || !(await driver.openAvailable?.(terminal))))
|
|
250
275
|
throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
|
|
251
|
-
let codexPolicy = [];
|
|
252
276
|
if (req.runtime === "claude")
|
|
253
277
|
await assertClaudePolicy(this.hostEnv);
|
|
254
278
|
if (req.runtime === "opencode")
|
|
@@ -259,6 +283,28 @@ export class Muster {
|
|
|
259
283
|
// Deciding from `req.host` accepts --idle-timeout under `host = "pty"` in
|
|
260
284
|
// config and then never arms it.
|
|
261
285
|
const lifecycle = resolveLifecycle(effective, this.config, req.kind === "session" && typeof driver?.schedule === "function");
|
|
286
|
+
return {
|
|
287
|
+
effective,
|
|
288
|
+
terminal,
|
|
289
|
+
permissions,
|
|
290
|
+
enforcement,
|
|
291
|
+
agentBase,
|
|
292
|
+
selectedMcp,
|
|
293
|
+
selectedPlugins,
|
|
294
|
+
model,
|
|
295
|
+
driver,
|
|
296
|
+
lifecycle,
|
|
297
|
+
argv,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Phase 4: claim the slot. The first step with a side effect, and the first
|
|
302
|
+
* that can answer "this launch already happened" — a repeated `--request-key`
|
|
303
|
+
* returns the original outcome rather than starting a second agent, so the
|
|
304
|
+
* caller cannot tell a retry from a duplicate.
|
|
305
|
+
*/
|
|
306
|
+
async reserveLaunch(c) {
|
|
307
|
+
const { req, effective, policy, requester, permissions, enforcement, model, lifecycle, selectedMcp, selectedPlugins, identityName, } = c;
|
|
262
308
|
const { entry: reservation, deduped } = await this.registry.reserve({
|
|
263
309
|
runtime: req.runtime,
|
|
264
310
|
kind: req.kind,
|
|
@@ -295,9 +341,409 @@ export class Muster {
|
|
|
295
341
|
status: reservation.status,
|
|
296
342
|
requester,
|
|
297
343
|
}, policy.logMode);
|
|
298
|
-
return this.recordedOutcome(reservation);
|
|
344
|
+
return { outcome: this.recordedOutcome(reservation) };
|
|
299
345
|
}
|
|
300
346
|
this.owned.add(reservation.launchId);
|
|
347
|
+
return { entry: reservation };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Phase 5: everything the agent's own environment needs before a process can
|
|
351
|
+
* be started — MCP servers probed, the per-launch identity copy made (the
|
|
352
|
+
* launch id exists now, and the copy is named after it), the configuration
|
|
353
|
+
* home the child will actually open recorded, and Claude's MCP bridge file
|
|
354
|
+
* written.
|
|
355
|
+
*
|
|
356
|
+
* Three of the sixteen per-agent capabilities surface here: workspace trust
|
|
357
|
+
* is written to a different file and key per agent and does not apply to
|
|
358
|
+
* OpenCode, the configuration-home variable differs, and the bridge file is
|
|
359
|
+
* Claude's alone. See AGENT_CAPABILITIES.md.
|
|
360
|
+
*/
|
|
361
|
+
async prepareEnvironment(c) {
|
|
362
|
+
const { req, identityName, identityMeta, credential, selectedMcp, agentBase, deadline, } = c;
|
|
363
|
+
let entry = c.entry;
|
|
364
|
+
const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
|
|
365
|
+
let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
|
|
366
|
+
entry = await this.registry.update(entry.launchId, prepared.summary);
|
|
367
|
+
if (identityName && identityMeta) {
|
|
368
|
+
// The launch id exists now, so the copy can be made and named after it.
|
|
369
|
+
const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
|
|
370
|
+
// Written into the copy, never the live ~/.claude.json: a running
|
|
371
|
+
// session rewrites that file continuously and Muster cannot coordinate.
|
|
372
|
+
if (identityMeta.agent === "claude")
|
|
373
|
+
// RESOLVED, not as written: Claude keys projects[] by what its own
|
|
374
|
+
// getcwd(2) returns, which has every symlink resolved. On macOS a
|
|
375
|
+
// launch into /tmp/... is really /private/tmp/..., so the un-resolved
|
|
376
|
+
// key is one Claude never looks up — the trust dialog appears, the
|
|
377
|
+
// session never registers, and the launch burns its whole deadline.
|
|
378
|
+
// `assertAllowedWorkspace` already realpaths for containment.
|
|
379
|
+
await setWorkspaceTrust(copy, await realpath(req.cwd));
|
|
380
|
+
// Codex gates the same way behind a different file and a different key,
|
|
381
|
+
// and a fresh copy trusts nothing — so every Codex identity launch sat
|
|
382
|
+
// on "Do you trust the contents of this directory?" until the deadline.
|
|
383
|
+
// Resolved for the same reason: Codex keys `projects` by its own cwd.
|
|
384
|
+
else if (identityMeta.agent === "codex")
|
|
385
|
+
await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
|
|
386
|
+
runtimeEnv = {
|
|
387
|
+
...runtimeEnv,
|
|
388
|
+
...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
|
|
389
|
+
};
|
|
390
|
+
entry = await this.registry.update(entry.launchId, {
|
|
391
|
+
identityPath: copy,
|
|
392
|
+
// Recorded alongside the path, because the path outlives the directory
|
|
393
|
+
// it names and never named the account.
|
|
394
|
+
identity: identityName,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Where the agent will record its session. Distinct from `hostEnv`, which
|
|
398
|
+
// keeps spawning Muster's own helpers — see `resolutionEnv`.
|
|
399
|
+
//
|
|
400
|
+
// Derived from the COMPOSED agent environment, not from `identityPath`
|
|
401
|
+
// alone: a requester profile's `env_defaults` delivers any key by design,
|
|
402
|
+
// so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
|
|
403
|
+
// configuration home while resolution searched the host's — reserve, spawn,
|
|
404
|
+
// dead at the deadline. The identity copy still wins where there is one,
|
|
405
|
+
// because `identityEnv` is merged into `runtimeEnv` above.
|
|
406
|
+
const configVar = configHomeVar(req.runtime);
|
|
407
|
+
const configHome = configVar
|
|
408
|
+
? (runtimeEnv[configVar] ?? entry.identityPath)
|
|
409
|
+
: undefined;
|
|
410
|
+
// Persisted only when it differs from the host's, so a launch with neither
|
|
411
|
+
// an identity nor an override writes nothing new and reads exactly as
|
|
412
|
+
// before. `refreshed()` has only the entry in scope and must agree.
|
|
413
|
+
if (configVar && configHome && configHome !== this.hostEnv[configVar])
|
|
414
|
+
entry = await this.registry.update(entry.launchId, { configHome });
|
|
415
|
+
const resolveEnv = this.resolutionEnv(req.runtime, configHome);
|
|
416
|
+
let bridgePath;
|
|
417
|
+
if (req.runtime === "claude" && prepared.servers.length) {
|
|
418
|
+
const dir = join(this.home, "mcp");
|
|
419
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
420
|
+
bridgePath = join(dir, entry.launchId + ".json");
|
|
421
|
+
entry = await this.registry.update(entry.launchId, {
|
|
422
|
+
mcpConfigPath: bridgePath,
|
|
423
|
+
});
|
|
424
|
+
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
425
|
+
}
|
|
426
|
+
return { entry, prepared, runtimeEnv, configHome, resolveEnv, bridgePath };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Phase 6: turn the plan into a command line. This is the widest per-agent
|
|
430
|
+
* split in the launch — Claude and Codex are handed argv and contain
|
|
431
|
+
* themselves from there, while OpenCode is a server Muster starts on an
|
|
432
|
+
* allocated loopback port and then talks to over HTTP, so its "argv" comes
|
|
433
|
+
* back from a builder that also reports the port and an environment patch.
|
|
434
|
+
*
|
|
435
|
+
* Codex's policy overrides are spliced in AFTER launchArgs rather than passed
|
|
436
|
+
* to it, because they are computed from the composed runtime environment that
|
|
437
|
+
* only exists by this point.
|
|
438
|
+
*/
|
|
439
|
+
async buildCommand(c) {
|
|
440
|
+
const { req, effective, prepared, bridgePath, selectedPlugins, deadline } = c;
|
|
441
|
+
let { argv, codexPolicy, runtimeEnv, entry } = c;
|
|
442
|
+
if (req.runtime !== "opencode")
|
|
443
|
+
// `runtimeEnv`, not the host's: an identity launch relocates
|
|
444
|
+
// CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
|
|
445
|
+
// the dir the child will actually open, not the one Muster runs under.
|
|
446
|
+
argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
|
|
447
|
+
if (req.runtime === "codex") {
|
|
448
|
+
codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
449
|
+
argv.splice(1, 0, ...codexPolicy);
|
|
450
|
+
}
|
|
451
|
+
else if (req.runtime === "opencode" && req.kind === "session") {
|
|
452
|
+
const port = await allocateLoopbackPort();
|
|
453
|
+
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
454
|
+
const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
455
|
+
argv = launch.argv;
|
|
456
|
+
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
457
|
+
entry = await this.registry.update(entry.launchId, {
|
|
458
|
+
server_url: launch.serverUrl,
|
|
459
|
+
opencode_port: launch.port,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
else if (req.runtime === "opencode" && req.kind === "task") {
|
|
463
|
+
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
464
|
+
const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
465
|
+
argv = launch.argv;
|
|
466
|
+
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
467
|
+
}
|
|
468
|
+
return { argv, codexPolicy, runtimeEnv, entry };
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Phase 7: start the process. The first point at which a process can exist
|
|
472
|
+
* without the registry knowing about it, which is why `spawning` is recorded
|
|
473
|
+
* before the host is asked to launch anything.
|
|
474
|
+
*
|
|
475
|
+
* A task ends here — `startTask` owns its whole lifetime — so this returns an
|
|
476
|
+
* outcome rather than falling through, and only a session carries on to
|
|
477
|
+
* readiness.
|
|
478
|
+
*/
|
|
479
|
+
async startProcess(c) {
|
|
480
|
+
const { req, argv, runtimeEnv, policy, driver, lifecycle, deadline } = c;
|
|
481
|
+
let entry = c.entry;
|
|
482
|
+
if (this.closing)
|
|
483
|
+
throw new Error("Launch cancelled: Muster is closing");
|
|
484
|
+
// From here a process may exist without the registry knowing it. See the
|
|
485
|
+
// recovery branch in Registry.transaction.
|
|
486
|
+
entry = await this.registry.update(entry.launchId, { spawning: true });
|
|
487
|
+
if (req.kind === "task")
|
|
488
|
+
return {
|
|
489
|
+
outcome: await this.startTask(entry, argv, runtimeEnv, policy.logMode),
|
|
490
|
+
};
|
|
491
|
+
const launched = await driver.launch({
|
|
492
|
+
argv,
|
|
493
|
+
cwd: req.cwd,
|
|
494
|
+
env: runtimeEnv,
|
|
495
|
+
label: req.prompt.slice(0, 48),
|
|
496
|
+
deadline,
|
|
497
|
+
});
|
|
498
|
+
const root = await processRef(launched.pid);
|
|
499
|
+
if (!root) {
|
|
500
|
+
await driver.stop(launched.hostRef);
|
|
501
|
+
throw new Error("process exited during launch");
|
|
502
|
+
}
|
|
503
|
+
const group = await ownedGroup(launched.pid);
|
|
504
|
+
entry = {
|
|
505
|
+
...entry,
|
|
506
|
+
root,
|
|
507
|
+
...(group ? { group } : {}),
|
|
508
|
+
host: driver.id,
|
|
509
|
+
hostRef: launched.hostRef,
|
|
510
|
+
};
|
|
511
|
+
// Only the fields that changed, not the whole entry: `entry` still
|
|
512
|
+
// carries its recorded `requester`, and update() refuses a patch that
|
|
513
|
+
// mentions that field at all, changed or not.
|
|
514
|
+
await this.registry.update(entry.launchId, {
|
|
515
|
+
root,
|
|
516
|
+
...(group ? { group } : {}),
|
|
517
|
+
host: driver.id,
|
|
518
|
+
hostRef: launched.hostRef,
|
|
519
|
+
});
|
|
520
|
+
// Armed only once hostRef exists: the job addresses the window, and a job
|
|
521
|
+
// armed before the window is recorded would poll a target the registry
|
|
522
|
+
// cannot name. `schedule` is optional on the host interface, so pty and
|
|
523
|
+
// macos-terminal opt out by not implementing it.
|
|
524
|
+
if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
|
|
525
|
+
const armed = await driver
|
|
526
|
+
.schedule(pollInterval(lifecycle), [
|
|
527
|
+
process.execPath,
|
|
528
|
+
// ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
|
|
529
|
+
// under vitest as well as from dist/run.js in the package, landing
|
|
530
|
+
// on <root>/dist either way. Same idiom as the tmux bootstrap.
|
|
531
|
+
fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
|
|
532
|
+
"reap-check",
|
|
533
|
+
"--home",
|
|
534
|
+
this.home,
|
|
535
|
+
"--socket",
|
|
536
|
+
driver.socketName?.() ?? "muster",
|
|
537
|
+
entry.launchId,
|
|
538
|
+
])
|
|
539
|
+
.then(() => true, () => false);
|
|
540
|
+
// A lifecycle that could not be armed must not fail the launch — the
|
|
541
|
+
// session is up and usable, and the orphan risk is what we had before.
|
|
542
|
+
// But it must not be REPORTED either: a record promising an expiry
|
|
543
|
+
// nothing will honour is worse than one promising none, because there
|
|
544
|
+
// is no way to tell from the outside.
|
|
545
|
+
if (!armed) {
|
|
546
|
+
lifecycle.idleTimeout = null;
|
|
547
|
+
lifecycle.ttl = null;
|
|
548
|
+
entry = await this.registry.update(entry.launchId, {
|
|
549
|
+
idleTimeout: null,
|
|
550
|
+
ttl: null,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return { entry, root, launched };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Phase 8: wait until the agent has registered a session Muster can address,
|
|
558
|
+
* or until the deadline, whichever comes first. The subtlest phase, and the
|
|
559
|
+
* one where the runtimes differ most: a session record on disk for Claude, a
|
|
560
|
+
* JSON-RPC thread read for Codex, an HTTP endpoint plus a port-ownership
|
|
561
|
+
* check for OpenCode. They share a question, not a mechanism.
|
|
562
|
+
*
|
|
563
|
+
* `diagnostic` is the reason the launch would fail if the deadline arrived
|
|
564
|
+
* now. It is overwritten by anything the loop learns, so a timeout reports
|
|
565
|
+
* the last real explanation rather than "no identity found" — and
|
|
566
|
+
* `neverRegistered` keeps the original, because only that one can be about a
|
|
567
|
+
* consent screen nobody answered.
|
|
568
|
+
*
|
|
569
|
+
* The RPC connection is created by the caller rather than here: `launch`'s
|
|
570
|
+
* `finally` has to close it even when this throws.
|
|
571
|
+
*/
|
|
572
|
+
async awaitReadiness(c) {
|
|
573
|
+
const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, startedAt, deadline, } = c;
|
|
574
|
+
let entry = c.entry;
|
|
575
|
+
let diagnostic = req.runtime === "claude"
|
|
576
|
+
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
577
|
+
: req.runtime === "opencode"
|
|
578
|
+
? "no OpenCode session matched the launch endpoint, cwd, and creation window"
|
|
579
|
+
: "no descendant runtime identity found";
|
|
580
|
+
// Kept so the throw below can tell "nothing ever registered" from a
|
|
581
|
+
// diagnosis the loop actually earned. Only the first case can be about a
|
|
582
|
+
// consent screen; anything later means a session existed.
|
|
583
|
+
const neverRegistered = diagnostic;
|
|
584
|
+
// `resolveEnv`, not `hostEnv`: this connection is how the thread
|
|
585
|
+
// `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
|
|
586
|
+
// non-null answer is fatal, and `codexReachable` at readiness. An
|
|
587
|
+
// app-server reading the operator's CODEX_HOME has no rollout for a thread
|
|
588
|
+
// created under the copy, so it answers "no rollout found", every
|
|
589
|
+
// candidate is rejected, and the launch burns its deadline. Pointing
|
|
590
|
+
// discovery at the copy while its reads stay on the host is the same bug
|
|
591
|
+
// one call downstream.
|
|
592
|
+
while (Date.now() < deadline) {
|
|
593
|
+
if (deadline - Date.now() < 25)
|
|
594
|
+
break;
|
|
595
|
+
if (await isSame(root)) {
|
|
596
|
+
const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
|
|
597
|
+
const refs = [...(entry.descendants ?? [])];
|
|
598
|
+
for (const ref of tracked)
|
|
599
|
+
if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
|
|
600
|
+
refs.push(ref);
|
|
601
|
+
if (refs.length !== (entry.descendants ?? []).length) {
|
|
602
|
+
entry = { ...entry, descendants: refs };
|
|
603
|
+
await this.registry.update(entry.launchId, { descendants: refs });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (this.closing)
|
|
607
|
+
throw new Error("Launch cancelled: Muster is closing");
|
|
608
|
+
if (!(await isSame(root)))
|
|
609
|
+
throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
|
|
610
|
+
let openingTerminal = false;
|
|
611
|
+
try {
|
|
612
|
+
const identity = req.runtime === "codex"
|
|
613
|
+
? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
|
|
614
|
+
try {
|
|
615
|
+
const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
|
|
616
|
+
return threadRejection(thread, id);
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
return e.message;
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
: req.runtime === "opencode"
|
|
623
|
+
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
|
|
624
|
+
: await resolveClaude(root.pid, resolveEnv, deadline);
|
|
625
|
+
if (identity) {
|
|
626
|
+
diagnostic =
|
|
627
|
+
req.runtime === "codex"
|
|
628
|
+
? "thread id reserved but no rollout"
|
|
629
|
+
: req.runtime === "opencode"
|
|
630
|
+
? "OpenCode session exists but its endpoint status is not readable"
|
|
631
|
+
: "registry exists but inbox socket is not accepting connections";
|
|
632
|
+
let metadata;
|
|
633
|
+
if (req.runtime === "codex")
|
|
634
|
+
metadata = await codexReachable(rpc, identity.id, deadline);
|
|
635
|
+
else if (req.runtime === "opencode")
|
|
636
|
+
metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
|
|
637
|
+
else {
|
|
638
|
+
const claude = identity;
|
|
639
|
+
if (!(await claudeReachable(claude.socketPath, deadline)))
|
|
640
|
+
throw new Error(diagnostic);
|
|
641
|
+
metadata = claude;
|
|
642
|
+
}
|
|
643
|
+
if (Date.now() >= deadline)
|
|
644
|
+
throw new Error("readiness completed after launch deadline");
|
|
645
|
+
if (!(await isSame(root)) || !(await processRef(identity.pid)))
|
|
646
|
+
throw new Error("process exited before readiness completed");
|
|
647
|
+
if (req.runtime === "opencode" &&
|
|
648
|
+
!(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
|
|
649
|
+
throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
|
|
650
|
+
const runtime = runtimeName(req.runtime);
|
|
651
|
+
const named = assignNames([
|
|
652
|
+
{ runtime, uuid: identity.id, rawName: metadata.rawName },
|
|
653
|
+
])[0];
|
|
654
|
+
const peer = {
|
|
655
|
+
kind: "session",
|
|
656
|
+
...permissions,
|
|
657
|
+
enforcement,
|
|
658
|
+
...prepared.summary,
|
|
659
|
+
name: named.display,
|
|
660
|
+
canonical_id: named.canonicalId,
|
|
661
|
+
runtime,
|
|
662
|
+
state: metadata.state,
|
|
663
|
+
cwd: req.cwd,
|
|
664
|
+
...(req.runtime === "codex"
|
|
665
|
+
? { thread_id: identity.id }
|
|
666
|
+
: req.runtime === "opencode"
|
|
667
|
+
? {
|
|
668
|
+
session_id: identity.id,
|
|
669
|
+
server_url: entry.server_url,
|
|
670
|
+
}
|
|
671
|
+
: { session_id: identity.id }),
|
|
672
|
+
pid: identity.pid,
|
|
673
|
+
host: driver.id,
|
|
674
|
+
capabilities: driver.capabilities(),
|
|
675
|
+
// The RESOLVED name, matching what the entry records and what
|
|
676
|
+
// `list` projects: a launch result and a listing must not
|
|
677
|
+
// disagree about which account this ran as.
|
|
678
|
+
...(identityName ? { identity: identityName } : {}),
|
|
679
|
+
// Same reason, for the same pair of records.
|
|
680
|
+
model: model?.model ?? null,
|
|
681
|
+
model_source: model?.source ?? null,
|
|
682
|
+
...(lifecycle.idleTimeout
|
|
683
|
+
? { idle_timeout: lifecycle.idleTimeout }
|
|
684
|
+
: {}),
|
|
685
|
+
...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
|
|
686
|
+
attach_hint: driver.attachHint(launched.hostRef),
|
|
687
|
+
};
|
|
688
|
+
if (req.open) {
|
|
689
|
+
openingTerminal = true;
|
|
690
|
+
await driver.open(launched.hostRef, deadline, terminal);
|
|
691
|
+
peer.terminal_opened = true;
|
|
692
|
+
peer.terminal = terminal;
|
|
693
|
+
}
|
|
694
|
+
entry = await this.registry.update(entry.launchId, {
|
|
695
|
+
id: identity.id,
|
|
696
|
+
status: "running",
|
|
697
|
+
peer,
|
|
698
|
+
});
|
|
699
|
+
await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
|
|
700
|
+
return peer;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
if (openingTerminal)
|
|
705
|
+
throw e;
|
|
706
|
+
// An error arriving after the deadline is dropped, because most of
|
|
707
|
+
// them are artifacts of the deadline itself — aborted round trips
|
|
708
|
+
// that would overwrite a real diagnosis with "timed out". The cost is
|
|
709
|
+
// that a genuine answer landing a moment late is lost too, so raise
|
|
710
|
+
// one as early as it is known rather than at readiness (#20).
|
|
711
|
+
if (Date.now() < deadline)
|
|
712
|
+
diagnostic = e.message;
|
|
713
|
+
}
|
|
714
|
+
await delay(Math.min(250, Math.max(0, deadline - Date.now())));
|
|
715
|
+
}
|
|
716
|
+
// Resolved here rather than before the launch: this reads the operator's
|
|
717
|
+
// live `.claude.json`, and a launch that is going to work should not pay
|
|
718
|
+
// for it. Failing closed is also wrong before a launch — `--kind task`
|
|
719
|
+
// runs headless and never sees the dialog, so refusing an untrusted
|
|
720
|
+
// directory up front would break task launches that work today.
|
|
721
|
+
if (req.runtime === "claude" && diagnostic === neverRegistered)
|
|
722
|
+
diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
|
|
723
|
+
throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
|
|
724
|
+
}
|
|
725
|
+
async launch(input, requester) {
|
|
726
|
+
const { req, policy, level } = await this.authorize(input, requester);
|
|
727
|
+
const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
|
|
728
|
+
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
729
|
+
let argv = plannedArgv;
|
|
730
|
+
let codexPolicy = [];
|
|
731
|
+
const reserved = await this.reserveLaunch({
|
|
732
|
+
req,
|
|
733
|
+
effective,
|
|
734
|
+
policy,
|
|
735
|
+
requester,
|
|
736
|
+
permissions,
|
|
737
|
+
enforcement,
|
|
738
|
+
model,
|
|
739
|
+
lifecycle,
|
|
740
|
+
selectedMcp,
|
|
741
|
+
selectedPlugins,
|
|
742
|
+
identityName,
|
|
743
|
+
});
|
|
744
|
+
if ("outcome" in reserved)
|
|
745
|
+
return reserved.outcome;
|
|
746
|
+
const reservation = reserved.entry;
|
|
301
747
|
let entry = reservation;
|
|
302
748
|
const startedAt = Date.now();
|
|
303
749
|
const deadline = startedAt + this.config.launch_timeout_sec * 1000;
|
|
@@ -327,315 +773,70 @@ export class Muster {
|
|
|
327
773
|
...permissions,
|
|
328
774
|
enforcement,
|
|
329
775
|
}, policy.logMode);
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (identityMeta.agent === "claude")
|
|
339
|
-
// RESOLVED, not as written: Claude keys projects[] by what its own
|
|
340
|
-
// getcwd(2) returns, which has every symlink resolved. On macOS a
|
|
341
|
-
// launch into /tmp/... is really /private/tmp/..., so the un-resolved
|
|
342
|
-
// key is one Claude never looks up — the trust dialog appears, the
|
|
343
|
-
// session never registers, and the launch burns its whole deadline.
|
|
344
|
-
// `assertAllowedWorkspace` already realpaths for containment.
|
|
345
|
-
await setWorkspaceTrust(copy, await realpath(req.cwd));
|
|
346
|
-
// Codex gates the same way behind a different file and a different key,
|
|
347
|
-
// and a fresh copy trusts nothing — so every Codex identity launch sat
|
|
348
|
-
// on "Do you trust the contents of this directory?" until the deadline.
|
|
349
|
-
// Resolved for the same reason: Codex keys `projects` by its own cwd.
|
|
350
|
-
else if (identityMeta.agent === "codex")
|
|
351
|
-
await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
|
|
352
|
-
runtimeEnv = {
|
|
353
|
-
...runtimeEnv,
|
|
354
|
-
...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
|
|
355
|
-
};
|
|
356
|
-
entry = await this.registry.update(entry.launchId, {
|
|
357
|
-
identityPath: copy,
|
|
358
|
-
// Recorded alongside the path, because the path outlives the directory
|
|
359
|
-
// it names and never named the account.
|
|
360
|
-
identity: identityName,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
// Where the agent will record its session. Distinct from `hostEnv`, which
|
|
364
|
-
// keeps spawning Muster's own helpers — see `resolutionEnv`.
|
|
365
|
-
//
|
|
366
|
-
// Derived from the COMPOSED agent environment, not from `identityPath`
|
|
367
|
-
// alone: a requester profile's `env_defaults` delivers any key by design,
|
|
368
|
-
// so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
|
|
369
|
-
// configuration home while resolution searched the host's — reserve, spawn,
|
|
370
|
-
// dead at the deadline. The identity copy still wins where there is one,
|
|
371
|
-
// because `identityEnv` is merged into `runtimeEnv` above.
|
|
372
|
-
const configVar = configHomeVar(req.runtime);
|
|
373
|
-
const configHome = configVar
|
|
374
|
-
? (runtimeEnv[configVar] ?? entry.identityPath)
|
|
375
|
-
: undefined;
|
|
376
|
-
// Persisted only when it differs from the host's, so a launch with neither
|
|
377
|
-
// an identity nor an override writes nothing new and reads exactly as
|
|
378
|
-
// before. `refreshed()` has only the entry in scope and must agree.
|
|
379
|
-
if (configVar && configHome && configHome !== this.hostEnv[configVar])
|
|
380
|
-
entry = await this.registry.update(entry.launchId, { configHome });
|
|
381
|
-
const resolveEnv = this.resolutionEnv(req.runtime, configHome);
|
|
382
|
-
let bridgePath;
|
|
383
|
-
if (req.runtime === "claude" && prepared.servers.length) {
|
|
384
|
-
const dir = join(this.home, "mcp");
|
|
385
|
-
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
386
|
-
bridgePath = join(dir, entry.launchId + ".json");
|
|
387
|
-
entry = await this.registry.update(entry.launchId, {
|
|
388
|
-
mcpConfigPath: bridgePath,
|
|
389
|
-
});
|
|
390
|
-
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
391
|
-
}
|
|
392
|
-
if (req.runtime !== "opencode")
|
|
393
|
-
// `runtimeEnv`, not the host's: an identity launch relocates
|
|
394
|
-
// CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
|
|
395
|
-
// the dir the child will actually open, not the one Muster runs under.
|
|
396
|
-
argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
|
|
397
|
-
if (req.runtime === "codex") {
|
|
398
|
-
codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
399
|
-
argv.splice(1, 0, ...codexPolicy);
|
|
400
|
-
}
|
|
401
|
-
else if (req.runtime === "opencode" && req.kind === "session") {
|
|
402
|
-
const port = await allocateLoopbackPort();
|
|
403
|
-
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
404
|
-
const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
405
|
-
argv = launch.argv;
|
|
406
|
-
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
407
|
-
entry = await this.registry.update(entry.launchId, {
|
|
408
|
-
server_url: launch.serverUrl,
|
|
409
|
-
opencode_port: launch.port,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
else if (req.runtime === "opencode" && req.kind === "task") {
|
|
413
|
-
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
414
|
-
const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
|
|
415
|
-
argv = launch.argv;
|
|
416
|
-
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
417
|
-
}
|
|
418
|
-
if (this.closing)
|
|
419
|
-
throw new Error("Launch cancelled: Muster is closing");
|
|
420
|
-
// From here a process may exist without the registry knowing it. See the
|
|
421
|
-
// recovery branch in Registry.transaction.
|
|
422
|
-
entry = await this.registry.update(entry.launchId, { spawning: true });
|
|
423
|
-
if (req.kind === "task")
|
|
424
|
-
return await this.startTask(entry, argv, runtimeEnv, policy.logMode);
|
|
425
|
-
const launched = await driver.launch({
|
|
426
|
-
argv,
|
|
427
|
-
cwd: req.cwd,
|
|
428
|
-
env: runtimeEnv,
|
|
429
|
-
label: req.prompt.slice(0, 48),
|
|
776
|
+
const environment = await this.prepareEnvironment({
|
|
777
|
+
req,
|
|
778
|
+
entry,
|
|
779
|
+
identityName,
|
|
780
|
+
identityMeta,
|
|
781
|
+
credential,
|
|
782
|
+
selectedMcp,
|
|
783
|
+
agentBase,
|
|
430
784
|
deadline,
|
|
431
785
|
});
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
entry = {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
786
|
+
entry = environment.entry;
|
|
787
|
+
// `runtimeEnv` is still reassigned below — an OpenCode launch patches it
|
|
788
|
+
// with the port its server ended up on — so it stays a `let` here rather
|
|
789
|
+
// than being destructured as a const.
|
|
790
|
+
let runtimeEnv = environment.runtimeEnv;
|
|
791
|
+
const { prepared, configHome, resolveEnv, bridgePath } = environment;
|
|
792
|
+
({ argv, codexPolicy, runtimeEnv, entry } = await this.buildCommand({
|
|
793
|
+
req,
|
|
794
|
+
effective,
|
|
795
|
+
entry,
|
|
796
|
+
argv,
|
|
797
|
+
codexPolicy,
|
|
798
|
+
runtimeEnv,
|
|
799
|
+
prepared,
|
|
800
|
+
bridgePath,
|
|
801
|
+
selectedPlugins,
|
|
802
|
+
deadline,
|
|
803
|
+
}));
|
|
804
|
+
const started = await this.startProcess({
|
|
805
|
+
req,
|
|
806
|
+
entry,
|
|
807
|
+
argv,
|
|
808
|
+
runtimeEnv,
|
|
809
|
+
policy,
|
|
810
|
+
driver,
|
|
811
|
+
lifecycle,
|
|
812
|
+
deadline,
|
|
453
813
|
});
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
|
|
459
|
-
const armed = await driver
|
|
460
|
-
.schedule(pollInterval(lifecycle), [
|
|
461
|
-
process.execPath,
|
|
462
|
-
// ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
|
|
463
|
-
// under vitest as well as from dist/run.js in the package, landing
|
|
464
|
-
// on <root>/dist either way. Same idiom as the tmux bootstrap.
|
|
465
|
-
fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
|
|
466
|
-
"reap-check",
|
|
467
|
-
"--home",
|
|
468
|
-
this.home,
|
|
469
|
-
"--socket",
|
|
470
|
-
driver.socketName?.() ?? "muster",
|
|
471
|
-
entry.launchId,
|
|
472
|
-
])
|
|
473
|
-
.then(() => true, () => false);
|
|
474
|
-
// A lifecycle that could not be armed must not fail the launch — the
|
|
475
|
-
// session is up and usable, and the orphan risk is what we had before.
|
|
476
|
-
// But it must not be REPORTED either: a record promising an expiry
|
|
477
|
-
// nothing will honour is worse than one promising none, because there
|
|
478
|
-
// is no way to tell from the outside.
|
|
479
|
-
if (!armed) {
|
|
480
|
-
lifecycle.idleTimeout = null;
|
|
481
|
-
lifecycle.ttl = null;
|
|
482
|
-
entry = await this.registry.update(entry.launchId, {
|
|
483
|
-
idleTimeout: null,
|
|
484
|
-
ttl: null,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
let diagnostic = req.runtime === "claude"
|
|
489
|
-
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
490
|
-
: req.runtime === "opencode"
|
|
491
|
-
? "no OpenCode session matched the launch endpoint, cwd, and creation window"
|
|
492
|
-
: "no descendant runtime identity found";
|
|
493
|
-
// Kept so the throw below can tell "nothing ever registered" from a
|
|
494
|
-
// diagnosis the loop actually earned. Only the first case can be about a
|
|
495
|
-
// consent screen; anything later means a session existed.
|
|
496
|
-
const neverRegistered = diagnostic;
|
|
497
|
-
// `resolveEnv`, not `hostEnv`: this connection is how the thread
|
|
498
|
-
// `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
|
|
499
|
-
// non-null answer is fatal, and `codexReachable` at readiness. An
|
|
500
|
-
// app-server reading the operator's CODEX_HOME has no rollout for a thread
|
|
501
|
-
// created under the copy, so it answers "no rollout found", every
|
|
502
|
-
// candidate is rejected, and the launch burns its deadline. Pointing
|
|
503
|
-
// discovery at the copy while its reads stay on the host is the same bug
|
|
504
|
-
// one call downstream.
|
|
814
|
+
if ("outcome" in started)
|
|
815
|
+
return started.outcome;
|
|
816
|
+
entry = started.entry;
|
|
817
|
+
const { root, launched } = started;
|
|
505
818
|
if (req.runtime === "codex")
|
|
506
819
|
rpc = new CodexRpc(resolveEnv, req.cwd);
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const identity = req.runtime === "codex"
|
|
528
|
-
? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
|
|
529
|
-
try {
|
|
530
|
-
const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
|
|
531
|
-
return threadRejection(thread, id);
|
|
532
|
-
}
|
|
533
|
-
catch (e) {
|
|
534
|
-
return e.message;
|
|
535
|
-
}
|
|
536
|
-
})
|
|
537
|
-
: req.runtime === "opencode"
|
|
538
|
-
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
|
|
539
|
-
: await resolveClaude(root.pid, resolveEnv, deadline);
|
|
540
|
-
if (identity) {
|
|
541
|
-
diagnostic =
|
|
542
|
-
req.runtime === "codex"
|
|
543
|
-
? "thread id reserved but no rollout"
|
|
544
|
-
: req.runtime === "opencode"
|
|
545
|
-
? "OpenCode session exists but its endpoint status is not readable"
|
|
546
|
-
: "registry exists but inbox socket is not accepting connections";
|
|
547
|
-
let metadata;
|
|
548
|
-
if (req.runtime === "codex")
|
|
549
|
-
metadata = await codexReachable(rpc, identity.id, deadline);
|
|
550
|
-
else if (req.runtime === "opencode")
|
|
551
|
-
metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
|
|
552
|
-
else {
|
|
553
|
-
const claude = identity;
|
|
554
|
-
if (!(await claudeReachable(claude.socketPath, deadline)))
|
|
555
|
-
throw new Error(diagnostic);
|
|
556
|
-
metadata = claude;
|
|
557
|
-
}
|
|
558
|
-
if (Date.now() >= deadline)
|
|
559
|
-
throw new Error("readiness completed after launch deadline");
|
|
560
|
-
if (!(await isSame(root)) || !(await processRef(identity.pid)))
|
|
561
|
-
throw new Error("process exited before readiness completed");
|
|
562
|
-
if (req.runtime === "opencode" &&
|
|
563
|
-
!(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
|
|
564
|
-
throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
|
|
565
|
-
const runtime = runtimeName(req.runtime);
|
|
566
|
-
const named = assignNames([
|
|
567
|
-
{ runtime, uuid: identity.id, rawName: metadata.rawName },
|
|
568
|
-
])[0];
|
|
569
|
-
const peer = {
|
|
570
|
-
kind: "session",
|
|
571
|
-
...permissions,
|
|
572
|
-
enforcement,
|
|
573
|
-
...prepared.summary,
|
|
574
|
-
name: named.display,
|
|
575
|
-
canonical_id: named.canonicalId,
|
|
576
|
-
runtime,
|
|
577
|
-
state: metadata.state,
|
|
578
|
-
cwd: req.cwd,
|
|
579
|
-
...(req.runtime === "codex"
|
|
580
|
-
? { thread_id: identity.id }
|
|
581
|
-
: req.runtime === "opencode"
|
|
582
|
-
? {
|
|
583
|
-
session_id: identity.id,
|
|
584
|
-
server_url: entry.server_url,
|
|
585
|
-
}
|
|
586
|
-
: { session_id: identity.id }),
|
|
587
|
-
pid: identity.pid,
|
|
588
|
-
host: driver.id,
|
|
589
|
-
capabilities: driver.capabilities(),
|
|
590
|
-
// The RESOLVED name, matching what the entry records and what
|
|
591
|
-
// `list` projects: a launch result and a listing must not
|
|
592
|
-
// disagree about which account this ran as.
|
|
593
|
-
...(identityName ? { identity: identityName } : {}),
|
|
594
|
-
// Same reason, for the same pair of records.
|
|
595
|
-
model: model?.model ?? null,
|
|
596
|
-
model_source: model?.source ?? null,
|
|
597
|
-
...(lifecycle.idleTimeout
|
|
598
|
-
? { idle_timeout: lifecycle.idleTimeout }
|
|
599
|
-
: {}),
|
|
600
|
-
...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
|
|
601
|
-
attach_hint: driver.attachHint(launched.hostRef),
|
|
602
|
-
};
|
|
603
|
-
if (req.open) {
|
|
604
|
-
openingTerminal = true;
|
|
605
|
-
await driver.open(launched.hostRef, deadline, terminal);
|
|
606
|
-
peer.terminal_opened = true;
|
|
607
|
-
peer.terminal = terminal;
|
|
608
|
-
}
|
|
609
|
-
entry = await this.registry.update(entry.launchId, {
|
|
610
|
-
id: identity.id,
|
|
611
|
-
status: "running",
|
|
612
|
-
peer,
|
|
613
|
-
});
|
|
614
|
-
await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
|
|
615
|
-
return peer;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
catch (e) {
|
|
619
|
-
if (openingTerminal)
|
|
620
|
-
throw e;
|
|
621
|
-
// An error arriving after the deadline is dropped, because most of
|
|
622
|
-
// them are artifacts of the deadline itself — aborted round trips
|
|
623
|
-
// that would overwrite a real diagnosis with "timed out". The cost is
|
|
624
|
-
// that a genuine answer landing a moment late is lost too, so raise
|
|
625
|
-
// one as early as it is known rather than at readiness (#20).
|
|
626
|
-
if (Date.now() < deadline)
|
|
627
|
-
diagnostic = e.message;
|
|
628
|
-
}
|
|
629
|
-
await delay(Math.min(250, Math.max(0, deadline - Date.now())));
|
|
630
|
-
}
|
|
631
|
-
// Resolved here rather than before the launch: this reads the operator's
|
|
632
|
-
// live `.claude.json`, and a launch that is going to work should not pay
|
|
633
|
-
// for it. Failing closed is also wrong before a launch — `--kind task`
|
|
634
|
-
// runs headless and never sees the dialog, so refusing an untrusted
|
|
635
|
-
// directory up front would break task launches that work today.
|
|
636
|
-
if (req.runtime === "claude" && diagnostic === neverRegistered)
|
|
637
|
-
diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
|
|
638
|
-
throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
|
|
820
|
+
return await this.awaitReadiness({
|
|
821
|
+
req,
|
|
822
|
+
entry,
|
|
823
|
+
root,
|
|
824
|
+
launched,
|
|
825
|
+
driver,
|
|
826
|
+
policy,
|
|
827
|
+
requester,
|
|
828
|
+
resolveEnv,
|
|
829
|
+
rpc,
|
|
830
|
+
enforcement,
|
|
831
|
+
prepared,
|
|
832
|
+
identityName,
|
|
833
|
+
model,
|
|
834
|
+
permissions,
|
|
835
|
+
terminal,
|
|
836
|
+
lifecycle,
|
|
837
|
+
startedAt,
|
|
838
|
+
deadline,
|
|
839
|
+
});
|
|
639
840
|
}
|
|
640
841
|
catch (e) {
|
|
641
842
|
await this.stopEntry(entry).catch(() => { });
|