maesttro 0.2.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # maesttro
2
2
 
3
- Isolated Docker sandbox for local coding agents — `claude`, `opencode`, `codex`,
4
- and others. Inside the sandbox, `gh` and `git` act as the Maesttro GitHub App's
3
+ Isolated Docker runner for local coding agents — `opencode`, `claude`,
4
+ and others. Inside the runner, `gh` and `git` act as the Maesttro GitHub App's
5
5
  bot, using a token that is scoped to the current repo and lasts at most an hour.
6
6
  Your personal `~/.ssh` and `~/.config/gh` are never used. Install once, then
7
7
  `maesttro` just works from any project.
@@ -56,10 +56,9 @@ maesttro login / logout / status # manage the GitHub login
56
56
  maesttro list # see available clients
57
57
  maesttro doctor # health check anytime
58
58
 
59
- maesttro # from a repo root: new session clone + default client (claude)
59
+ maesttro # from a repo root: new session clone + default client (opencode)
60
60
  maesttro opencode # same, with opencode
61
- maesttro codex # same, with codex
62
- maesttro bash # plain shell inside the sandbox, for debugging
61
+ maesttro bash # plain shell inside the runner, for debugging
63
62
  maesttro sessions # list this repo's kept sessions
64
63
  maesttro resume <id> # continue one
65
64
  maesttro push [branch] # push a branch a session created (shows what goes out, asks first)
@@ -82,7 +81,7 @@ part of the repo the container sees:
82
81
  - The host never runs git inside the clone, so agent-authored git config, hooks
83
82
  or attributes can't make your host git run anything.
84
83
  - The agent commits on its own branch in the clone. It **cannot push**:
85
- the sandbox's GitHub token is read-only for code.
84
+ the runner's GitHub token is read-only for code.
86
85
  - When a branch is ready, the agent asks you to run `maesttro push <branch>`
87
86
  from the repo root. That command pushes only branches a session created,
88
87
  never the default branch or branches that existed before. It shows you the
@@ -110,9 +109,9 @@ comments). It acts as the Maesttro GitHub App's bot:
110
109
  ## Your instructions come along
111
110
 
112
111
  At every container start, `maesttro configure` copies your host
113
- `~/.claude/CLAUDE.md`, `~/.config/opencode/AGENTS.md`, `~/.codex/AGENTS.md` and
114
- `~/.claude/{agents,commands,skills}` into the sandbox, read-only at the
115
- source, and adds a short section explaining the sandbox rules. Credentials and
112
+ `~/.claude/CLAUDE.md`, `~/.config/opencode/AGENTS.md` and
113
+ `~/.claude/{agents,commands,skills}` into the runner, read-only at the
114
+ source, and adds a short section explaining the runner rules. Credentials and
116
115
  settings files are never copied.
117
116
 
118
117
  Upgrading from 0.1.x: the first launch removes the token you pasted before;
@@ -125,25 +124,40 @@ Defined in `src/clients.ts`, installed in `docker/Dockerfile`, configured via
125
124
 
126
125
  | Key | Client | Package |
127
126
  |---|---|---|
128
- | `claude` (default) | Claude Code | `@anthropic-ai/claude-code` |
129
- | `opencode` | opencode | `opencode-ai` |
130
- | `codex` | OpenAI Codex CLI | `@openai/codex` |
127
+ | `opencode` (default) | opencode | `opencode-ai` |
128
+ | `claude` | Claude Code | `@anthropic-ai/claude-code` |
131
129
 
132
130
  Adding a new client: add an entry to `src/clients.ts`, an install line in
133
131
  `docker/Dockerfile`, and a `docker/configs/<key>/` directory with any default
134
- config — then `maesttro update` rebuilds the image with it included.
132
+ config — then `maesttro update` rebuilds the runner image with it included.
133
+
134
+ ### Maesttro provider
135
+
136
+ Every launch builds opencode's config on your machine: your own
137
+ `~/.config/opencode/opencode.json(c)`, with secrets removed, and Maesttro's
138
+ config on top (`$MAESTTRO_URL`, default `http://100.64.0.2`). Only the result
139
+ is mounted into the runner, read-only, and copied into place at start. Your
140
+ raw config never enters the container, and your host's config is never written.
141
+
142
+ Injected: a `maesttro` provider (Orchestrate) at `/v1`, a `dgx-spark` provider
143
+ (lorem direct on the DGX), compaction, default model `maesttro/maesttro`, and
144
+ thinking blocks shown. The two providers replace yours of the same name; the
145
+ rest (`mcp`, `plugin`, other providers) is merged. Removed secrets are listed
146
+ at launch by path. `{env:X}` / `$X` references are kept.
147
+
148
+ Claude Code gets no Maesttro config and uses its own defaults and login.
135
149
 
136
150
  ---
137
151
 
138
152
  ## How `maesttro update` keeps configs current
139
153
 
140
154
  `docker/configs/<client>/` holds the curated client defaults. They aren't baked
141
- into the image: at every container start, `maesttro configure` runs from your
155
+ into the runner image: at every container start, `maesttro configure` runs from your
142
156
  installed package, which is mounted read-only, and merges them into each client's
143
157
  config, keeping your own keys. So `maesttro update` has two steps:
144
158
  1. `npm i -g maesttro@latest` refreshes the package, including `docker/configs/`,
145
159
  which the next session picks up.
146
- 2. It rebuilds the sandbox image, for new client and tool versions.
160
+ 2. It rebuilds the runner image, for new client and tool versions.
147
161
 
148
162
  Your session state (in the `maesttro-home` volume) and your project files are
149
163
  untouched by an update.
@@ -189,9 +203,7 @@ maesttro/
189
203
  ├── Dockerfile
190
204
  ├── gitconfig # → /etc/gitconfig: SSH→HTTPS, gh as credential helper
191
205
  └── configs/
192
- ├── claude/
193
- ├── opencode/
194
- └── codex/
206
+ └── opencode/
195
207
  ```
196
208
 
197
209
  ## Trying it locally
@@ -214,7 +226,6 @@ registry it was installed from.
214
226
 
215
227
  ## Open items
216
228
 
217
- - Fill in real curated defaults in `docker/configs/*` (currently placeholders)
218
229
  - Pin exact client CLI versions in the Dockerfile once stable
219
230
  - Decide whether each client gets its own volume, or shares one (current setup: shared)
220
231
  - Homebrew tap as an alternative install path for non-Node users — optional
package/dist/maesttro.js CHANGED
@@ -351,15 +351,342 @@ async function status() {
351
351
 
352
352
  // src/configure.ts
353
353
  import {
354
- cpSync,
355
- existsSync,
356
- mkdirSync as mkdirSync2,
354
+ cpSync as cpSync2,
355
+ existsSync as existsSync2,
356
+ mkdirSync as mkdirSync4,
357
357
  readdirSync,
358
- readFileSync as readFileSync2,
359
- writeFileSync as writeFileSync2
358
+ readFileSync as readFileSync4,
359
+ writeFileSync as writeFileSync4
360
360
  } from "node:fs";
361
361
  import { homedir as homedir2 } from "node:os";
362
- import { dirname as dirname3, join as join3 } from "node:path";
362
+ import { dirname as dirname5, join as join4 } from "node:path";
363
+
364
+ // src/client-configs.ts
365
+ import {
366
+ chmodSync as chmodSync2,
367
+ cpSync,
368
+ existsSync,
369
+ mkdirSync as mkdirSync3,
370
+ mkdtempSync,
371
+ readFileSync as readFileSync3,
372
+ rmSync as rmSync2,
373
+ writeFileSync as writeFileSync3
374
+ } from "node:fs";
375
+ import { tmpdir } from "node:os";
376
+ import { dirname as dirname3, join as join2 } from "node:path";
377
+
378
+ // src/json-file.ts
379
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
380
+ import { dirname as dirname2 } from "node:path";
381
+ function isObject(value) {
382
+ return typeof value === "object" && value !== null && !Array.isArray(value);
383
+ }
384
+ function mergeJson(existing, curated) {
385
+ const merged = { ...existing };
386
+ for (const [key, value] of Object.entries(curated)) {
387
+ const current = merged[key];
388
+ merged[key] = isObject(current) && isObject(value) ? mergeJson(current, value) : value;
389
+ }
390
+ return merged;
391
+ }
392
+ function readJson(path) {
393
+ try {
394
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
395
+ return isObject(parsed) ? parsed : {};
396
+ } catch {
397
+ return {};
398
+ }
399
+ }
400
+ function writeJson(path, value) {
401
+ mkdirSync2(dirname2(path), { recursive: true });
402
+ writeFileSync2(path, `${JSON.stringify(value, null, 2)}
403
+ `);
404
+ }
405
+ function skipInsignificant(text, from) {
406
+ let index = from;
407
+ while (index < text.length) {
408
+ if (/\s/.test(text[index] ?? "")) {
409
+ index++;
410
+ } else if (text.startsWith("//", index)) {
411
+ while (index < text.length && text[index] !== `
412
+ `)
413
+ index++;
414
+ } else if (text.startsWith("/*", index)) {
415
+ const end = text.indexOf("*/", index + 2);
416
+ index = end === -1 ? text.length : end + 2;
417
+ } else {
418
+ return index;
419
+ }
420
+ }
421
+ return index;
422
+ }
423
+ function stripJsonc(text) {
424
+ let output = "";
425
+ let index = 0;
426
+ while (index < text.length) {
427
+ const char = text[index];
428
+ if (char === '"') {
429
+ const start = index;
430
+ index++;
431
+ while (index < text.length && text[index] !== '"') {
432
+ index += text[index] === "\\" ? 2 : 1;
433
+ }
434
+ index++;
435
+ output += text.slice(start, index);
436
+ continue;
437
+ }
438
+ const significant = skipInsignificant(text, index);
439
+ if (significant > index) {
440
+ output += " ";
441
+ index = significant;
442
+ continue;
443
+ }
444
+ const trailingComma = char === "," && /[}\]]/.test(text[skipInsignificant(text, index + 1)] ?? "");
445
+ if (!trailingComma)
446
+ output += char;
447
+ index++;
448
+ }
449
+ return output;
450
+ }
451
+ function parseJsonc(text) {
452
+ const parsed = JSON.parse(stripJsonc(text));
453
+ if (!isObject(parsed))
454
+ throw new Error("expected a JSON object");
455
+ return parsed;
456
+ }
457
+
458
+ // src/maesttro-endpoint.ts
459
+ var DEFAULT_MAESTTRO_URL = "http://100.64.0.2";
460
+ var ORCHESTRATOR_MODEL = "maesttro";
461
+ function maesttroUrl(env = process.env) {
462
+ return (env.MAESTTRO_URL?.trim() || DEFAULT_MAESTTRO_URL).replace(/\/+$/, "");
463
+ }
464
+
465
+ // src/provider-configs.ts
466
+ var PROVIDER_ID = "maesttro";
467
+ var MAESTTRO_API_KEY = "maesttro";
468
+ var DGX_SPARK_PROVIDER_ID = "dgx-spark";
469
+ var DGX_SPARK_URL = "http://100.64.0.1:8001";
470
+ var INJECTED_OPENCODE_PROVIDERS = [PROVIDER_ID, DGX_SPARK_PROVIDER_ID];
471
+ function without(object, keys) {
472
+ if (!isObject(object))
473
+ return {};
474
+ return Object.fromEntries(Object.entries(object).filter(([key]) => !keys.includes(key)));
475
+ }
476
+ function stringList(value) {
477
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
478
+ }
479
+ function opencodeHostBase(host) {
480
+ return isObject(host.provider) ? { ...host, provider: without(host.provider, INJECTED_OPENCODE_PROVIDERS) } : host;
481
+ }
482
+ var IMAGE_INPUT = {
483
+ attachment: true,
484
+ modalities: { input: ["text", "image"] }
485
+ };
486
+ function opencodeProviders(url) {
487
+ return {
488
+ [PROVIDER_ID]: {
489
+ npm: "@ai-sdk/openai-compatible",
490
+ name: "Maesttro",
491
+ options: { baseURL: `${url}/v1`, apiKey: MAESTTRO_API_KEY },
492
+ models: {
493
+ [ORCHESTRATOR_MODEL]: { name: "Orchestrate", ...IMAGE_INPUT }
494
+ }
495
+ },
496
+ [DGX_SPARK_PROVIDER_ID]: {
497
+ npm: "@ai-sdk/openai-compatible",
498
+ name: "DGX-Spark",
499
+ options: { baseURL: `${DGX_SPARK_URL}/v1`, apiKey: "" },
500
+ models: {
501
+ "maesttro/lorem": {
502
+ name: "Qwen3.8 27B",
503
+ ...IMAGE_INPUT,
504
+ limit: { context: 131072, output: 32768 }
505
+ }
506
+ }
507
+ }
508
+ };
509
+ }
510
+ function opencodeConfig(base, url) {
511
+ const merged = mergeJson({ $schema: "https://opencode.ai/config.json", ...base }, {
512
+ model: `${PROVIDER_ID}/${ORCHESTRATOR_MODEL}`,
513
+ compaction: { auto: true, keep: { tokens: 24000 }, buffer: 32000 }
514
+ });
515
+ const enabled = stringList(base.enabled_providers);
516
+ const disabled = stringList(base.disabled_providers);
517
+ return {
518
+ ...merged,
519
+ ...enabled ? {
520
+ enabled_providers: [
521
+ ...enabled.filter((id) => !INJECTED_OPENCODE_PROVIDERS.includes(id)),
522
+ ...INJECTED_OPENCODE_PROVIDERS
523
+ ]
524
+ } : {},
525
+ ...disabled ? {
526
+ disabled_providers: disabled.filter((id) => !INJECTED_OPENCODE_PROVIDERS.includes(id))
527
+ } : {},
528
+ provider: {
529
+ ...isObject(base.provider) ? base.provider : {},
530
+ ...opencodeProviders(url)
531
+ }
532
+ };
533
+ }
534
+
535
+ // src/secrets.ts
536
+ var SECRET_KEY = /api[_-]?key|token|secret|passw(or)?d|credential|authorization|bearer|private[_-]?key|cookie/i;
537
+ var ENV_NAME_KEY = /(^|_)env(_var|_key|_vars)?$/i;
538
+ var SECRET_MAPS = new Set(["env", "environment", "headers", "http_headers"]);
539
+ var CREDENTIAL_SHAPES = [
540
+ /(^|[^A-Za-z0-9])sk[-_][A-Za-z0-9_-]{16,}/,
541
+ /(^|[^A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}/,
542
+ /github_pat_[A-Za-z0-9_]{20,}/,
543
+ /glpat-[A-Za-z0-9_-]{16,}/,
544
+ /xox[abposr]-[A-Za-z0-9-]{10,}/,
545
+ /(^|[^A-Za-z0-9])(AKIA|ASIA)[0-9A-Z]{16}/,
546
+ /AIza[0-9A-Za-z_-]{30,}/,
547
+ /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i,
548
+ /:\/\/[^/\s:@]+:[^/\s@]+@/,
549
+ /[?&](api[_-]?key|key|token|access_token|secret)=/i,
550
+ /hooks\.slack\.com\/services\//
551
+ ];
552
+ var SECRET_FLAG = /^--?[\w-]*(token|api[_-]?key|secret|passw(or)?d)[\w-]*$/i;
553
+ var SECRET_FLAG_WITH_VALUE = /^--?[\w-]*(token|api[_-]?key|secret|passw(or)?d)[\w-]*=/i;
554
+ var REFERENCE = /^\{(env|file):[^}]+\}$|^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/;
555
+ var HARMLESS_MAP_VALUE = /^(\d+(\.\d+)?|true|false|yes|no|on|off)$/i;
556
+ function isSecretString({
557
+ key,
558
+ value,
559
+ inSecretMap
560
+ }) {
561
+ if (value.trim() === "" || REFERENCE.test(value.trim()))
562
+ return false;
563
+ if (CREDENTIAL_SHAPES.some((shape) => shape.test(value)))
564
+ return true;
565
+ if (inSecretMap)
566
+ return !HARMLESS_MAP_VALUE.test(value.trim());
567
+ return SECRET_KEY.test(key) && !ENV_NAME_KEY.test(key);
568
+ }
569
+ function redactValue(value, key, path, inSecretMap, removed) {
570
+ if (typeof value === "string") {
571
+ if (isSecretString({ key, value, inSecretMap })) {
572
+ removed.push(path);
573
+ return { keep: false, value };
574
+ }
575
+ return { keep: true, value };
576
+ }
577
+ if (Array.isArray(value)) {
578
+ const items = value.flatMap((item, index) => {
579
+ const previous = value[index - 1];
580
+ const flagged = typeof item === "string" && (typeof previous === "string" && SECRET_FLAG.test(previous) || SECRET_FLAG_WITH_VALUE.test(item));
581
+ if (flagged) {
582
+ removed.push(`${path}[${index}]`);
583
+ return [];
584
+ }
585
+ const result = redactValue(item, key, `${path}[${index}]`, inSecretMap, removed);
586
+ return result.keep ? [result.value] : [];
587
+ });
588
+ return { keep: true, value: items };
589
+ }
590
+ if (isObject(value)) {
591
+ return {
592
+ keep: true,
593
+ value: redactObject(value, path, inSecretMap || SECRET_MAPS.has(key), removed)
594
+ };
595
+ }
596
+ return { keep: true, value };
597
+ }
598
+ function redactObject(object, path, inSecretMap, removed) {
599
+ const kept = {};
600
+ for (const [key, value] of Object.entries(object)) {
601
+ const result = redactValue(value, key, path ? `${path}.${key}` : key, inSecretMap, removed);
602
+ const emptied = isObject(value) && Object.keys(value).length > 0 && isObject(result.value) && Object.keys(result.value).length === 0;
603
+ if (result.keep && !emptied)
604
+ kept[key] = result.value;
605
+ }
606
+ return kept;
607
+ }
608
+ function redactSecrets(config) {
609
+ const removed = [];
610
+ return { value: redactObject(config, "", false, removed), removed };
611
+ }
612
+
613
+ // src/client-configs.ts
614
+ var CONTAINER_CLIENT_CONFIGS = "/opt/maesttro-clients";
615
+ var OPENCODE_HOST_CONFIGS = [
616
+ ".config/opencode/opencode.json",
617
+ ".config/opencode/opencode.jsonc"
618
+ ];
619
+ var OPENCODE_CONFIG = ".config/opencode/opencode.json";
620
+ var OPENCODE_TUI_STATE = ".local/state/opencode/kv.json";
621
+ var CURATED_OPENCODE_TUI_STATE = {
622
+ thinking_mode: "show",
623
+ thinking_visibility: true
624
+ };
625
+ function readHostFile({
626
+ home,
627
+ file,
628
+ parse,
629
+ warn
630
+ }) {
631
+ const path = join2(home, file);
632
+ if (!existsSync(path))
633
+ return {};
634
+ try {
635
+ return parse(readFileSync3(path, "utf8"));
636
+ } catch {
637
+ warn(`maesttro: ignoring your ~/${file}, it doesn't parse`);
638
+ return {};
639
+ }
640
+ }
641
+ function readHostClientConfigs({
642
+ home,
643
+ warn = (message) => console.error(message)
644
+ }) {
645
+ return {
646
+ opencode: OPENCODE_HOST_CONFIGS.reduce((config, file) => mergeJson(config, readHostFile({ home, file, parse: parseJsonc, warn })), {})
647
+ };
648
+ }
649
+ function json(value) {
650
+ return `${JSON.stringify(value, null, 2)}
651
+ `;
652
+ }
653
+ function buildClientConfigs({
654
+ host,
655
+ url
656
+ }) {
657
+ const opencode = redactSecrets(opencodeHostBase(host.opencode));
658
+ return {
659
+ files: { [OPENCODE_CONFIG]: json(opencodeConfig(opencode.value, url)) },
660
+ removed: opencode.removed.map((path) => `opencode ${path}`)
661
+ };
662
+ }
663
+ function writeClientConfigDir(files, parent = tmpdir()) {
664
+ const root = mkdtempSync(join2(parent, "maesttro-clients-"));
665
+ chmodSync2(root, 448);
666
+ const dir = join2(root, "home");
667
+ for (const [file, content] of Object.entries(files)) {
668
+ const path = join2(dir, file);
669
+ mkdirSync3(dirname3(path), { recursive: true, mode: 493 });
670
+ writeFileSync3(path, content);
671
+ chmodSync2(path, 420);
672
+ }
673
+ chmodSync2(dir, 493);
674
+ return { dir, remove: () => rmSync2(root, { recursive: true, force: true }) };
675
+ }
676
+ function installClientConfigs({
677
+ home,
678
+ source = CONTAINER_CLIENT_CONFIGS
679
+ }) {
680
+ if (!existsSync(source))
681
+ return false;
682
+ cpSync(source, home, { recursive: true, force: true });
683
+ return true;
684
+ }
685
+ function injectOpencodeTuiState({ home }) {
686
+ const path = join2(home, OPENCODE_TUI_STATE);
687
+ writeJson(path, mergeJson(readJson(path), CURATED_OPENCODE_TUI_STATE));
688
+ return path;
689
+ }
363
690
 
364
691
  // src/clients.ts
365
692
  var CLIENTS = {
@@ -374,15 +701,9 @@ var CLIENTS = {
374
701
  npmPackage: "opencode-ai",
375
702
  binary: "opencode",
376
703
  configDir: "/home/agent/.config/opencode"
377
- },
378
- codex: {
379
- label: "OpenAI Codex CLI",
380
- npmPackage: "@openai/codex",
381
- binary: "codex",
382
- configDir: "/home/agent/.codex"
383
704
  }
384
705
  };
385
- var DEFAULT_CLIENT = "claude";
706
+ var DEFAULT_CLIENT = "opencode";
386
707
  function isClientKey(value) {
387
708
  return Object.hasOwn(CLIENTS, value);
388
709
  }
@@ -396,21 +717,20 @@ var STAGED_DIRECTORIES = [
396
717
  ];
397
718
  var INSTRUCTION_FILES = [
398
719
  ".claude/CLAUDE.md",
399
- ".config/opencode/AGENTS.md",
400
- ".codex/AGENTS.md"
720
+ ".config/opencode/AGENTS.md"
401
721
  ];
402
722
  var STAGED_PATHS = [...INSTRUCTION_FILES, ...STAGED_DIRECTORIES];
403
723
 
404
724
  // src/paths.ts
405
- import { dirname as dirname2, join as join2, resolve } from "node:path";
725
+ import { dirname as dirname4, join as join3, resolve } from "node:path";
406
726
  import { fileURLToPath } from "node:url";
407
- var PACKAGE_ROOT = resolve(dirname2(fileURLToPath(import.meta.url)), "..");
408
- var DOCKERFILE_DIR = join2(PACKAGE_ROOT, "docker");
727
+ var PACKAGE_ROOT = resolve(dirname4(fileURLToPath(import.meta.url)), "..");
728
+ var DOCKERFILE_DIR = join3(PACKAGE_ROOT, "docker");
409
729
  var IMAGE = "maesttro-runner:latest";
410
730
  var VOLUME = process.env.MAESTTRO_VOLUME?.trim() || "maesttro-home";
411
731
 
412
- // src/sandbox-context.ts
413
- function sandboxContextFromEnv(env) {
732
+ // src/runner-context.ts
733
+ function runnerContextFromEnv(env) {
414
734
  return {
415
735
  sessionId: env.MAESTTRO_SESSION_ID,
416
736
  base: env.MAESTTRO_BASE,
@@ -418,7 +738,7 @@ function sandboxContextFromEnv(env) {
418
738
  githubAccess: env.MAESTTRO_GITHUB === "on"
419
739
  };
420
740
  }
421
- function sandboxInstructions(context) {
741
+ function runnerInstructions(context) {
422
742
  const base = context.base ?? "the default branch";
423
743
  const github = context.githubAccess ? [
424
744
  `- \`gh\` works for ${context.repo ?? "this repo"} only: issues, pull requests, comments. Its token is short-lived and refreshed for you.`,
@@ -427,20 +747,20 @@ function sandboxInstructions(context) {
427
747
  "- GitHub access is unavailable in this session, so `gh` has no token. Local git work still works."
428
748
  ];
429
749
  return [
430
- "# Maesttro sandbox",
750
+ "# Maesttro runner",
431
751
  "",
432
- "You are running inside Maesttro's isolated sandbox container. These rules describe what the environment allows, so plan around them.",
752
+ "You are running inside Maesttro's isolated runner container. These rules describe what the environment allows, so plan around them.",
433
753
  "",
434
754
  "## Your workspace",
435
755
  "",
436
756
  `- The current directory is an isolated clone of the repository${context.sessionId ? ` for this session (\`${context.sessionId}\`)` : ""}, detached at \`${base}\`. It is the only checkout you can see or change; your main checkout is not here.`,
437
757
  "- Before your first commit, create a branch for the task: `git switch -c <short-descriptive-name>`. Keep one branch per task.",
438
758
  "- Work in this directory. Anything outside it is not persisted.",
439
- "- Commit on a branch you create here. The user pushes it from their machine with `maesttro push`; the sandbox cannot push, merge, or delete remote branches.",
759
+ "- Commit on a branch you create here. The user pushes it from their machine with `maesttro push`; the runner cannot push, merge, or delete remote branches.",
440
760
  "",
441
761
  "## Pushing",
442
762
  "",
443
- "- `git push` does not work here: the sandbox has read-only access to the remote. This is deliberate.",
763
+ "- `git push` does not work here: the runner has read-only access to the remote. This is deliberate.",
444
764
  '- When a branch is ready, commit everything, then tell the user: "Ready to push: <branch>. Run `maesttro push <branch>` from the repository root."',
445
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.",
446
766
  "",
@@ -453,52 +773,33 @@ function sandboxInstructions(context) {
453
773
  }
454
774
 
455
775
  // src/configure.ts
456
- var SANDBOX_MARKER = "/etc/maesttro/sandbox";
457
- function isObject(value) {
458
- return typeof value === "object" && value !== null && !Array.isArray(value);
459
- }
776
+ var RUNNER_MARKER = "/etc/maesttro/runner";
460
777
  function withoutComments(value) {
461
778
  return Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith("_comment")).map(([key, inner]) => [
462
779
  key,
463
780
  isObject(inner) ? withoutComments(inner) : inner
464
781
  ]));
465
782
  }
466
- function mergeJson(existing, curated) {
467
- const merged = { ...existing };
468
- for (const [key, value] of Object.entries(curated)) {
469
- const current = merged[key];
470
- merged[key] = isObject(current) && isObject(value) ? mergeJson(current, value) : value;
471
- }
472
- return merged;
473
- }
474
- function readJson(path) {
475
- try {
476
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
477
- return isObject(parsed) ? parsed : {};
478
- } catch {
479
- return {};
480
- }
481
- }
482
783
  function applyProviderConfigs({
483
784
  home,
484
- configsDir = join3(DOCKERFILE_DIR, "configs")
785
+ configsDir = join4(DOCKERFILE_DIR, "configs")
485
786
  }) {
486
787
  const written = [];
487
788
  for (const [key, client] of Object.entries(CLIENTS)) {
488
- const source = join3(configsDir, key);
489
- if (!existsSync(source))
789
+ const source = join4(configsDir, key);
790
+ if (!existsSync2(source))
490
791
  continue;
491
- const targetDir = join3(home, client.configDir.replace(/^\/home\/agent\/?/, ""));
792
+ const targetDir = join4(home, client.configDir.replace(/^\/home\/agent\/?/, ""));
492
793
  for (const name of readdirSync(source)) {
493
794
  if (!name.endsWith(".json"))
494
795
  continue;
495
- const curated = withoutComments(readJson(join3(source, name)));
796
+ const curated = withoutComments(readJson(join4(source, name)));
496
797
  if (Object.keys(curated).length === 0)
497
798
  continue;
498
- const target = join3(targetDir, name);
499
- mkdirSync2(targetDir, { recursive: true });
799
+ const target = join4(targetDir, name);
800
+ mkdirSync4(targetDir, { recursive: true });
500
801
  const merged = mergeJson(readJson(target), curated);
501
- writeFileSync2(target, `${JSON.stringify(merged, null, 2)}
802
+ writeFileSync4(target, `${JSON.stringify(merged, null, 2)}
502
803
  `);
503
804
  written.push(target);
504
805
  }
@@ -511,33 +812,41 @@ function stageHostInstructions({
511
812
  context
512
813
  }) {
513
814
  for (const directory of STAGED_DIRECTORIES) {
514
- const source = join3(staging, directory);
515
- if (!existsSync(source))
815
+ const source = join4(staging, directory);
816
+ if (!existsSync2(source))
516
817
  continue;
517
818
  try {
518
- cpSync(source, join3(home, directory), { recursive: true, force: true });
819
+ cpSync2(source, join4(home, directory), { recursive: true, force: true });
519
820
  } catch {}
520
821
  }
521
- const instructions = sandboxInstructions(context);
822
+ const instructions = runnerInstructions(context);
522
823
  for (const file of INSTRUCTION_FILES) {
523
- const source = join3(staging, file);
524
- const hostCopy = existsSync(source) ? readFileSync2(source, "utf8") : "";
525
- const target = join3(home, file);
526
- mkdirSync2(dirname3(target), { recursive: true });
527
- writeFileSync2(target, hostCopy ? `${hostCopy.trimEnd()}
824
+ const source = join4(staging, file);
825
+ const hostCopy = existsSync2(source) ? readFileSync4(source, "utf8") : "";
826
+ const target = join4(home, file);
827
+ mkdirSync4(dirname5(target), { recursive: true });
828
+ writeFileSync4(target, hostCopy ? `${hostCopy.trimEnd()}
528
829
 
529
830
  ${instructions}` : instructions);
530
831
  }
531
832
  }
532
833
  function configure({
533
834
  home = homedir2(),
534
- inSandbox = existsSync(SANDBOX_MARKER),
535
- env = process.env
835
+ inRunner = existsSync2(RUNNER_MARKER),
836
+ env = process.env,
837
+ staging = HOST_STAGING_DIR,
838
+ clientConfigs
536
839
  } = {}) {
537
840
  applyProviderConfigs({ home });
538
- if (inSandbox) {
539
- stageHostInstructions({ home, context: sandboxContextFromEnv(env) });
540
- }
841
+ if (!inRunner)
842
+ return;
843
+ installClientConfigs({ home, source: clientConfigs });
844
+ injectOpencodeTuiState({ home });
845
+ stageHostInstructions({
846
+ home,
847
+ staging,
848
+ context: runnerContextFromEnv(env)
849
+ });
541
850
  }
542
851
 
543
852
  // src/doctor.ts
@@ -646,7 +955,7 @@ function purgeLegacyToken() {
646
955
  function hasLegacyToken() {
647
956
  return inVolume(DETECT_LEGACY_TOKEN).includes("FOUND");
648
957
  }
649
- var REVOKE_NOTICE = "Removed a long-lived GitHub token that an older maesttro stored in the sandbox volume. Revoke it on GitHub too: https://github.com/settings/tokens?type=beta";
958
+ var REVOKE_NOTICE = "Removed a long-lived GitHub token that an older maesttro stored in the runner volume. Revoke it on GitHub too: https://github.com/settings/tokens?type=beta";
650
959
  function purgeLegacyTokenOnce({
651
960
  config,
652
961
  saveConfig,
@@ -661,8 +970,8 @@ function purgeLegacyTokenOnce({
661
970
  }
662
971
 
663
972
  // src/repo.ts
664
- import { appendFileSync, mkdirSync as mkdirSync3, realpathSync } from "node:fs";
665
- import { join as join4 } from "node:path";
973
+ import { appendFileSync, mkdirSync as mkdirSync5, realpathSync } from "node:fs";
974
+ import { join as join5 } from "node:path";
666
975
  var MINIMUM_GIT = [2, 48];
667
976
  var WORKTREES_DIR = ".worktrees";
668
977
 
@@ -705,7 +1014,7 @@ function repoRoot(run, cwd) {
705
1014
  "--git-common-dir"
706
1015
  ]).out;
707
1016
  const root = realpathSync(top.out);
708
- if (realpathSync(commonDir) !== join4(root, ".git")) {
1017
+ if (realpathSync(commonDir) !== join5(root, ".git")) {
709
1018
  throw new LaunchError("This is a git worktree. Run maesttro from the main checkout; each session gets its own isolated clone.");
710
1019
  }
711
1020
  if (realpathSync(cwd) !== root) {
@@ -732,12 +1041,12 @@ function defaultBranch(run, root) {
732
1041
  throw new LaunchError("Can't find the default branch of origin. Run `git remote set-head origin --auto`.");
733
1042
  }
734
1043
  function ensureWorktreesDir(run, root) {
735
- mkdirSync3(join4(root, WORKTREES_DIR), { recursive: true });
1044
+ mkdirSync5(join5(root, WORKTREES_DIR), { recursive: true });
736
1045
  if (git(run, root, ["check-ignore", "-q", `${WORKTREES_DIR}/`]).ok)
737
1046
  return;
738
- const info = join4(root, ".git", "info");
739
- mkdirSync3(info, { recursive: true });
740
- appendFileSync(join4(info, "exclude"), `
1047
+ const info = join5(root, ".git", "info");
1048
+ mkdirSync5(info, { recursive: true });
1049
+ appendFileSync(join5(info, "exclude"), `
741
1050
  ${WORKTREES_DIR}/
742
1051
  `);
743
1052
  }
@@ -779,7 +1088,7 @@ async function check(label, probe) {
779
1088
  return false;
780
1089
  }
781
1090
  }
782
- function inSandbox(command, { withVolume = true } = {}) {
1091
+ function inRunner(command, { withVolume = true } = {}) {
783
1092
  const volume = withVolume ? `-v ${VOLUME}:/home/agent ` : "";
784
1093
  return execSync2(`docker run --rm ${hostIdentityFlags()} ${volume}${IMAGE} "${command}"`).toString();
785
1094
  }
@@ -802,7 +1111,7 @@ async function doctor() {
802
1111
  }
803
1112
  ],
804
1113
  [
805
- "Sandbox image built",
1114
+ "Runner image built",
806
1115
  () => `built ${execSync2(`docker image inspect ${IMAGE} --format "{{.Created}}"`).toString().trim()}`
807
1116
  ],
808
1117
  [
@@ -845,9 +1154,9 @@ async function doctor() {
845
1154
  }
846
1155
  ],
847
1156
  [
848
- "Personal SSH key NOT visible inside sandbox (isolation intact)",
1157
+ "Personal SSH key NOT visible inside runner (isolation intact)",
849
1158
  () => {
850
- const out = inSandbox("cat ~/.ssh/id_rsa 2>&1 || echo MISSING");
1159
+ const out = inRunner("cat ~/.ssh/id_rsa 2>&1 || echo MISSING");
851
1160
  if (!out.includes("MISSING") && !out.includes("No such file")) {
852
1161
  throw new Error("isolation broken");
853
1162
  }
@@ -856,7 +1165,7 @@ async function doctor() {
856
1165
  ...Object.values(CLIENTS).map((client) => [
857
1166
  `Client available: ${client.label}`,
858
1167
  () => {
859
- inSandbox(`which ${client.binary}`, { withVolume: false });
1168
+ inRunner(`which ${client.binary}`, { withVolume: false });
860
1169
  }
861
1170
  ])
862
1171
  ];
@@ -873,10 +1182,10 @@ async function doctor() {
873
1182
  }
874
1183
 
875
1184
  // src/help.ts
876
- var HELP = `maesttro — isolated sandbox for local coding agents
1185
+ var HELP = `maesttro — isolated runner for local coding agents
877
1186
 
878
1187
  Usage:
879
- maesttro Start a session from the repo root: new clone, default client (claude)
1188
+ maesttro Start a session from the repo root: new clone, default client (opencode)
880
1189
  maesttro <client> Same, with a specific client — see \`maesttro list\`
881
1190
  maesttro resume <id> Continue a kept session in its clone
882
1191
  maesttro sessions List this repo's sessions (\`sessions remove <id>\` to drop one)
@@ -886,57 +1195,57 @@ Usage:
886
1195
  maesttro login Log in to GitHub (device flow) so sessions get repo access
887
1196
  maesttro logout Remove the stored GitHub credentials
888
1197
  maesttro status Show GitHub login status
889
- maesttro configure Apply maesttro's client configs (also runs inside the sandbox)
890
- maesttro update Update the package and rebuild the image with refreshed client configs
1198
+ maesttro configure Apply maesttro's client configs (also runs inside the runner)
1199
+ maesttro update Update the package and rebuild the runner image with refreshed client configs
891
1200
  maesttro doctor Health check — Docker, image, volume, GitHub login, broker, isolation, clients
892
1201
  maesttro help Show this message
893
1202
  `;
894
1203
 
895
1204
  // src/launch.ts
896
1205
  import { execSync as execSync3, spawn } from "node:child_process";
897
- import { existsSync as existsSync3, rmSync as rmSync5 } from "node:fs";
1206
+ import { existsSync as existsSync4, rmSync as rmSync6 } from "node:fs";
898
1207
  import { constants, homedir as homedir4 } from "node:os";
899
- import { join as join10 } from "node:path";
1208
+ import { join as join11 } from "node:path";
900
1209
 
901
1210
  // src/backup.ts
902
1211
  import { createHash } from "node:crypto";
903
- import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, rmSync as rmSync2 } from "node:fs";
904
- import { basename, join as join6 } from "node:path";
1212
+ import { mkdirSync as mkdirSync7, readdirSync as readdirSync2, rmSync as rmSync3 } from "node:fs";
1213
+ import { basename, join as join7 } from "node:path";
905
1214
 
906
1215
  // src/maesttro-home.ts
907
1216
  import {
908
1217
  appendFileSync as appendFileSync2,
909
- mkdirSync as mkdirSync4,
910
- readFileSync as readFileSync3,
1218
+ mkdirSync as mkdirSync6,
1219
+ readFileSync as readFileSync5,
911
1220
  renameSync as renameSync2,
912
- writeFileSync as writeFileSync3
1221
+ writeFileSync as writeFileSync5
913
1222
  } from "node:fs";
914
1223
  import { homedir as homedir3 } from "node:os";
915
- import { join as join5 } from "node:path";
1224
+ import { join as join6 } from "node:path";
916
1225
  function maesttroHome() {
917
- return join5(homedir3(), ".config", "maesttro");
1226
+ return join6(homedir3(), ".config", "maesttro");
918
1227
  }
919
1228
  function configPath() {
920
- return join5(maesttroHome(), "config.json");
1229
+ return join6(maesttroHome(), "config.json");
921
1230
  }
922
1231
  function readMaesttroConfig() {
923
1232
  try {
924
- return JSON.parse(readFileSync3(configPath(), "utf8"));
1233
+ return JSON.parse(readFileSync5(configPath(), "utf8"));
925
1234
  } catch {
926
1235
  return {};
927
1236
  }
928
1237
  }
929
1238
  function writeMaesttroConfig(config) {
930
- mkdirSync4(maesttroHome(), { recursive: true, mode: 448 });
1239
+ mkdirSync6(maesttroHome(), { recursive: true, mode: 448 });
931
1240
  const temporary = `${configPath()}.tmp`;
932
- writeFileSync3(temporary, `${JSON.stringify(config, null, 2)}
1241
+ writeFileSync5(temporary, `${JSON.stringify(config, null, 2)}
933
1242
  `);
934
1243
  renameSync2(temporary, configPath());
935
1244
  }
936
- function fileSessionLog(path = join5(maesttroHome(), "maesttro.log")) {
1245
+ function fileSessionLog(path = join6(maesttroHome(), "maesttro.log")) {
937
1246
  return (event, detail) => {
938
1247
  try {
939
- mkdirSync4(maesttroHome(), { recursive: true, mode: 448 });
1248
+ mkdirSync6(maesttroHome(), { recursive: true, mode: 448 });
940
1249
  appendFileSync2(path, `${new Date().toISOString()} ${event}${detail ? ` ${detail}` : ""}
941
1250
  `);
942
1251
  } catch {}
@@ -949,7 +1258,7 @@ function shortHash(value) {
949
1258
  return createHash("sha256").update(value).digest("hex").slice(0, 8);
950
1259
  }
951
1260
  function backupDir(repoRoot, home = maesttroHome()) {
952
- return join6(home, "backups", `${basename(repoRoot)}-${shortHash(repoRoot)}`);
1261
+ return join7(home, "backups", `${basename(repoRoot)}-${shortHash(repoRoot)}`);
953
1262
  }
954
1263
  function refsFingerprint(refs) {
955
1264
  const sorted = Object.keys(refs).sort().map((ref) => `${ref} ${refs[ref]}`);
@@ -967,20 +1276,20 @@ function backupRepo({
967
1276
  }) {
968
1277
  if (Object.keys(refs).length === 0)
969
1278
  return;
970
- mkdirSync5(dir, { recursive: true, mode: 448 });
1279
+ mkdirSync7(dir, { recursive: true, mode: 448 });
971
1280
  const fingerprint = refsFingerprint(refs);
972
1281
  const existing = readdirSync2(dir).filter((name) => name.endsWith(".bundle")).sort();
973
1282
  const latest = existing.at(-1);
974
1283
  if (latest?.endsWith(`-${fingerprint}.bundle`)) {
975
- return { path: join6(dir, latest), reused: true };
1284
+ return { path: join7(dir, latest), reused: true };
976
1285
  }
977
1286
  const stamp = now.toISOString().replace(/[:.]/g, "-");
978
- const path = join6(dir, `${stamp}-${sessionId}-${fingerprint}.bundle`);
1287
+ const path = join7(dir, `${stamp}-${sessionId}-${fingerprint}.bundle`);
979
1288
  if (!git(run, repoRoot, ["bundle", "create", "--quiet", path, "--all"]).ok) {
980
1289
  return;
981
1290
  }
982
1291
  for (const stale of [...existing, basename(path)].sort().slice(0, -keep)) {
983
- rmSync2(join6(dir, stale), { force: true });
1292
+ rmSync3(join7(dir, stale), { force: true });
984
1293
  }
985
1294
  return { path, reused: false };
986
1295
  }
@@ -1065,20 +1374,20 @@ function startRefresher({
1065
1374
 
1066
1375
  // src/session-dir.ts
1067
1376
  import {
1068
- chmodSync as chmodSync2,
1069
- mkdirSync as mkdirSync6,
1070
- mkdtempSync,
1377
+ chmodSync as chmodSync3,
1378
+ mkdirSync as mkdirSync8,
1379
+ mkdtempSync as mkdtempSync2,
1071
1380
  renameSync as renameSync3,
1072
- rmSync as rmSync3,
1073
- writeFileSync as writeFileSync4
1381
+ rmSync as rmSync4,
1382
+ writeFileSync as writeFileSync6
1074
1383
  } from "node:fs";
1075
- import { tmpdir } from "node:os";
1076
- import { join as join7 } from "node:path";
1384
+ import { tmpdir as tmpdir2 } from "node:os";
1385
+ import { join as join8 } from "node:path";
1077
1386
  var CONTAINER_GH_CONFIG_DIR = "/home/agent/.config/gh-maesttro";
1078
1387
  function writeAtomically(path, content) {
1079
1388
  const temporary = `${path}.tmp`;
1080
- writeFileSync4(temporary, content, { mode: 420 });
1081
- chmodSync2(temporary, 420);
1389
+ writeFileSync6(temporary, content, { mode: 420 });
1390
+ chmodSync3(temporary, 420);
1082
1391
  renameSync3(temporary, path);
1083
1392
  }
1084
1393
  function hostsYaml(token, user) {
@@ -1091,20 +1400,20 @@ function hostsYaml(token, user) {
1091
1400
  ].join(`
1092
1401
  `);
1093
1402
  }
1094
- function createSessionDir(parent = tmpdir()) {
1095
- const root = mkdtempSync(join7(parent, "maesttro-"));
1096
- chmodSync2(root, 448);
1097
- const ghDir = join7(root, "gh");
1098
- mkdirSync6(ghDir);
1099
- chmodSync2(ghDir, 493);
1100
- writeAtomically(join7(ghDir, "config.yml"), `version: "1"
1403
+ function createSessionDir(parent = tmpdir2()) {
1404
+ const root = mkdtempSync2(join8(parent, "maesttro-"));
1405
+ chmodSync3(root, 448);
1406
+ const ghDir = join8(root, "gh");
1407
+ mkdirSync8(ghDir);
1408
+ chmodSync3(ghDir, 493);
1409
+ writeAtomically(join8(ghDir, "config.yml"), `version: "1"
1101
1410
  `);
1102
1411
  return {
1103
1412
  ghDir,
1104
- writeToken: (token, user) => writeAtomically(join7(ghDir, "hosts.yml"), hostsYaml(token, user)),
1105
- writeStatus: (status) => writeAtomically(join7(ghDir, "status"), `${status}
1413
+ writeToken: (token, user) => writeAtomically(join8(ghDir, "hosts.yml"), hostsYaml(token, user)),
1414
+ writeStatus: (status) => writeAtomically(join8(ghDir, "status"), `${status}
1106
1415
  `),
1107
- remove: () => rmSync3(root, { recursive: true, force: true })
1416
+ remove: () => rmSync4(root, { recursive: true, force: true })
1108
1417
  };
1109
1418
  }
1110
1419
 
@@ -1247,13 +1556,13 @@ function ask(question) {
1247
1556
  // src/session-clone.ts
1248
1557
  import {
1249
1558
  copyFileSync,
1250
- existsSync as existsSync2,
1559
+ existsSync as existsSync3,
1251
1560
  lstatSync,
1252
- mkdirSync as mkdirSync7,
1561
+ mkdirSync as mkdirSync9,
1253
1562
  readdirSync as readdirSync3,
1254
- readFileSync as readFileSync4
1563
+ readFileSync as readFileSync6
1255
1564
  } from "node:fs";
1256
- import { join as join8 } from "node:path";
1565
+ import { join as join9 } from "node:path";
1257
1566
 
1258
1567
  // src/sanitize.ts
1259
1568
  function stripControl(text) {
@@ -1345,14 +1654,14 @@ function dirents(dir) {
1345
1654
  }
1346
1655
  }
1347
1656
  function cloneGitDir(clonePath) {
1348
- const gitDir = join8(clonePath, ".git");
1657
+ const gitDir = join9(clonePath, ".git");
1349
1658
  return realDir(gitDir) ? gitDir : undefined;
1350
1659
  }
1351
1660
  function readSha(path) {
1352
1661
  try {
1353
1662
  if (!lstatSync(path).isFile())
1354
1663
  return;
1355
- const text = readFileSync4(path, "utf8").trim();
1664
+ const text = readFileSync6(path, "utf8").trim();
1356
1665
  return SHA.test(text) ? text : undefined;
1357
1666
  } catch {
1358
1667
  return;
@@ -1362,7 +1671,7 @@ function packedRefs(gitDir) {
1362
1671
  const refs = new Map;
1363
1672
  let text;
1364
1673
  try {
1365
- text = readFileSync4(join8(gitDir, "packed-refs"), "utf8");
1674
+ text = readFileSync6(join9(gitDir, "packed-refs"), "utf8");
1366
1675
  } catch {
1367
1676
  return refs;
1368
1677
  }
@@ -1387,12 +1696,12 @@ function cloneBranches(clonePath) {
1387
1696
  continue;
1388
1697
  const name = prefix ? `${prefix}/${entry.name}` : entry.name;
1389
1698
  if (entry.isDirectory())
1390
- walk(join8(dir, entry.name), name);
1699
+ walk(join9(dir, entry.name), name);
1391
1700
  else if (entry.isFile())
1392
1701
  names.add(name);
1393
1702
  }
1394
1703
  };
1395
- walk(join8(gitDir, "refs", "heads"), "");
1704
+ walk(join9(gitDir, "refs", "heads"), "");
1396
1705
  for (const ref of packedRefs(gitDir).keys()) {
1397
1706
  if (ref.startsWith(HEADS_PREFIX))
1398
1707
  names.add(ref.slice(HEADS_PREFIX.length));
@@ -1405,7 +1714,7 @@ function branchTip(clonePath, branch) {
1405
1714
  const gitDir = cloneGitDir(clonePath);
1406
1715
  if (!gitDir)
1407
1716
  return;
1408
- return readSha(join8(gitDir, "refs", "heads", branch)) ?? packedRefs(gitDir).get(`${HEADS_PREFIX}${branch}`);
1717
+ return readSha(join9(gitDir, "refs", "heads", branch)) ?? packedRefs(gitDir).get(`${HEADS_PREFIX}${branch}`);
1409
1718
  }
1410
1719
  function cloneHead(clonePath) {
1411
1720
  const gitDir = cloneGitDir(clonePath);
@@ -1413,9 +1722,9 @@ function cloneHead(clonePath) {
1413
1722
  return;
1414
1723
  let text;
1415
1724
  try {
1416
- if (!lstatSync(join8(gitDir, "HEAD")).isFile())
1725
+ if (!lstatSync(join9(gitDir, "HEAD")).isFile())
1417
1726
  return;
1418
- text = readFileSync4(join8(gitDir, "HEAD"), "utf8").trim();
1727
+ text = readFileSync6(join9(gitDir, "HEAD"), "utf8").trim();
1419
1728
  } catch {
1420
1729
  return;
1421
1730
  }
@@ -1436,31 +1745,31 @@ function copyObjectFiles(fromDir, intoDir) {
1436
1745
  for (const entry of dirents(fromDir)) {
1437
1746
  if (!entry.isFile())
1438
1747
  continue;
1439
- const dest = join8(intoDir, entry.name);
1440
- if (existsSync2(dest))
1748
+ const dest = join9(intoDir, entry.name);
1749
+ if (existsSync3(dest))
1441
1750
  continue;
1442
1751
  if (!created) {
1443
- mkdirSync7(intoDir, { recursive: true });
1752
+ mkdirSync9(intoDir, { recursive: true });
1444
1753
  created = true;
1445
1754
  }
1446
- copyFileSync(join8(fromDir, entry.name), dest);
1755
+ copyFileSync(join9(fromDir, entry.name), dest);
1447
1756
  }
1448
1757
  }
1449
1758
  function importObjects(run, root, clonePath, tip) {
1450
1759
  const gitDir = cloneGitDir(clonePath);
1451
1760
  if (!gitDir)
1452
1761
  return false;
1453
- const objects = join8(gitDir, "objects");
1762
+ const objects = join9(gitDir, "objects");
1454
1763
  if (!realDir(objects))
1455
1764
  return false;
1456
- const into = join8(root, ".git", "objects");
1765
+ const into = join9(root, ".git", "objects");
1457
1766
  for (const entry of dirents(objects)) {
1458
1767
  if (entry.name === "info" || entry.isSymbolicLink() || !entry.isDirectory()) {
1459
1768
  continue;
1460
1769
  }
1461
1770
  if (!OBJECT_SUBDIR.test(entry.name) && entry.name !== "pack")
1462
1771
  continue;
1463
- copyObjectFiles(join8(objects, entry.name), join8(into, entry.name));
1772
+ copyObjectFiles(join9(objects, entry.name), join9(into, entry.name));
1464
1773
  }
1465
1774
  return git(run, root, ["cat-file", "-e", `${tip}^{commit}`]).ok;
1466
1775
  }
@@ -1468,36 +1777,36 @@ function importObjects(run, root, clonePath, tip) {
1468
1777
  // src/sessions.ts
1469
1778
  import { randomBytes } from "node:crypto";
1470
1779
  import {
1471
- mkdirSync as mkdirSync8,
1780
+ mkdirSync as mkdirSync10,
1472
1781
  readdirSync as readdirSync4,
1473
- readFileSync as readFileSync5,
1782
+ readFileSync as readFileSync7,
1474
1783
  renameSync as renameSync4,
1475
- rmSync as rmSync4,
1476
- writeFileSync as writeFileSync5
1784
+ rmSync as rmSync5,
1785
+ writeFileSync as writeFileSync7
1477
1786
  } from "node:fs";
1478
- import { join as join9 } from "node:path";
1787
+ import { join as join10 } from "node:path";
1479
1788
  function newSessionId() {
1480
1789
  return `maesttro-${randomBytes(4).toString("hex")}`;
1481
1790
  }
1482
1791
  function sessionsDir(home = maesttroHome()) {
1483
- return join9(home, "sessions");
1792
+ return join10(home, "sessions");
1484
1793
  }
1485
1794
  function saveSession(record, dir = sessionsDir()) {
1486
- mkdirSync8(dir, { recursive: true, mode: 448 });
1487
- const path = join9(dir, `${record.id}.json`);
1488
- writeFileSync5(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
1795
+ mkdirSync10(dir, { recursive: true, mode: 448 });
1796
+ const path = join10(dir, `${record.id}.json`);
1797
+ writeFileSync7(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
1489
1798
  `);
1490
1799
  renameSync4(`${path}.tmp`, path);
1491
1800
  }
1492
1801
  function readSession(id, dir = sessionsDir()) {
1493
1802
  try {
1494
- return JSON.parse(readFileSync5(join9(dir, `${id}.json`), "utf8"));
1803
+ return JSON.parse(readFileSync7(join10(dir, `${id}.json`), "utf8"));
1495
1804
  } catch {
1496
1805
  return;
1497
1806
  }
1498
1807
  }
1499
1808
  function deleteSession(id, dir = sessionsDir()) {
1500
- rmSync4(join9(dir, `${id}.json`), { force: true });
1809
+ rmSync5(join10(dir, `${id}.json`), { force: true });
1501
1810
  }
1502
1811
  function sessionsForRepo(repoRoot, dir = sessionsDir()) {
1503
1812
  let names;
@@ -1555,19 +1864,25 @@ function sessionMounts({
1555
1864
  packageRoot,
1556
1865
  hostHome,
1557
1866
  stagedPaths,
1867
+ clientConfigs,
1558
1868
  sharedSkills
1559
1869
  }) {
1560
- const objects = join10(repoRoot, ".git", "objects");
1870
+ const objects = join11(repoRoot, ".git", "objects");
1561
1871
  const mounts = [
1562
1872
  { host: VOLUME, container: "/home/agent" },
1563
1873
  { host: objects, container: objects, readOnly: true },
1564
1874
  { host: sessionDir, container: CONTAINER_REPO },
1565
1875
  { host: packageRoot, container: CONTAINER_PACKAGE, readOnly: true },
1566
1876
  ...stagedPaths.map((path) => ({
1567
- host: join10(hostHome, path),
1877
+ host: join11(hostHome, path),
1568
1878
  container: `${HOST_STAGING_DIR}/${path}`,
1569
1879
  readOnly: true
1570
- }))
1880
+ })),
1881
+ {
1882
+ host: clientConfigs,
1883
+ container: CONTAINER_CLIENT_CONFIGS,
1884
+ readOnly: true
1885
+ }
1571
1886
  ];
1572
1887
  if (sharedSkills) {
1573
1888
  mounts.push({
@@ -1616,12 +1931,12 @@ function ensureReady() {
1616
1931
  execSync3(`docker image inspect ${IMAGE}`, { stdio: "ignore" });
1617
1932
  execSync3(`docker volume inspect ${VOLUME}`, { stdio: "ignore" });
1618
1933
  } catch {
1619
- throw new LaunchError("Sandbox isn't set up yet. Run `maesttro setup` first.");
1934
+ throw new LaunchError("Runner isn't set up yet. Run `maesttro setup` first.");
1620
1935
  }
1621
1936
  }
1622
1937
  function sharedSkillsDir() {
1623
- const dir = process.env.AGENT_SHARED_SKILLS ?? join10(homedir4(), "agent-shared", "skills");
1624
- return existsSync3(dir) ? dir : undefined;
1938
+ const dir = process.env.AGENT_SHARED_SKILLS ?? join11(homedir4(), "agent-shared", "skills");
1939
+ return existsSync4(dir) ? dir : undefined;
1625
1940
  }
1626
1941
  async function resolveAuthor(workspace) {
1627
1942
  const fromRepo = workspaceAuthor(runCommand, workspace);
@@ -1632,7 +1947,7 @@ async function resolveAuthor(workspace) {
1632
1947
  return config.author;
1633
1948
  if (!process.stdin.isTTY)
1634
1949
  return;
1635
- console.log("No git identity found for this repo. Commits in the sandbox need an author (stored in ~/.config/maesttro/config.json).");
1950
+ console.log("No git identity found for this repo. Commits in the runner need an author (stored in ~/.config/maesttro/config.json).");
1636
1951
  const name = await ask("Author name: ");
1637
1952
  const email = await ask("Author email: ");
1638
1953
  if (!name || !email)
@@ -1650,7 +1965,7 @@ function startSession(run, root) {
1650
1965
  if (!baseSha)
1651
1966
  throw new LaunchError(`Can't resolve ${base}.`);
1652
1967
  const id = newSessionId();
1653
- const sessionDir = join10(root, WORKTREES_DIR, id);
1968
+ const sessionDir = join11(root, WORKTREES_DIR, id);
1654
1969
  createSessionClone(run, root, sessionDir, baseSha);
1655
1970
  return {
1656
1971
  id,
@@ -1667,7 +1982,7 @@ function resumeSession(root, id) {
1667
1982
  if (!session || session.repoRoot !== root) {
1668
1983
  throw new LaunchError(`No session ${id} in this repo. See \`maesttro sessions\`.`);
1669
1984
  }
1670
- if (!existsSync3(session.worktree)) {
1985
+ if (!existsSync4(session.worktree)) {
1671
1986
  throw new LaunchError(`The clone for ${id} is gone: ${session.worktree}`);
1672
1987
  }
1673
1988
  return session;
@@ -1690,13 +2005,13 @@ function preserveBranches(run, session) {
1690
2005
  return preserved;
1691
2006
  }
1692
2007
  function finishSession(run, session) {
1693
- if (!existsSync3(session.worktree)) {
2008
+ if (!existsSync4(session.worktree)) {
1694
2009
  deleteSession(session.id);
1695
2010
  return;
1696
2011
  }
1697
2012
  const preserved = preserveBranches(run, session);
1698
2013
  if (!cloneHasWork(session.worktree, session.baseSha)) {
1699
- rmSync5(session.worktree, { recursive: true, force: true });
2014
+ rmSync6(session.worktree, { recursive: true, force: true });
1700
2015
  deleteSession(session.id);
1701
2016
  return;
1702
2017
  }
@@ -1762,12 +2077,21 @@ async function launch({
1762
2077
  if (github.notice)
1763
2078
  console.log(github.notice);
1764
2079
  const hostHome = homedir4();
2080
+ const clientConfigs = buildClientConfigs({
2081
+ host: readHostClientConfigs({ home: hostHome }),
2082
+ url: maesttroUrl()
2083
+ });
2084
+ if (clientConfigs.removed.length > 0) {
2085
+ console.log(`Kept out of the runner as secrets: ${clientConfigs.removed.join(", ")}`);
2086
+ }
2087
+ const clientConfigDir = writeClientConfigDir(clientConfigs.files);
1765
2088
  const { mounts, workdir } = sessionMounts({
1766
2089
  repoRoot: root,
1767
2090
  sessionDir: saved.worktree,
1768
2091
  packageRoot: PACKAGE_ROOT,
1769
2092
  hostHome,
1770
- stagedPaths: STAGED_PATHS.filter((path) => existsSync3(join10(hostHome, path))),
2093
+ stagedPaths: STAGED_PATHS.filter((path) => existsSync4(join11(hostHome, path))),
2094
+ clientConfigs: clientConfigDir.dir,
1771
2095
  sharedSkills: sharedSkillsDir()
1772
2096
  });
1773
2097
  const sessionEnv = {
@@ -1801,6 +2125,7 @@ async function launch({
1801
2125
  for (const signal of FORWARDED_SIGNALS)
1802
2126
  process.off(signal, forward);
1803
2127
  await github.close();
2128
+ clientConfigDir.remove();
1804
2129
  finishSession(run, saved);
1805
2130
  process.exit(exitCode);
1806
2131
  }
@@ -1925,7 +2250,7 @@ Push? [y/N] `);
1925
2250
  }
1926
2251
 
1927
2252
  // src/sessions-command.ts
1928
- import { existsSync as existsSync4, rmSync as rmSync6 } from "node:fs";
2253
+ import { existsSync as existsSync5, rmSync as rmSync7 } from "node:fs";
1929
2254
  function sessionsCommand({ args }) {
1930
2255
  const run = runCommand;
1931
2256
  const root = repoRoot(run, process.cwd());
@@ -1937,7 +2262,7 @@ function sessionsCommand({ args }) {
1937
2262
  process.exitCode = 1;
1938
2263
  return;
1939
2264
  }
1940
- if (existsSync4(session.worktree)) {
2265
+ if (existsSync5(session.worktree)) {
1941
2266
  const preserved = preserveBranches(run, session);
1942
2267
  const head = cloneHead(session.worktree);
1943
2268
  if (preserved.length === 0 && head?.detached !== undefined && head.detached !== session.baseSha) {
@@ -1945,7 +2270,7 @@ function sessionsCommand({ args }) {
1945
2270
  process.exitCode = 1;
1946
2271
  return;
1947
2272
  }
1948
- rmSync6(session.worktree, { recursive: true, force: true });
2273
+ rmSync7(session.worktree, { recursive: true, force: true });
1949
2274
  const kept = preserved.length > 0 ? ` Its branches are kept as refs/maesttro/${session.id}/*.` : "";
1950
2275
  console.log(`Removed session ${session.id}.${kept}`);
1951
2276
  } else {
@@ -1960,7 +2285,7 @@ function sessionsCommand({ args }) {
1960
2285
  return;
1961
2286
  }
1962
2287
  for (const session of sessions) {
1963
- const branches = existsSync4(session.worktree) ? cloneBranches(session.worktree) : undefined;
2288
+ const branches = existsSync5(session.worktree) ? cloneBranches(session.worktree) : undefined;
1964
2289
  const where = branches === undefined ? "clone missing" : branches.length > 0 ? `branches: ${branches.join(", ")}` : "no branches yet";
1965
2290
  console.log(` ${session.id} ${session.createdAt.slice(0, 16)} ${where}`);
1966
2291
  }
@@ -1984,7 +2309,7 @@ async function setup({ args }) {
1984
2309
  console.log(`== maesttro setup ==
1985
2310
  `);
1986
2311
  ensureDocker();
1987
- console.log("-> Building sandbox image (this pulls node:22-slim + installs CLIs, first run only)...");
2312
+ console.log("-> Building runner image (this pulls node:22-slim + installs CLIs, first run only)...");
1988
2313
  run(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`);
1989
2314
  console.log(`
1990
2315
  -> Ensuring persistent volume exists...`);
@@ -2010,10 +2335,10 @@ async function setup({ args }) {
2010
2335
 
2011
2336
  // src/update.ts
2012
2337
  import { execSync as execSync5 } from "node:child_process";
2013
- import { readFileSync as readFileSync6 } from "node:fs";
2014
- import { join as join11 } from "node:path";
2338
+ import { readFileSync as readFileSync8 } from "node:fs";
2339
+ import { join as join12 } from "node:path";
2015
2340
  function installedPackageName() {
2016
- const manifest = JSON.parse(readFileSync6(join11(PACKAGE_ROOT, "package.json"), "utf8"));
2341
+ const manifest = JSON.parse(readFileSync8(join12(PACKAGE_ROOT, "package.json"), "utf8"));
2017
2342
  return manifest.name;
2018
2343
  }
2019
2344
  function update() {
@@ -2023,11 +2348,11 @@ function update() {
2023
2348
  console.log(`-> Updating ${packageName} package...`);
2024
2349
  execSync5(`npm i -g ${packageName}@latest`, { stdio: "inherit" });
2025
2350
  console.log(`
2026
- -> Rebuilding sandbox image with refreshed client configs...`);
2351
+ -> Rebuilding runner image with refreshed client configs...`);
2027
2352
  execSync5(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`, { stdio: "inherit" });
2028
2353
  console.log(`
2029
2354
  Update complete. Bot identity and your project files are untouched —`);
2030
- console.log("only the image (client versions + curated configs) was refreshed.");
2355
+ console.log("only the runner image (client versions + curated configs) was refreshed.");
2031
2356
  }
2032
2357
 
2033
2358
  // src/main.ts
package/docker/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- # git is built from source: Debian ships 2.47 and the sandbox wants a recent
1
+ # git is built from source: Debian ships 2.47 and the runner wants a recent
2
2
  # git. Builder matches node:22-slim's Debian so the runtime libs line up.
3
3
  FROM debian:bookworm-slim AS git-build
4
4
  ARG GIT_VERSION=2.55.0
@@ -35,19 +35,18 @@ RUN apt-get update \
35
35
  # System-level, not ~/.gitconfig: /home/agent is the persistent volume and
36
36
  # shadows anything baked into home for existing installs
37
37
  COPY gitconfig /etc/gitconfig
38
- COPY sandbox-marker /etc/maesttro/sandbox
38
+ COPY runner-marker /etc/maesttro/runner
39
39
 
40
40
  # Curated client CLIs — keep in sync with src/clients.ts
41
41
  RUN npm install -g \
42
42
  @anthropic-ai/claude-code \
43
- opencode-ai \
44
- @openai/codex
43
+ opencode-ai
45
44
 
46
45
  RUN useradd -m -s /bin/bash agent
47
46
  USER agent
48
47
  # Created as agent so a fresh volume inherits them; otherwise Docker creates
49
48
  # ~/.config and ~/.claude as root when it sets up the nested read-only mounts
50
- RUN mkdir -p /home/agent/.config/opencode /home/agent/.claude /home/agent/.codex
49
+ RUN mkdir -p /home/agent/.config/opencode /home/agent/.claude
51
50
  WORKDIR /workspace
52
51
 
53
52
  # Client configs are applied at container start by `maesttro configure`
@@ -59,4 +58,4 @@ WORKDIR /workspace
59
58
  USER root
60
59
  COPY --chmod=755 entrypoint.sh /usr/local/bin/maesttro-entrypoint
61
60
  ENTRYPOINT ["/usr/local/bin/maesttro-entrypoint"]
62
- CMD ["claude"]
61
+ CMD ["opencode"]
@@ -1,3 +1,3 @@
1
1
  {
2
- "_comment": "Curated default settings for opencode inside the maesttro sandbox. Replace with your actual house defaults."
2
+ "_comment": "Curated default settings for opencode inside the maesttro runner. Replace with your actual house defaults."
3
3
  }
@@ -0,0 +1 @@
1
+ maesttro runner
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "maesttro",
3
- "version": "0.2.0",
4
- "description": "Isolated Docker sandbox for running local coding agents (claude, opencode, codex, ...) under a dedicated bot GitHub identity, with curated configs baked in per client.",
3
+ "version": "0.2.1",
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": {
7
7
  "maesttro": "dist/maesttro.js"
@@ -1,3 +0,0 @@
1
- {
2
- "_comment": "Curated default settings for Claude Code inside the maesttro sandbox. Replace with your actual house defaults."
3
- }
@@ -1,3 +0,0 @@
1
- {
2
- "_comment": "Curated default settings for OpenAI Codex CLI inside the maesttro sandbox. Replace with your actual house defaults."
3
- }
@@ -1 +0,0 @@
1
- maesttro sandbox