@brutalsystems/muster 0.7.11 → 0.7.14
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 +280 -9
- package/dist/claude-policy.js +0 -2
- package/dist/config.js +64 -1
- package/dist/enforcement.js +5 -0
- package/dist/guard.js +103 -0
- package/dist/identity/claude.js +14 -1
- package/dist/identity/processes.js +60 -12
- package/dist/identity-auth.js +66 -0
- package/dist/identity-cli.js +88 -0
- package/dist/identity-copy.js +124 -0
- package/dist/identity-store.js +101 -0
- package/dist/log.js +82 -4
- package/dist/mcp-tools.js +134 -0
- package/dist/muster.js +72 -96
- package/dist/registry.js +21 -4
- package/dist/requester-policy.js +106 -0
- package/dist/requester.js +59 -0
- package/dist/run.js +306 -54
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `run`, `list`, `stop` and `output` tools exactly as the MCP server
|
|
3
|
+
* advertises them.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `src/muster.ts` so it can be ASSERTED against `runSchema`
|
|
6
|
+
* rather than kept in step by hand. One request shape had two hand-maintained
|
|
7
|
+
* descriptions and nothing checked that they agreed, and four fields drifted
|
|
8
|
+
* across two branches: `level`, then `identity`, `model` and `plugin`. Because
|
|
9
|
+
* the schema is `additionalProperties: false`, an omitted key is not merely
|
|
10
|
+
* undocumented — a conformant client is told it is INVALID.
|
|
11
|
+
* `test/mcp-run-schema.test.ts` is what ends that class.
|
|
12
|
+
*/
|
|
13
|
+
export const MCP_TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: "run",
|
|
16
|
+
description: "Launch an instructed agent. Sessions return only when reachable; tasks return a non-messageable run handle.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
runtime: {
|
|
21
|
+
type: "string",
|
|
22
|
+
enum: ["codex", "claude", "opencode"],
|
|
23
|
+
},
|
|
24
|
+
prompt: { type: "string", minLength: 1 },
|
|
25
|
+
cwd: { type: "string" },
|
|
26
|
+
kind: {
|
|
27
|
+
type: "string",
|
|
28
|
+
enum: ["session", "task"],
|
|
29
|
+
default: "session",
|
|
30
|
+
},
|
|
31
|
+
host: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: ["auto", "tmux", "pty", "macos-terminal"],
|
|
34
|
+
},
|
|
35
|
+
open: {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
description: "Open the tmux session in a terminal viewer (macOS only).",
|
|
38
|
+
},
|
|
39
|
+
terminal: {
|
|
40
|
+
type: "string",
|
|
41
|
+
enum: ["auto", "terminal", "iterm2", "ghostty"],
|
|
42
|
+
description: "Viewer app; requires open. Auto uses Terminal.app.",
|
|
43
|
+
},
|
|
44
|
+
permissions: {
|
|
45
|
+
type: "string",
|
|
46
|
+
enum: ["auto", "deny", "bypass"],
|
|
47
|
+
description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
|
|
48
|
+
},
|
|
49
|
+
sandbox: {
|
|
50
|
+
type: "string",
|
|
51
|
+
enum: ["read-only", "workspace-write", "full-access"],
|
|
52
|
+
description: "Defaults to config (read-only). Full access requires config authorization.",
|
|
53
|
+
},
|
|
54
|
+
level: {
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["read", "work", "open"],
|
|
57
|
+
description: "A spelling of a legal permissions/sandbox pair: read is deny + read-only, work is auto + workspace-write, open is bypass + full-access. Naming a level and either flag is an error.",
|
|
58
|
+
},
|
|
59
|
+
mcp: {
|
|
60
|
+
type: "array",
|
|
61
|
+
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
62
|
+
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
63
|
+
},
|
|
64
|
+
project: {
|
|
65
|
+
type: "string",
|
|
66
|
+
pattern: "^[a-zA-Z0-9_-]+$",
|
|
67
|
+
description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
|
|
68
|
+
},
|
|
69
|
+
requestKey: {
|
|
70
|
+
type: "string",
|
|
71
|
+
minLength: 1,
|
|
72
|
+
maxLength: 200,
|
|
73
|
+
description: "Caller-owned identity for this launch. Repeating it returns the launch it already produced instead of starting another; repeating it with different parameters is refused.",
|
|
74
|
+
},
|
|
75
|
+
plugin: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
78
|
+
description: "Configured OpenCode plugin names. Omit for session defaults; [] disables all. OpenCode only.",
|
|
79
|
+
},
|
|
80
|
+
model: {
|
|
81
|
+
type: "string",
|
|
82
|
+
minLength: 1,
|
|
83
|
+
description: "PROVIDER/MODEL, overriding the configured [opencode] model. OpenCode only.",
|
|
84
|
+
},
|
|
85
|
+
identity: {
|
|
86
|
+
type: "string",
|
|
87
|
+
pattern: "^[a-zA-Z0-9_-]+$",
|
|
88
|
+
description: "A named identity created by `muster setup-identity`; the agent runs as that account instead of inheriting the ambient environment. A non-local requester is bounded by its profile's identities.",
|
|
89
|
+
},
|
|
90
|
+
args: { type: "array", items: { type: "string" } },
|
|
91
|
+
},
|
|
92
|
+
required: ["runtime", "prompt"],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "list",
|
|
98
|
+
description: "List Muster-owned sessions and tasks, including host capabilities.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: { kind: { type: "string", enum: ["session", "task"] } },
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "stop",
|
|
107
|
+
description: "Stop a Muster-owned run by durable id or unambiguous peer name.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: { id: { type: "string", minLength: 1 } },
|
|
111
|
+
required: ["id"],
|
|
112
|
+
additionalProperties: false,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "output",
|
|
117
|
+
description: "Read captured output from a task run.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: { id: { type: "string", minLength: 1 } },
|
|
121
|
+
required: ["id"],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
/**
|
|
127
|
+
* runSchema keys deliberately absent from the advertised `run` inputSchema.
|
|
128
|
+
*
|
|
129
|
+
* Empty today, and it stays here rather than being implicit: adding a field to
|
|
130
|
+
* runSchema without either advertising it or naming it here — with a reason —
|
|
131
|
+
* fails the drift test. That is the point. An entry is a decision that MCP
|
|
132
|
+
* callers may not set the field, not a place to park an oversight.
|
|
133
|
+
*/
|
|
134
|
+
export const RUN_MCP_OMITTED = [];
|
package/dist/muster.js
CHANGED
|
@@ -6,11 +6,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { Muster } from "./run.js";
|
|
9
|
-
import { runSchema } from "./guard.js";
|
|
10
9
|
import { delay } from "./identity/processes.js";
|
|
11
10
|
import { formatHuman, humanColorEnabled } from "./format.js";
|
|
12
11
|
import { checkPlugins, npmRegistry } from "./doctor.js";
|
|
13
|
-
import { loadConfig } from "./config.js";
|
|
12
|
+
import { loadConfig, musterHome } from "./config.js";
|
|
13
|
+
import { describeIdentities, setupIdentity } from "./identity-cli.js";
|
|
14
|
+
import { MCP_TOOLS } from "./mcp-tools.js";
|
|
14
15
|
const listSchema = z
|
|
15
16
|
.object({ kind: z.enum(["session", "task"]).optional() })
|
|
16
17
|
.strict();
|
|
@@ -22,11 +23,17 @@ const help = `Usage:
|
|
|
22
23
|
muster stop <id> [--format json|human]
|
|
23
24
|
muster output <id>
|
|
24
25
|
muster doctor [--format json|human]
|
|
26
|
+
muster setup-identity --identity NAME --agent <codex|claude|opencode> [--token-env VAR]
|
|
27
|
+
muster identities [--format json|human]
|
|
25
28
|
muster mcp
|
|
26
29
|
muster --version
|
|
27
30
|
|
|
28
31
|
run also accepts --format json|human, --permissions auto|deny|bypass,
|
|
29
32
|
and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
|
|
33
|
+
--level read|work|open names a legal permissions and sandbox pair: read is
|
|
34
|
+
deny + read-only, work is auto + workspace-write, open is bypass + full-access
|
|
35
|
+
and still requires allow_dangerous_flags. Naming both a level and either flag
|
|
36
|
+
is an error rather than a precedence rule.
|
|
30
37
|
--open displays a tmux session on macOS. --terminal accepts auto (Terminal.app),
|
|
31
38
|
terminal, iterm2, or ghostty, and requires --open.
|
|
32
39
|
--mcp NAME is repeatable and replaces configured session defaults; --no-mcp
|
|
@@ -58,6 +65,27 @@ MCP: install deliberately in the one authorized session, never at user scope.
|
|
|
58
65
|
const text = (value) => ({
|
|
59
66
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
60
67
|
});
|
|
68
|
+
/**
|
|
69
|
+
* Human rendering for `identities`, kept local rather than added to
|
|
70
|
+
* format.ts's RecordView/DoctorView: an identity record shares no field with
|
|
71
|
+
* either of those shapes.
|
|
72
|
+
*/
|
|
73
|
+
function identitiesHuman(view, color) {
|
|
74
|
+
const paint = (value, code) => color ? `[${code}m${value}[0m` : value;
|
|
75
|
+
const lines = [];
|
|
76
|
+
if (!view.identities.length)
|
|
77
|
+
lines.push("No identities.");
|
|
78
|
+
for (const identity of view.identities) {
|
|
79
|
+
const code = identity.auth.state === "configured" ? 32 : 31;
|
|
80
|
+
lines.push(`${paint(identity.name, 1)} (${identity.agent}) — ${paint(identity.auth.state, code)}: ${identity.auth.detail}`);
|
|
81
|
+
}
|
|
82
|
+
if (view.staleGrants.length) {
|
|
83
|
+
lines.push("");
|
|
84
|
+
for (const grant of view.staleGrants)
|
|
85
|
+
lines.push(paint(`stale grant: profile ${grant.profile} names identity ${grant.identity}, which does not exist`, 33));
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n") + "\n";
|
|
88
|
+
}
|
|
61
89
|
async function main() {
|
|
62
90
|
const argv = process.argv.slice(2);
|
|
63
91
|
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
@@ -86,106 +114,20 @@ async function main() {
|
|
|
86
114
|
if (mcp) {
|
|
87
115
|
const server = new Server({ name: "muster", version }, { capabilities: { tools: {} } });
|
|
88
116
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
89
|
-
tools:
|
|
90
|
-
{
|
|
91
|
-
name: "run",
|
|
92
|
-
description: "Launch an instructed agent. Sessions return only when reachable; tasks return a non-messageable run handle.",
|
|
93
|
-
inputSchema: {
|
|
94
|
-
type: "object",
|
|
95
|
-
properties: {
|
|
96
|
-
runtime: {
|
|
97
|
-
type: "string",
|
|
98
|
-
enum: ["codex", "claude", "opencode"],
|
|
99
|
-
},
|
|
100
|
-
prompt: { type: "string", minLength: 1 },
|
|
101
|
-
cwd: { type: "string" },
|
|
102
|
-
kind: {
|
|
103
|
-
type: "string",
|
|
104
|
-
enum: ["session", "task"],
|
|
105
|
-
default: "session",
|
|
106
|
-
},
|
|
107
|
-
host: {
|
|
108
|
-
type: "string",
|
|
109
|
-
enum: ["auto", "tmux", "pty", "macos-terminal"],
|
|
110
|
-
},
|
|
111
|
-
open: {
|
|
112
|
-
type: "boolean",
|
|
113
|
-
description: "Open the tmux session in a terminal viewer (macOS only).",
|
|
114
|
-
},
|
|
115
|
-
terminal: {
|
|
116
|
-
type: "string",
|
|
117
|
-
enum: ["auto", "terminal", "iterm2", "ghostty"],
|
|
118
|
-
description: "Viewer app; requires open. Auto uses Terminal.app.",
|
|
119
|
-
},
|
|
120
|
-
permissions: {
|
|
121
|
-
type: "string",
|
|
122
|
-
enum: ["auto", "deny", "bypass"],
|
|
123
|
-
description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
|
|
124
|
-
},
|
|
125
|
-
sandbox: {
|
|
126
|
-
type: "string",
|
|
127
|
-
enum: ["read-only", "workspace-write", "full-access"],
|
|
128
|
-
description: "Defaults to config (read-only). Full access requires config authorization.",
|
|
129
|
-
},
|
|
130
|
-
mcp: {
|
|
131
|
-
type: "array",
|
|
132
|
-
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
133
|
-
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
134
|
-
},
|
|
135
|
-
project: {
|
|
136
|
-
type: "string",
|
|
137
|
-
pattern: "^[a-zA-Z0-9_-]+$",
|
|
138
|
-
description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
|
|
139
|
-
},
|
|
140
|
-
requestKey: {
|
|
141
|
-
type: "string",
|
|
142
|
-
minLength: 1,
|
|
143
|
-
maxLength: 200,
|
|
144
|
-
description: "Caller-owned identity for this launch. Repeating it returns the launch it already produced instead of starting another; repeating it with different parameters is refused.",
|
|
145
|
-
},
|
|
146
|
-
args: { type: "array", items: { type: "string" } },
|
|
147
|
-
},
|
|
148
|
-
required: ["runtime", "prompt"],
|
|
149
|
-
additionalProperties: false,
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
name: "list",
|
|
154
|
-
description: "List Muster-owned sessions and tasks, including host capabilities.",
|
|
155
|
-
inputSchema: {
|
|
156
|
-
type: "object",
|
|
157
|
-
properties: { kind: { type: "string", enum: ["session", "task"] } },
|
|
158
|
-
additionalProperties: false,
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
name: "stop",
|
|
163
|
-
description: "Stop a Muster-owned run by durable id or unambiguous peer name.",
|
|
164
|
-
inputSchema: {
|
|
165
|
-
type: "object",
|
|
166
|
-
properties: { id: { type: "string", minLength: 1 } },
|
|
167
|
-
required: ["id"],
|
|
168
|
-
additionalProperties: false,
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
name: "output",
|
|
173
|
-
description: "Read captured output from a task run.",
|
|
174
|
-
inputSchema: {
|
|
175
|
-
type: "object",
|
|
176
|
-
properties: { id: { type: "string", minLength: 1 } },
|
|
177
|
-
required: ["id"],
|
|
178
|
-
additionalProperties: false,
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
],
|
|
117
|
+
tools: MCP_TOOLS,
|
|
182
118
|
}));
|
|
183
119
|
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
184
120
|
try {
|
|
185
121
|
const args = params.arguments ?? {};
|
|
186
122
|
switch (params.name) {
|
|
187
123
|
case "run":
|
|
188
|
-
return text(await muster.run(
|
|
124
|
+
return text(await muster.run(args, {
|
|
125
|
+
kind: "local",
|
|
126
|
+
// The client's self-reported name was never authentication; it
|
|
127
|
+
// is a label on a local requester, which is what it always was
|
|
128
|
+
// in substance.
|
|
129
|
+
label: `mcp:${server.getClientVersion()?.name ?? "unknown"}`,
|
|
130
|
+
}));
|
|
189
131
|
case "list":
|
|
190
132
|
return text(await muster.list(listSchema.parse(args).kind));
|
|
191
133
|
case "stop":
|
|
@@ -229,6 +171,7 @@ async function main() {
|
|
|
229
171
|
format: { type: "string" },
|
|
230
172
|
permissions: { type: "string" },
|
|
231
173
|
sandbox: { type: "string" },
|
|
174
|
+
level: { type: "string" },
|
|
232
175
|
mcp: { type: "string", multiple: true },
|
|
233
176
|
"no-mcp": { type: "boolean" },
|
|
234
177
|
plugin: { type: "string", multiple: true },
|
|
@@ -236,6 +179,9 @@ async function main() {
|
|
|
236
179
|
"no-plugin": { type: "boolean" },
|
|
237
180
|
"request-key": { type: "string" },
|
|
238
181
|
project: { type: "string" },
|
|
182
|
+
identity: { type: "string" },
|
|
183
|
+
agent: { type: "string" },
|
|
184
|
+
"token-env": { type: "string" },
|
|
239
185
|
},
|
|
240
186
|
});
|
|
241
187
|
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
|
|
@@ -257,6 +203,8 @@ async function main() {
|
|
|
257
203
|
throw new Error("format must be json or human");
|
|
258
204
|
if (command === "output" && parsed.format !== undefined)
|
|
259
205
|
throw new Error("output prints raw task text; --format applies to run, list and stop");
|
|
206
|
+
if (command === "setup-identity" && parsed.format !== undefined)
|
|
207
|
+
throw new Error("setup-identity always prints JSON; --format applies to run, list, stop, doctor and identities");
|
|
260
208
|
const print = (value) => process.stdout.write(format === "human"
|
|
261
209
|
? formatHuman(value, humanColorEnabled(process.stdout.isTTY, process.env))
|
|
262
210
|
: JSON.stringify(value) + "\n");
|
|
@@ -281,6 +229,34 @@ async function main() {
|
|
|
281
229
|
throw new Error("list takes no positional arguments");
|
|
282
230
|
print(await muster.list(listSchema.parse(values).kind));
|
|
283
231
|
}
|
|
232
|
+
else if (command === "setup-identity") {
|
|
233
|
+
if (positionals.length)
|
|
234
|
+
throw new Error("setup-identity takes no positional arguments");
|
|
235
|
+
const { identity, agent, "token-env": tokenEnv, } = values;
|
|
236
|
+
if (!identity || !agent)
|
|
237
|
+
throw new Error("setup-identity requires --identity and --agent");
|
|
238
|
+
// Not routed through print(): its RecordView/DoctorView shapes are for
|
|
239
|
+
// run/list/stop/doctor, and an identity record shares no field with
|
|
240
|
+
// either, so this always prints JSON.
|
|
241
|
+
process.stdout.write(JSON.stringify(await setupIdentity({
|
|
242
|
+
home: musterHome(),
|
|
243
|
+
name: identity,
|
|
244
|
+
agent: agent,
|
|
245
|
+
...(tokenEnv ? { tokenEnv } : {}),
|
|
246
|
+
})) + "\n");
|
|
247
|
+
}
|
|
248
|
+
else if (command === "identities") {
|
|
249
|
+
if (positionals.length)
|
|
250
|
+
throw new Error("identities takes no positional arguments");
|
|
251
|
+
const cfg = await loadConfig();
|
|
252
|
+
const view = await describeIdentities(musterHome(), process.env, Object.fromEntries(Object.entries(cfg.requester_profiles).map(([n, p]) => [
|
|
253
|
+
n,
|
|
254
|
+
p.identities,
|
|
255
|
+
])));
|
|
256
|
+
process.stdout.write(format === "human"
|
|
257
|
+
? identitiesHuman(view, humanColorEnabled(process.stdout.isTTY, process.env))
|
|
258
|
+
: JSON.stringify(view) + "\n");
|
|
259
|
+
}
|
|
284
260
|
else if (command === "doctor") {
|
|
285
261
|
if (positionals.length || Object.keys(values).length)
|
|
286
262
|
throw new Error("doctor takes no arguments");
|
package/dist/registry.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { processRef, isSame, delay, } from "./identity/processes.js";
|
|
4
|
+
import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
|
|
5
|
+
import { LOCAL, requesterKey } from "./requester.js";
|
|
5
6
|
/**
|
|
6
7
|
* A request key arrived again with different parameters. Distinct from a launch
|
|
7
8
|
* failure: nothing was attempted, and the caller reused a key it should have
|
|
@@ -80,7 +81,11 @@ export class Registry {
|
|
|
80
81
|
if (["starting", "running"].includes(entry.status) &&
|
|
81
82
|
entry.root &&
|
|
82
83
|
!(await isSame(entry.root))) {
|
|
83
|
-
|
|
84
|
+
// The recorded descendants are only those seen during launch, and a
|
|
85
|
+
// reparented one is not among them at all. Ask the group before
|
|
86
|
+
// concluding the session ended: it is the signal that survives both.
|
|
87
|
+
if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
|
|
88
|
+
!(entry.group && (await groupAlive(entry.group))))
|
|
84
89
|
entry.status = "exited";
|
|
85
90
|
}
|
|
86
91
|
}
|
|
@@ -108,19 +113,28 @@ export class Registry {
|
|
|
108
113
|
* first would reject a retry of a launch that succeeded, which reads as a
|
|
109
114
|
* failure and invites a third attempt.
|
|
110
115
|
*/
|
|
111
|
-
async reserve(req, cap, idem) {
|
|
116
|
+
async reserve(req, cap, idem, requester = LOCAL) {
|
|
112
117
|
const owner = await processRef(process.pid);
|
|
113
118
|
if (!owner)
|
|
114
119
|
throw new Error("Cannot identify Muster owner process");
|
|
120
|
+
const scope = requesterKey(requester);
|
|
115
121
|
return this.transaction(async (entries) => {
|
|
116
122
|
if (idem) {
|
|
117
|
-
|
|
123
|
+
// Scoped by requester: two machines may legitimately choose the same
|
|
124
|
+
// request key, and deduping one into the other would hand a caller a
|
|
125
|
+
// session it never asked for.
|
|
126
|
+
const prior = entries.find((e) => e.requestKey === idem.requestKey &&
|
|
127
|
+
requesterKey(e.requester ?? LOCAL) === scope);
|
|
118
128
|
if (prior) {
|
|
119
129
|
if (prior.paramsFingerprint !== idem.paramsFingerprint)
|
|
120
130
|
throw new IdempotencyKeyReuse(idem.requestKey, prior.paramsFingerprint ?? "none", idem.paramsFingerprint);
|
|
121
131
|
return { entry: structuredClone(prior), deduped: true };
|
|
122
132
|
}
|
|
123
133
|
}
|
|
134
|
+
// Admission seam: `scope` is the per-requester key a future per-requester
|
|
135
|
+
// cap and locally-reserved portion would count against, available here
|
|
136
|
+
// inside the transaction where the accounting has to happen. The cap is
|
|
137
|
+
// global today.
|
|
124
138
|
if (entries.filter((e) => e.status === "starting" || e.status === "running")
|
|
125
139
|
.length >= cap)
|
|
126
140
|
throw new Error(`Concurrency cap reached (${cap})`);
|
|
@@ -131,6 +145,7 @@ export class Registry {
|
|
|
131
145
|
launchId: id,
|
|
132
146
|
...(idem ?? {}),
|
|
133
147
|
status: "starting",
|
|
148
|
+
requester,
|
|
134
149
|
createdAt: new Date().toISOString(),
|
|
135
150
|
owner,
|
|
136
151
|
};
|
|
@@ -147,6 +162,8 @@ export class Registry {
|
|
|
147
162
|
const entry = byLaunch ?? durable[0];
|
|
148
163
|
if (!entry)
|
|
149
164
|
throw new Error(`Unknown launch ${id}`);
|
|
165
|
+
if ("requester" in patch)
|
|
166
|
+
throw new Error("requester is recorded once and cannot be updated");
|
|
150
167
|
if (patch.id &&
|
|
151
168
|
entries.some((e) => e !== entry && e.runtime === entry.runtime && e.id === patch.id))
|
|
152
169
|
throw new Error("Durable identity already registered");
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { LOCAL, requesterKey } from "./requester.js";
|
|
2
|
+
/** A remote tuple with no enrolment. Absence of a grant is a denial. */
|
|
3
|
+
export class RequesterNotEnrolled extends Error {
|
|
4
|
+
authority;
|
|
5
|
+
subjectPrefix;
|
|
6
|
+
constructor(authority, subjectPrefix) {
|
|
7
|
+
super(`No enrolment for requester ${authority}/${subjectPrefix} — add a [[requesters]] entry to config.toml`);
|
|
8
|
+
this.authority = authority;
|
|
9
|
+
this.subjectPrefix = subjectPrefix;
|
|
10
|
+
this.name = "RequesterNotEnrolled";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Enrolled, but the request exceeds the profile. `ceiling` is what a transport
|
|
15
|
+
* maps to a stable code, so nothing downstream matches on message text.
|
|
16
|
+
*/
|
|
17
|
+
export class RequesterPolicyRefusal extends Error {
|
|
18
|
+
ceiling;
|
|
19
|
+
constructor(ceiling, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.ceiling = ceiling;
|
|
22
|
+
this.name = "RequesterPolicyRefusal";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The receiver is misconfigured. Not the requester's fault, so not a refusal. */
|
|
26
|
+
export class RequesterConfigError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "RequesterConfigError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Ordered weakest to strongest, so a ceiling is one index comparison. */
|
|
33
|
+
const LEVEL_ORDER = ["read", "work", "open"];
|
|
34
|
+
const GRADE_ORDER = ["none", "tool-policy", "kernel"];
|
|
35
|
+
export function resolveRequesterPolicy(requester, config) {
|
|
36
|
+
if (requester.kind === "local")
|
|
37
|
+
return {
|
|
38
|
+
profile: undefined,
|
|
39
|
+
level: undefined,
|
|
40
|
+
minEnforcement: undefined,
|
|
41
|
+
logMode: "full",
|
|
42
|
+
identities: undefined,
|
|
43
|
+
};
|
|
44
|
+
const enrolment = config.requesters.find((e) => e.authority === requester.authority && e.subject === requester.subject);
|
|
45
|
+
if (!enrolment)
|
|
46
|
+
throw new RequesterNotEnrolled(requester.authority, requester.subject.slice(0, 4));
|
|
47
|
+
const profile = config.requester_profiles[enrolment.profile];
|
|
48
|
+
if (!profile)
|
|
49
|
+
throw new RequesterConfigError(`Enrolment ${enrolment.authority}/${enrolment.subject} names unknown profile ${enrolment.profile}`);
|
|
50
|
+
return {
|
|
51
|
+
profile,
|
|
52
|
+
level: profile.level,
|
|
53
|
+
minEnforcement: profile.min_enforcement,
|
|
54
|
+
// Not a profile setting: the exposure is the receiver's to carry and the
|
|
55
|
+
// requester's to avoid, so a profile may not opt into full logging.
|
|
56
|
+
logMode: "metadata",
|
|
57
|
+
identities: profile.identities,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function assertLevelWithinCeiling(requested, policy) {
|
|
61
|
+
if (!policy.level)
|
|
62
|
+
return;
|
|
63
|
+
if (LEVEL_ORDER.indexOf(requested) > LEVEL_ORDER.indexOf(policy.level))
|
|
64
|
+
throw new RequesterPolicyRefusal("level", `Level ${requested} exceeds this requester's ceiling of ${policy.level}`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* May this requester act on this entry?
|
|
68
|
+
*
|
|
69
|
+
* Asymmetric on purpose. A local requester is the operator and may stop or read
|
|
70
|
+
* anything on the machine. A remote requester may act only on launches it asked
|
|
71
|
+
* for — an entry with no recorded requester predates the field and reads as
|
|
72
|
+
* local, so it is protected rather than open.
|
|
73
|
+
*/
|
|
74
|
+
export function assertMayAct(entry, requester) {
|
|
75
|
+
if (requester.kind === "local")
|
|
76
|
+
return;
|
|
77
|
+
if (requesterKey(entry.requester ?? LOCAL) !== requesterKey(requester))
|
|
78
|
+
throw new RequesterPolicyRefusal("not-owner", "this launch belongs to a different requester");
|
|
79
|
+
}
|
|
80
|
+
export function assertEnforcementMeetsMinimum(runtime, grade, policy) {
|
|
81
|
+
if (!policy.minEnforcement)
|
|
82
|
+
return;
|
|
83
|
+
if (GRADE_ORDER.indexOf(grade) < GRADE_ORDER.indexOf(policy.minEnforcement))
|
|
84
|
+
throw new RequesterPolicyRefusal("enforcement", `Runtime ${runtime} offers ${grade} enforcement and this requester requires ${policy.minEnforcement}`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Bound, do not pin — the same shape as allowed_roots, level and
|
|
88
|
+
* min_enforcement. A local requester is the operator and may name anything.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveIdentityForRequest(requested, policy) {
|
|
91
|
+
if (!policy.identities)
|
|
92
|
+
return requested;
|
|
93
|
+
const granted = policy.identities;
|
|
94
|
+
if (requested) {
|
|
95
|
+
if (!granted.includes(requested))
|
|
96
|
+
throw new RequesterPolicyRefusal("identity", granted.length
|
|
97
|
+
? `identity ${requested} is not granted to this requester (granted: ${granted.join(", ")})`
|
|
98
|
+
: `this requester is granted no identity, so it may not name one`);
|
|
99
|
+
return requested;
|
|
100
|
+
}
|
|
101
|
+
if (granted.length === 1)
|
|
102
|
+
return granted[0];
|
|
103
|
+
if (granted.length > 1)
|
|
104
|
+
throw new RequesterPolicyRefusal("identity", `this requester must name one of: ${granted.join(", ")}`);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Opaque, and free of control characters. The key encoding must never be able
|
|
4
|
+
* to confuse two identities, and a NUL or other control byte inside a field is
|
|
5
|
+
* the way that happens.
|
|
6
|
+
*/
|
|
7
|
+
const opaqueField = (max) => z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(max)
|
|
11
|
+
.regex(/^[ -~]+$/, "must not contain control characters");
|
|
12
|
+
export const authoritySchema = opaqueField(200);
|
|
13
|
+
export const subjectSchema = opaqueField(400);
|
|
14
|
+
/**
|
|
15
|
+
* Who asked for a launch.
|
|
16
|
+
*
|
|
17
|
+
* `authority` names the identity namespace that authenticated `subject`;
|
|
18
|
+
* `subject` is a stable opaque identifier within it. Muster treats both as
|
|
19
|
+
* opaque and hardcodes no namespace, so the spelling an integrator adopts is
|
|
20
|
+
* configuration rather than a Muster release.
|
|
21
|
+
*
|
|
22
|
+
* Muster does not authenticate its caller. Anything able to import Muster or
|
|
23
|
+
* exec its binary already runs as the owner account. The value here is least
|
|
24
|
+
* privilege and honest provenance for a trusted local invoker that has already
|
|
25
|
+
* authenticated a remote peer — not a defence against a compromised same-user
|
|
26
|
+
* process.
|
|
27
|
+
*/
|
|
28
|
+
export const requesterSchema = z.discriminatedUnion("kind", [
|
|
29
|
+
z
|
|
30
|
+
.object({
|
|
31
|
+
kind: z.literal("local"),
|
|
32
|
+
label: z.string().min(1).max(200).optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict(),
|
|
35
|
+
z
|
|
36
|
+
.object({
|
|
37
|
+
kind: z.literal("remote"),
|
|
38
|
+
authority: authoritySchema,
|
|
39
|
+
subject: subjectSchema,
|
|
40
|
+
label: z.string().min(1).max(200).optional(),
|
|
41
|
+
})
|
|
42
|
+
.strict(),
|
|
43
|
+
]);
|
|
44
|
+
export const LOCAL = { kind: "local" };
|
|
45
|
+
/**
|
|
46
|
+
* The policy and quota key. `label` is not a parameter, which is what keeps a
|
|
47
|
+
* caller-chosen display string structurally incapable of affecting policy.
|
|
48
|
+
*/
|
|
49
|
+
export function requesterKey(r) {
|
|
50
|
+
return r.kind === "local"
|
|
51
|
+
? "local"
|
|
52
|
+
: JSON.stringify(["remote", r.authority, r.subject]);
|
|
53
|
+
}
|
|
54
|
+
/** For humans and log lines only; never a key. */
|
|
55
|
+
export function requesterLabel(r) {
|
|
56
|
+
if (r.label)
|
|
57
|
+
return r.label;
|
|
58
|
+
return r.kind === "local" ? "local" : `${r.authority}/${r.subject}`;
|
|
59
|
+
}
|