maesttro 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -2
  2. package/dist/maesttro.js +112 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,9 +58,10 @@ maesttro doctor # health check anytime
58
58
 
59
59
  maesttro # from a repo root: new session clone + default client (opencode)
60
60
  maesttro opencode # same, with opencode
61
+ maesttro --strict [client] # same, with no GitHub token in the runner (no `gh`)
61
62
  maesttro bash # plain shell inside the runner, for debugging
62
63
  maesttro sessions # list this repo's kept sessions
63
- maesttro resume <id> # continue one
64
+ maesttro resume <id> # continue one, in the mode it started with
64
65
  maesttro push [branch] # push a branch a session created (shows what goes out, asks first)
65
66
 
66
67
  maesttro update # bump the package + rebuild image with refreshed configs
@@ -106,6 +107,15 @@ comments). It acts as the Maesttro GitHub App's bot:
106
107
  - Commits are authored by you (your repo's `user.name` / `user.email`) and committed by the bot.
107
108
  - The broker is reached over the tailnet. Off the tailnet, the session still starts, just without `gh`.
108
109
 
110
+ The write side is coarse. GitHub has no finer grant than "issues" and "pull
111
+ requests", so the same token that lets the agent comment and open PRs also lets
112
+ it edit label definitions and milestones, edit or close other people's issues and
113
+ comments, and submit reviews. If that is more than you want to hand an agent,
114
+ start the session with `maesttro --strict`: no token is minted and none reaches
115
+ the runner. Local git and `maesttro push` work as usual; `gh` and `git fetch` on a
116
+ private repo don't, and the agent hands GitHub actions to you instead. Resuming a
117
+ strict session stays strict; `maesttro resume <id> --no-strict` lifts it.
118
+
109
119
  ## Your instructions come along
110
120
 
111
121
  At every container start, `maesttro configure` copies your host
@@ -169,7 +179,7 @@ untouched by an update.
169
179
  | Mount | Source | Purpose |
170
180
  |---|---|---|
171
181
  | `/home/agent` | `maesttro-home` named volume | Per-client session state. Survives across runs, lives only in Docker. Holds no GitHub credential. |
172
- | `/home/agent/.config/gh-maesttro` (read-only) | per-session temp dir on host | The session's GitHub token (`hosts.yml`). The CLI refreshes it before its 1 h expiry and revokes it on exit. |
182
+ | `/home/agent/.config/gh-maesttro` (read-only) | per-session temp dir on host | The session's GitHub token (`hosts.yml`). The CLI refreshes it before its 1 h expiry and revokes it on exit. Not mounted in `--strict` sessions, or when GitHub access is unavailable. |
173
183
  | `<repo>/.git/objects` (read-only, same absolute path) | your repo's object store | Base history, borrowed by the clone through its `alternates`. The real `.git` is never mounted. |
174
184
  | `/workspace` | the session clone (`.worktrees/<id>`) | The working directory: a `git clone --shared` detached at the default branch. |
175
185
  | `/opt/maesttro`, `/opt/maesttro-host` (read-only) | this package; your instruction files | Used by `maesttro configure` at container start. |
package/dist/maesttro.js CHANGED
@@ -730,22 +730,41 @@ var IMAGE = "maesttro-runner:latest";
730
730
  var VOLUME = process.env.MAESTTRO_VOLUME?.trim() || "maesttro-home";
731
731
 
732
732
  // src/runner-context.ts
733
+ function githubMode(value) {
734
+ return value === "on" || value === "strict" ? value : "off";
735
+ }
736
+ function githubRules(context) {
737
+ switch (context.github) {
738
+ case "on":
739
+ return [
740
+ `- \`gh\` works for ${context.repo ?? "this repo"} only: issues, pull requests, comments. Its token is short-lived and refreshed for you.`,
741
+ "- Merging pull requests and deleting remote branches are not possible from here. A person merges."
742
+ ];
743
+ case "strict":
744
+ return [
745
+ "- This session runs in strict mode: GitHub is disabled on purpose and the runner holds no GitHub credentials. `gh` does not work, and `git fetch` fails for private repositories.",
746
+ "- Do not look for credentials or ask the user for a token. Local git work still works.",
747
+ "- When a task needs GitHub (an issue, a pull request, a comment, a review, a label), write out what should be posted and hand it to the user."
748
+ ];
749
+ case "off":
750
+ return [
751
+ "- GitHub access is unavailable in this session, so `gh` has no token. Local git work still works."
752
+ ];
753
+ }
754
+ }
755
+ function afterPushRule(github) {
756
+ return github === "on" ? "- After the user confirms it is pushed, `git fetch` and continue. Open the pull request with `gh pr create --head <branch>` if asked to." : "- After the user confirms it is pushed, continue on the same branch. The user opens the pull request.";
757
+ }
733
758
  function runnerContextFromEnv(env) {
734
759
  return {
735
760
  sessionId: env.MAESTTRO_SESSION_ID,
736
761
  base: env.MAESTTRO_BASE,
737
762
  repo: env.MAESTTRO_REPO,
738
- githubAccess: env.MAESTTRO_GITHUB === "on"
763
+ github: githubMode(env.MAESTTRO_GITHUB)
739
764
  };
740
765
  }
741
766
  function runnerInstructions(context) {
742
767
  const base = context.base ?? "the default branch";
743
- const github = context.githubAccess ? [
744
- `- \`gh\` works for ${context.repo ?? "this repo"} only: issues, pull requests, comments. Its token is short-lived and refreshed for you.`,
745
- "- Merging pull requests and deleting remote branches are not possible from here. A person merges."
746
- ] : [
747
- "- GitHub access is unavailable in this session, so `gh` has no token. Local git work still works."
748
- ];
749
768
  return [
750
769
  "# Maesttro runner",
751
770
  "",
@@ -762,11 +781,11 @@ function runnerInstructions(context) {
762
781
  "",
763
782
  "- `git push` does not work here: the runner has read-only access to the remote. This is deliberate.",
764
783
  '- When a branch is ready, commit everything, then tell the user: "Ready to push: <branch>. Run `maesttro push <branch>` from the repository root."',
765
- "- After the user confirms it is pushed, `git fetch` and continue. Open the pull request with `gh pr create --head <branch>` if asked to.",
784
+ afterPushRule(context.github),
766
785
  "",
767
786
  "## GitHub",
768
787
  "",
769
- ...github,
788
+ ...githubRules(context),
770
789
  ""
771
790
  ].join(`
772
791
  `);
@@ -1187,7 +1206,10 @@ var HELP = `maesttro — isolated runner for local coding agents
1187
1206
  Usage:
1188
1207
  maesttro Start a session from the repo root: new clone, default client (opencode)
1189
1208
  maesttro <client> Same, with a specific client — see \`maesttro list\`
1190
- maesttro resume <id> Continue a kept session in its clone
1209
+ maesttro --strict [client]
1210
+ Same, in strict mode: no GitHub token reaches the runner, so \`gh\` is off
1211
+ maesttro resume <id> Continue a kept session in its clone, in the mode it started with
1212
+ (\`--strict\` or \`--no-strict\` after the id changes it)
1191
1213
  maesttro sessions List this repo's sessions (\`sessions remove <id>\` to drop one)
1192
1214
  maesttro push [branch] Push a branch a session created, after showing what goes out
1193
1215
  maesttro list Show available clients
@@ -1420,6 +1442,7 @@ function createSessionDir(parent = tmpdir2()) {
1420
1442
  // src/github-session.ts
1421
1443
  var UNAVAILABLE = "GitHub access unavailable";
1422
1444
  var WITHOUT = "running without gh";
1445
+ var STRICT_NOTICE = "Strict mode: no GitHub token in the runner, so `gh` is off. Local git works; push with `maesttro push`.";
1423
1446
  function refusalNotice(code, repo) {
1424
1447
  switch (code) {
1425
1448
  case "broker_unreachable":
@@ -1462,6 +1485,7 @@ function errorCode(error) {
1462
1485
  function withoutGitHub(author, notice) {
1463
1486
  const env = {
1464
1487
  GH_CONFIG_DIR: CONTAINER_GH_CONFIG_DIR,
1488
+ GIT_TERMINAL_PROMPT: "0",
1465
1489
  ...identityEnv(author, undefined)
1466
1490
  };
1467
1491
  return {
@@ -1474,12 +1498,15 @@ function withoutGitHub(author, notice) {
1474
1498
  async function openGitHubSession({
1475
1499
  repo,
1476
1500
  author,
1501
+ strict = false,
1477
1502
  broker,
1478
1503
  login,
1479
1504
  log,
1480
1505
  fetch: fetchImpl = fetch,
1481
1506
  createDir = createSessionDir
1482
1507
  }) {
1508
+ if (strict)
1509
+ return withoutGitHub(author, STRICT_NOTICE);
1483
1510
  if (!repo)
1484
1511
  return withoutGitHub(author);
1485
1512
  const mint = async ({ interactive }) => {
@@ -2025,6 +2052,18 @@ Session ${session.id} kept. Continue with \`maesttro resume ${session.id}\`.`);
2025
2052
  console.log("You have commits on a detached HEAD. Resume and `git switch -c <name>` to keep them.");
2026
2053
  }
2027
2054
  }
2055
+ function isStrict(requested, session) {
2056
+ return requested ?? session.strict ?? false;
2057
+ }
2058
+ function runnerGitHubMode({
2059
+ strict,
2060
+ repo,
2061
+ notice
2062
+ }) {
2063
+ if (strict)
2064
+ return "strict";
2065
+ return notice || !repo ? "off" : "on";
2066
+ }
2028
2067
  function exitCodeOf(code, signal) {
2029
2068
  if (code !== null)
2030
2069
  return code;
@@ -2033,7 +2072,8 @@ function exitCodeOf(code, signal) {
2033
2072
  var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
2034
2073
  async function launch({
2035
2074
  args,
2036
- resume
2075
+ resume,
2076
+ strict: requestedStrict
2037
2077
  }) {
2038
2078
  const run = runCommand;
2039
2079
  ensureReady();
@@ -2051,9 +2091,14 @@ async function launch({
2051
2091
  refs: snapshotRefs(run, root),
2052
2092
  sessionId: started.id
2053
2093
  });
2054
- const saved = { ...started, backup: started.backup ?? backup?.path };
2094
+ const strict = isStrict(requestedStrict, started);
2095
+ const saved = {
2096
+ ...started,
2097
+ backup: started.backup ?? backup?.path,
2098
+ strict
2099
+ };
2055
2100
  saveSession(saved);
2056
- console.log(`Session ${saved.id}: clone .worktrees/${saved.id} at ${saved.base}${backup ? `, backup ${backup.reused ? "reused" : "written"}` : ""}.`);
2101
+ console.log(`Session ${saved.id}${strict ? " (strict)" : ""}: clone .worktrees/${saved.id} at ${saved.base}${backup ? `, backup ${backup.reused ? "reused" : "written"}` : ""}.`);
2057
2102
  const repo = workspaceRepo(run, root);
2058
2103
  if (repo && !resume) {
2059
2104
  git(run, saved.worktree, [
@@ -2066,6 +2111,7 @@ async function launch({
2066
2111
  const github = await openGitHubSession({
2067
2112
  repo,
2068
2113
  author: await resolveAuthor(root),
2114
+ strict,
2069
2115
  broker: brokerUrl(),
2070
2116
  login: {
2071
2117
  clientId: githubAppClientId(),
@@ -2098,7 +2144,7 @@ async function launch({
2098
2144
  ...github.env,
2099
2145
  MAESTTRO_SESSION_ID: saved.id,
2100
2146
  MAESTTRO_BASE: saved.base,
2101
- MAESTTRO_GITHUB: github.notice || !repo ? "off" : "on",
2147
+ MAESTTRO_GITHUB: runnerGitHubMode({ strict, repo, notice: github.notice }),
2102
2148
  ...hostIdentityEnv(),
2103
2149
  ...repo ? { MAESTTRO_REPO: repo } : {}
2104
2150
  };
@@ -2287,7 +2333,8 @@ function sessionsCommand({ args }) {
2287
2333
  for (const session of sessions) {
2288
2334
  const branches = existsSync5(session.worktree) ? cloneBranches(session.worktree) : undefined;
2289
2335
  const where = branches === undefined ? "clone missing" : branches.length > 0 ? `branches: ${branches.join(", ")}` : "no branches yet";
2290
- console.log(` ${session.id} ${session.createdAt.slice(0, 16)} ${where}`);
2336
+ const mode = session.strict ? " strict" : "";
2337
+ console.log(` ${session.id} ${session.createdAt.slice(0, 16)} ${where}${mode}`);
2291
2338
  }
2292
2339
  console.log("\n`maesttro resume <id>` continues one; `maesttro sessions remove <id>` removes its clone.");
2293
2340
  }
@@ -2369,11 +2416,48 @@ var defaultCommands = {
2369
2416
  status,
2370
2417
  print: (text) => console.log(text)
2371
2418
  };
2419
+ var MODE_FLAGS = {
2420
+ "--strict": true,
2421
+ "--no-strict": false
2422
+ };
2423
+ function takeModeFlags(args) {
2424
+ let strict;
2425
+ let index = 0;
2426
+ while (index < args.length && Object.hasOwn(MODE_FLAGS, args[index] ?? "")) {
2427
+ strict = MODE_FLAGS[args[index] ?? ""];
2428
+ index++;
2429
+ }
2430
+ return {
2431
+ ...strict === undefined ? {} : { strict },
2432
+ rest: args.slice(index)
2433
+ };
2434
+ }
2435
+ var NOT_LAUNCHING = new Set([
2436
+ "setup",
2437
+ "update",
2438
+ "doctor",
2439
+ "login",
2440
+ "logout",
2441
+ "status",
2442
+ "push",
2443
+ "sessions",
2444
+ "configure",
2445
+ "list",
2446
+ "help",
2447
+ "--help",
2448
+ "-h"
2449
+ ]);
2372
2450
  async function main(argv, commands = defaultCommands) {
2373
- const [command, ...rest] = argv;
2451
+ const leading = takeModeFlags(argv);
2452
+ const [command, ...rest] = leading.rest;
2453
+ const mode = leading.strict === undefined ? {} : { strict: leading.strict };
2454
+ if (leading.strict !== undefined && command !== undefined && NOT_LAUNCHING.has(command)) {
2455
+ commands.print(`--strict and --no-strict apply to launching a session, not to \`${command}\`.`);
2456
+ return;
2457
+ }
2374
2458
  switch (command) {
2375
2459
  case undefined:
2376
- await commands.launch({ args: [] });
2460
+ await commands.launch({ args: [], ...mode });
2377
2461
  return;
2378
2462
  case "setup":
2379
2463
  await commands.setup({ args: rest });
@@ -2400,12 +2484,19 @@ async function main(argv, commands = defaultCommands) {
2400
2484
  await commands.sessions({ args: rest });
2401
2485
  return;
2402
2486
  case "resume": {
2403
- const [id, ...clientArgs] = rest;
2487
+ const beforeId = takeModeFlags(rest);
2488
+ const [id, ...afterId] = beforeId.rest;
2489
+ const afterIdFlags = takeModeFlags(afterId);
2490
+ const strict = afterIdFlags.strict ?? beforeId.strict ?? leading.strict;
2404
2491
  if (!id) {
2405
- commands.print("Usage: maesttro resume <session-id> [client] [args...]");
2492
+ commands.print("Usage: maesttro resume <session-id> [--strict|--no-strict] [client] [args...]");
2406
2493
  return;
2407
2494
  }
2408
- await commands.launch({ args: clientArgs, resume: id });
2495
+ await commands.launch({
2496
+ args: afterIdFlags.rest,
2497
+ resume: id,
2498
+ ...strict === undefined ? {} : { strict }
2499
+ });
2409
2500
  return;
2410
2501
  }
2411
2502
  case "configure":
@@ -2420,7 +2511,7 @@ async function main(argv, commands = defaultCommands) {
2420
2511
  commands.print(HELP);
2421
2512
  return;
2422
2513
  default:
2423
- await commands.launch({ args: argv });
2514
+ await commands.launch({ args: leading.rest, ...mode });
2424
2515
  }
2425
2516
  }
2426
2517
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maesttro",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Isolated Docker runner for local coding agents (opencode, claude, ...) under a dedicated bot GitHub identity, with curated configs baked in per client.",
5
5
  "type": "module",
6
6
  "bin": {