balladeer 1.0.1 → 1.0.3

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.
@@ -0,0 +1,159 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse } from "@iarna/toml";
5
+ import { isOurEntry } from "./mcp-config.js";
6
+ export const CODEX_CONFIG_FILE = ".codex/config.toml";
7
+ const START = "# balladeer:mcp:start";
8
+ const END = "# balladeer:mcp:end";
9
+ function render(entry) {
10
+ return [
11
+ START,
12
+ "[mcp_servers.balladeer]",
13
+ `command = ${JSON.stringify(entry.command)}`,
14
+ `args = ${JSON.stringify(entry.args)}`,
15
+ ...(entry.env
16
+ ? [
17
+ "[mcp_servers.balladeer.env]",
18
+ ...Object.entries(entry.env).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`),
19
+ ]
20
+ : []),
21
+ END,
22
+ "",
23
+ ].join("\n");
24
+ }
25
+ function withoutOurs(value) {
26
+ const copy = structuredClone(value);
27
+ const servers = copy.mcp_servers;
28
+ if (servers &&
29
+ typeof servers === "object" &&
30
+ !Array.isArray(servers) &&
31
+ !(servers instanceof Date)) {
32
+ delete servers.balladeer;
33
+ if (Object.keys(servers).length === 0)
34
+ delete copy.mcp_servers;
35
+ }
36
+ return JSON.stringify(copy);
37
+ }
38
+ export function readCodexEntry(root) {
39
+ try {
40
+ const config = parse(readFileSync(join(root, CODEX_CONFIG_FILE), "utf8"));
41
+ const servers = config.mcp_servers;
42
+ return servers &&
43
+ typeof servers === "object" &&
44
+ !Array.isArray(servers) &&
45
+ !(servers instanceof Date)
46
+ ? servers.balladeer
47
+ : undefined;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ /** A client hint only when the complete file and our exact fenced table agree. */
54
+ export function hasManagedCodexEntry(root, controlPlane) {
55
+ try {
56
+ const text = readFileSync(join(root, CODEX_CONFIG_FILE), "utf8");
57
+ const starts = [...text.matchAll(/^# balladeer:mcp:start\r?$/gm)];
58
+ const ends = [...text.matchAll(/^# balladeer:mcp:end\r?$/gm)];
59
+ if (starts.length !== 1 ||
60
+ ends.length !== 1 ||
61
+ !starts[0] ||
62
+ !ends[0] ||
63
+ starts[0].index >= ends[0].index)
64
+ return false;
65
+ const block = parse(text.slice(starts[0].index + starts[0][0].length, ends[0].index));
66
+ const servers = block.mcp_servers;
67
+ if (Object.keys(block).length !== 1 ||
68
+ !servers ||
69
+ typeof servers !== "object" ||
70
+ Array.isArray(servers) ||
71
+ servers instanceof Date ||
72
+ Object.keys(servers).length !== 1)
73
+ return false;
74
+ const own = readCodexEntry(root);
75
+ return (isOurEntry(own, controlPlane) && JSON.stringify(own) === JSON.stringify(servers.balladeer));
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ /** Only our fenced table is replaced; every unrelated byte and parsed setting survives. */
82
+ export function mergeCodexConfig(root, entry, controlPlane) {
83
+ const block = render(entry);
84
+ const refused = (reason) => ({
85
+ kind: "refused",
86
+ reason: `${CODEX_CONFIG_FILE}: ${reason} No configuration was changed.`,
87
+ block,
88
+ });
89
+ const directory = join(root, ".codex");
90
+ const path = join(root, CODEX_CONFIG_FILE);
91
+ if (existsSync(directory) &&
92
+ (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()))
93
+ return refused("the directory is not an ordinary project directory.");
94
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()))
95
+ return refused("the file is not an ordinary project file.");
96
+ let previous = "";
97
+ let parsed;
98
+ try {
99
+ if (existsSync(path))
100
+ previous = readFileSync(path, "utf8");
101
+ parsed = parse(previous);
102
+ }
103
+ catch {
104
+ return refused("could not read valid TOML; repair the file before running setup again.");
105
+ }
106
+ const starts = [...previous.matchAll(/^# balladeer:mcp:start\r?$/gm)];
107
+ const ends = [...previous.matchAll(/^# balladeer:mcp:end\r?$/gm)];
108
+ if (starts.length !== ends.length ||
109
+ starts.length > 1 ||
110
+ (starts[0] && ends[0] && starts[0].index >= ends[0].index))
111
+ return refused("the Balladeer markers are damaged; repair them before running setup again.");
112
+ const servers = parsed.mcp_servers;
113
+ if (servers !== undefined &&
114
+ (servers === null ||
115
+ typeof servers !== "object" ||
116
+ Array.isArray(servers) ||
117
+ servers instanceof Date))
118
+ return refused("mcp_servers must be a table.");
119
+ const own = servers && typeof servers === "object" && !Array.isArray(servers) && !(servers instanceof Date)
120
+ ? servers.balladeer
121
+ : undefined;
122
+ if (own !== undefined && (!starts[0] || !isOurEntry(own, controlPlane)))
123
+ return refused("an unmanaged or unrecognized balladeer table already exists. Review it and merge the supplied block yourself; setup will not replace it.");
124
+ if (starts[0] && own === undefined)
125
+ return refused("the marked block does not contain a Balladeer server; review the markers first.");
126
+ const next = starts[0] && ends[0]
127
+ ? previous.slice(0, starts[0].index) +
128
+ block.trimEnd() +
129
+ previous.slice(ends[0].index + ends[0][0].length)
130
+ : previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + "\n" + block;
131
+ try {
132
+ const result = parse(next);
133
+ if (withoutOurs(parsed) !== withoutOurs(result))
134
+ return refused("the marked replacement would change another setting; review the block manually.");
135
+ }
136
+ catch {
137
+ return refused("the proposed block conflicts with existing TOML tables; merge the supplied block manually.");
138
+ }
139
+ if (next === previous)
140
+ return { kind: "written", changed: false };
141
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
142
+ const temporary = join(directory, `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
143
+ let descriptor;
144
+ try {
145
+ descriptor = openSync(temporary, "wx", existsSync(path) ? lstatSync(path).mode & 0o777 : 0o600);
146
+ writeSync(descriptor, next);
147
+ fsyncSync(descriptor);
148
+ closeSync(descriptor);
149
+ descriptor = undefined;
150
+ renameSync(temporary, path);
151
+ }
152
+ finally {
153
+ if (descriptor !== undefined)
154
+ closeSync(descriptor);
155
+ if (existsSync(temporary))
156
+ unlinkSync(temporary);
157
+ }
158
+ return { kind: "written", changed: true };
159
+ }
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import { TOUCH_MAP_FILE, affectedPaths, readLocalPackages, readTouchMap, verifierDigest, } from "../touch-map.js";
3
3
  import { formatInstant } from "../local-time.js";
4
- import {} from "../wire.js";
4
+ import { CLI_INVOCATION } from "../wire.js";
5
5
  import { promiseRoot } from "./touch-map.js";
6
6
  /** How many promises one path lists before the report says how many more. */
7
7
  const NAMED_LIMIT = 20;
@@ -36,7 +36,7 @@ export async function runAffected(options) {
36
36
  options.write(`${text}\n`);
37
37
  };
38
38
  if (options.paths.length === 0) {
39
- const message = "Name at least one path: balladeer affected src/orders/total.ts";
39
+ const message = `Name at least one path: ${CLI_INVOCATION} affected src/orders/total.ts`;
40
40
  if (options.json)
41
41
  emit({ step: "error", reason: "usage", message, changed: false, exitCode: 4 });
42
42
  else
@@ -55,8 +55,8 @@ export async function runAffected(options) {
55
55
  const reading = readTouchMap(root);
56
56
  if (reading.kind !== "map") {
57
57
  const message = reading.kind === "absent"
58
- ? `There is no touch map in this checkout yet. Build one with: balladeer touch-map`
59
- : `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: balladeer touch-map`;
58
+ ? `There is no touch map in this checkout yet. Build one with: ${CLI_INVOCATION} touch-map`
59
+ : `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: ${CLI_INVOCATION} touch-map`;
60
60
  emit({ step: "affected", mapped: false, paths: [], changed: false });
61
61
  say(message);
62
62
  // Zero, because having no map is not a fault in the change somebody is
@@ -115,8 +115,8 @@ export async function runAffected(options) {
115
115
  say("");
116
116
  if (stale > 0) {
117
117
  say(stale === 1
118
- ? "One answer above is stale. Run balladeer touch-map to measure it again."
119
- : `${stale} of those answers are stale. Run balladeer touch-map to measure them again.`);
118
+ ? `One answer above is stale. Run ${CLI_INVOCATION} touch-map to measure it again.`
119
+ : `${stale} of those answers are stale. Run ${CLI_INVOCATION} touch-map to measure them again.`);
120
120
  }
121
121
  say("This came from your own checkout. Balladeer was not asked, and was told nothing.");
122
122
  return 0;
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { runCommand } from "../gh.js";
4
4
  import { repositoryRoot } from "../git.js";
5
5
  import { promiseForPath, sealedPromises } from "../seals.js";
6
- import {} from "../wire.js";
6
+ import { CLI_INVOCATION } from "../wire.js";
7
7
  /**
8
8
  * Where the pinned runner sits once setup's instructions have been followed.
9
9
  *
@@ -258,17 +258,17 @@ function installPrePushHook(root, options, emit, say) {
258
258
  const hookPath = join(root, ".git", "hooks", "pre-push");
259
259
  if (existsSync(hookPath)) {
260
260
  const message = `${hookPath} already exists, so nothing was written. Add this line to it yourself if you ` +
261
- "want the check: balladeer check-seals";
261
+ `want the check: ${CLI_INVOCATION} check-seals`;
262
262
  emit({ step: "error", reason: "hook_exists", message, changed: false, exitCode: 4 });
263
263
  say(message);
264
264
  return 4;
265
265
  }
266
266
  const script = [
267
267
  "#!/bin/sh",
268
- "# Installed by: balladeer check-seals --install-hook",
268
+ `# Installed by: ${CLI_INVOCATION} check-seals --install-hook`,
269
269
  "# It stops a push that would break a promise's seal, and says nothing otherwise.",
270
270
  "# Delete this file to remove it.",
271
- "balladeer check-seals || exit 1",
271
+ `${CLI_INVOCATION} check-seals || exit 1`,
272
272
  "",
273
273
  ].join("\n");
274
274
  try {
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { callAgentTool, selectAgent, structuredString } from "../agent.js";
2
+ import { callAgentTool, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
3
3
  import { batchProposalReviewLink, proposalReviewLink } from "../client.js";
4
4
  import { commandLine } from "../release.js";
5
5
  import { repositoryHint } from "../repository.js";
6
6
  import { StoreError, findSession, readCredentials } from "../store.js";
7
- import {} from "../wire.js";
7
+ import { CLI_INVOCATION } from "../wire.js";
8
8
  import { readTeachBackFile } from "./propose.js";
9
9
  /**
10
10
  * Ten promises at 64 KiB each, which is the per-promise cap `propose` already
@@ -197,7 +197,7 @@ export async function runDiscover(options) {
197
197
  return exitCode;
198
198
  };
199
199
  if (!options.file) {
200
- return fail("usage", "Give a catalog file: balladeer discover --file <path>.", 4);
200
+ return fail("usage", `Give a catalog file: ${CLI_INVOCATION} discover --file <path>.`, 4);
201
201
  }
202
202
  let raw;
203
203
  try {
@@ -232,6 +232,7 @@ export async function runDiscover(options) {
232
232
  `Run \`${commandLine(null, "setup")}\` here again to pair this machine, which records who you are, or name the owner yourself with --owner <membership id> from the members list on the Balladeer MCP server's setup tool.`, 4);
233
233
  }
234
234
  const setup = await callAgentTool(agent, "get_promise_setup", {});
235
+ reportAgentEnforcementWarning(setup, options);
235
236
  if (setup.kind !== "result") {
236
237
  return fail("owner_unverified", `${memberListRefusal(setup.kind, options.controlPlane)} Nothing was sent.`, 5);
237
238
  }
@@ -254,7 +255,7 @@ export async function runDiscover(options) {
254
255
  index: index + 1,
255
256
  title,
256
257
  candidateId: outcome.candidateId,
257
- reviewUrl: proposalReviewLink(options.controlPlane, outcome.candidateId),
258
+ reviewUrl: proposalReviewLink(options.controlPlane, outcome.candidateId, agent.workspaceId),
258
259
  };
259
260
  filed.push(entry);
260
261
  emit({
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
3
+ import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
4
4
  import { inReadersZone } from "../local-time.js";
5
5
  import { commandLine } from "../release.js";
6
6
  import { repositoryHint } from "../repository.js";
@@ -150,6 +150,7 @@ export async function runPrepare(options) {
150
150
  promiseId: options.promiseId,
151
151
  again: options.again,
152
152
  });
153
+ reportAgentEnforcementWarning(call, options);
153
154
  switch (call.kind) {
154
155
  case "endpoint_refused":
155
156
  return fail("agent_endpoint_unsafe", `${call.reason} Nothing was prepared.`, 4);
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
2
+ import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
3
3
  import { proposalReviewLink } from "../client.js";
4
4
  import { commandLine } from "../release.js";
5
5
  import { repositoryHint } from "../repository.js";
6
6
  import { StoreError, readCredentials } from "../store.js";
7
- import {} from "../wire.js";
7
+ import { CLI_INVOCATION } from "../wire.js";
8
8
  const MAX_FILE_BYTES = 64 * 1024;
9
9
  /**
10
10
  * Every field the server's schema requires with no default. It is every
@@ -190,7 +190,7 @@ export async function runPropose(options) {
190
190
  return exitCode;
191
191
  };
192
192
  if (!options.file) {
193
- return fail("usage", "Give a proposal file: balladeer propose --file <path>.", 4);
193
+ return fail("usage", `Give a proposal file: ${CLI_INVOCATION} propose --file <path>.`, 4);
194
194
  }
195
195
  let raw;
196
196
  try {
@@ -240,6 +240,7 @@ export async function runPropose(options) {
240
240
  ? {}
241
241
  : { leastSure: parsed.value.teachBack.leastSure }),
242
242
  });
243
+ reportAgentEnforcementWarning(call, options);
243
244
  switch (call.kind) {
244
245
  case "endpoint_refused":
245
246
  return fail("agent_endpoint_unsafe", `${call.reason} Nothing was sent.`, 4);
@@ -273,7 +274,7 @@ export async function runPropose(options) {
273
274
  // The review link is built from the address this copy paired with. The tool
274
275
  // answers with an id and no link at all, so there is nothing here a
275
276
  // misconfigured public base URL could redirect.
276
- const review = proposalReviewLink(options.controlPlane, candidateId);
277
+ const review = proposalReviewLink(options.controlPlane, candidateId, agent.workspaceId);
277
278
  emit({ step: "promise", status: "proposed", candidateId, reviewUrl: review });
278
279
  if (!options.json) {
279
280
  options.write("Proposed. You will own it unless you named someone else; the owner reads it and clicks Agree, and nothing else can.\n");
@@ -94,6 +94,7 @@ function report(options, rows, workspaceKnown) {
94
94
  say(options, workspaceKnown
95
95
  ? "Repositories your GitHub account can see. Ones already in Balladeer are marked."
96
96
  : "Repositories your GitHub account can see. Nothing is stored for this Balladeer, so which of them are already added is unknown.");
97
+ say(options, "These are GitHub repositories, not local directories or git worktrees.");
97
98
  say(options, "");
98
99
  for (const row of rows) {
99
100
  const marks = [
@@ -1,9 +1,9 @@
1
- import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
1
+ import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
2
2
  import { headCommit } from "../git.js";
3
3
  import { repositoryHint } from "../repository.js";
4
4
  import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
5
5
  import { StoreError, readCredentials } from "../store.js";
6
- import {} from "../wire.js";
6
+ import { CLI_INVOCATION } from "../wire.js";
7
7
  /**
8
8
  * The id that joins what an agent was told to what it then wrote.
9
9
  *
@@ -63,6 +63,18 @@ export async function runSession(options) {
63
63
  });
64
64
  }
65
65
  catch (error) {
66
+ // This catch follows successful credential selection and covers only the
67
+ // local marker write. It must never turn an auth or --record refusal into
68
+ // permission to continue, or print the freshly generated but unsaved ID.
69
+ const filesystemError = error instanceof StoreError && error.code === "credential_store_unwritable"
70
+ ? error.cause
71
+ : error;
72
+ const permissionCode = filesystemError instanceof Error && "code" in filesystemError
73
+ ? filesystemError.code
74
+ : undefined;
75
+ if (permissionCode === "EPERM" || permissionCode === "EACCES") {
76
+ return fail("session_store_permission_denied", `No session stamp was saved: local file permissions refused the write (${permissionCode}). Your saved authenticated connection is unchanged; this command has not checked it with the server. Continue already-authorized MCP reads, coding and explicitly requested capture without the optional session field. Do not invent an ID, add a session trailer, or run session --record for this unsaved stamp. Do not broaden filesystem access or move credentials to retry this write. Capture still requires the person's request or accepted offer, and human meaning approval is unchanged.`, 4);
77
+ }
66
78
  return fail(error instanceof StoreError ? error.code : "session_store_unwritable", error instanceof StoreError ? error.message : String(error), 4);
67
79
  }
68
80
  const trailer = agentSessionTrailerLine(session.sessionId);
@@ -83,7 +95,7 @@ export async function runSession(options) {
83
95
  say("");
84
96
  say(` ${trailer}`);
85
97
  say("");
86
- say(`and run \`balladeer session --record\` once the commit exists, so a check that goes red on it can be read back against what you were told before you started. Balladeer is sent the session id and the commit SHA, and nothing else.`);
98
+ say(`and run \`${CLI_INVOCATION} session --record\` once the commit exists, so a check that goes red on it can be read back against what you were told before you started. Balladeer is sent the session id and the commit SHA, and nothing else.`);
87
99
  return 0;
88
100
  }
89
101
  async function recordCommit(options, agent, emit, say, fail) {
@@ -96,12 +108,13 @@ async function recordCommit(options, agent, emit, say, fail) {
96
108
  // Said in full rather than as a code. The likeliest reader of this line is
97
109
  // an agent that forgot the trailer, and the remedy is one line it can add
98
110
  // to the commit it just made.
99
- return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id balladeer session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
111
+ return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id ${CLI_INVOCATION} session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
100
112
  }
101
113
  const call = await callAgentTool(agent, "record_session_commit", {
102
114
  session: sessionId,
103
115
  commitSha: head.sha,
104
116
  });
117
+ reportAgentEnforcementWarning(call, options);
105
118
  if (call.kind !== "result") {
106
119
  return fail(call.kind === "tool_refusal" ? "refused" : call.kind, call.kind === "tool_refusal"
107
120
  ? call.text
@@ -4,6 +4,8 @@ export type SetupOptions = Readonly<{
4
4
  wait: boolean;
5
5
  repo?: string;
6
6
  createWorkspace?: string;
7
+ client?: "codex" | "claude";
8
+ chooseWorkspace?: boolean;
7
9
  /**
8
10
  * Every repository this run was told to set up, in the order they were named.
9
11
  *
@@ -14,6 +16,12 @@ export type SetupOptions = Readonly<{
14
16
  * one checkout can honestly do from here, and the run says so per repository.
15
17
  */
16
18
  repositories?: readonly string[];
19
+ /**
20
+ * Connect an invited teammate to a repository the workspace already holds.
21
+ * This is deliberately a refusal boundary: if the repository is absent, the
22
+ * run stops rather than turning an invitation into authority to enroll it.
23
+ */
24
+ existingOnly?: boolean;
17
25
  /**
18
26
  * Repair the two files a previous setup wrote in this repository, and do
19
27
  * nothing else. No pairing, no enrollment, no credential, no network: a