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 +30 -19
- package/dist/maesttro.js +500 -175
- package/docker/Dockerfile +5 -6
- package/docker/configs/opencode/config.json +1 -1
- package/docker/runner-marker +1 -0
- package/package.json +2 -2
- package/docker/configs/claude/settings.json +0 -3
- package/docker/configs/codex/config.json +0 -3
- package/docker/sandbox-marker +0 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# maesttro
|
|
2
2
|
|
|
3
|
-
Isolated Docker
|
|
4
|
-
and others. Inside the
|
|
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 (
|
|
59
|
+
maesttro # from a repo root: new session clone + default client (opencode)
|
|
60
60
|
maesttro opencode # same, with opencode
|
|
61
|
-
maesttro
|
|
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
|
|
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
|
|
114
|
-
`~/.claude/{agents,commands,skills}` into the
|
|
115
|
-
source, and adds a short section explaining the
|
|
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
|
-
| `
|
|
129
|
-
| `
|
|
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
|
|
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
|
-
|
|
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
|
|
354
|
+
cpSync as cpSync2,
|
|
355
|
+
existsSync as existsSync2,
|
|
356
|
+
mkdirSync as mkdirSync4,
|
|
357
357
|
readdirSync,
|
|
358
|
-
readFileSync as
|
|
359
|
-
writeFileSync as
|
|
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
|
|
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 = "
|
|
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
|
|
725
|
+
import { dirname as dirname4, join as join3, resolve } from "node:path";
|
|
406
726
|
import { fileURLToPath } from "node:url";
|
|
407
|
-
var PACKAGE_ROOT = resolve(
|
|
408
|
-
var DOCKERFILE_DIR =
|
|
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/
|
|
413
|
-
function
|
|
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
|
|
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
|
|
750
|
+
"# Maesttro runner",
|
|
431
751
|
"",
|
|
432
|
-
"You are running inside Maesttro's isolated
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
489
|
-
if (!
|
|
789
|
+
const source = join4(configsDir, key);
|
|
790
|
+
if (!existsSync2(source))
|
|
490
791
|
continue;
|
|
491
|
-
const targetDir =
|
|
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(
|
|
796
|
+
const curated = withoutComments(readJson(join4(source, name)));
|
|
496
797
|
if (Object.keys(curated).length === 0)
|
|
497
798
|
continue;
|
|
498
|
-
const target =
|
|
499
|
-
|
|
799
|
+
const target = join4(targetDir, name);
|
|
800
|
+
mkdirSync4(targetDir, { recursive: true });
|
|
500
801
|
const merged = mergeJson(readJson(target), curated);
|
|
501
|
-
|
|
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 =
|
|
515
|
-
if (!
|
|
815
|
+
const source = join4(staging, directory);
|
|
816
|
+
if (!existsSync2(source))
|
|
516
817
|
continue;
|
|
517
818
|
try {
|
|
518
|
-
|
|
819
|
+
cpSync2(source, join4(home, directory), { recursive: true, force: true });
|
|
519
820
|
} catch {}
|
|
520
821
|
}
|
|
521
|
-
const instructions =
|
|
822
|
+
const instructions = runnerInstructions(context);
|
|
522
823
|
for (const file of INSTRUCTION_FILES) {
|
|
523
|
-
const source =
|
|
524
|
-
const hostCopy =
|
|
525
|
-
const target =
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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 (
|
|
539
|
-
|
|
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
|
|
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
|
|
665
|
-
import { join as
|
|
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) !==
|
|
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
|
-
|
|
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 =
|
|
739
|
-
|
|
740
|
-
appendFileSync(
|
|
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
|
|
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
|
-
"
|
|
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
|
|
1157
|
+
"Personal SSH key NOT visible inside runner (isolation intact)",
|
|
849
1158
|
() => {
|
|
850
|
-
const out =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
904
|
-
import { basename, join as
|
|
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
|
|
910
|
-
readFileSync as
|
|
1218
|
+
mkdirSync as mkdirSync6,
|
|
1219
|
+
readFileSync as readFileSync5,
|
|
911
1220
|
renameSync as renameSync2,
|
|
912
|
-
writeFileSync as
|
|
1221
|
+
writeFileSync as writeFileSync5
|
|
913
1222
|
} from "node:fs";
|
|
914
1223
|
import { homedir as homedir3 } from "node:os";
|
|
915
|
-
import { join as
|
|
1224
|
+
import { join as join6 } from "node:path";
|
|
916
1225
|
function maesttroHome() {
|
|
917
|
-
return
|
|
1226
|
+
return join6(homedir3(), ".config", "maesttro");
|
|
918
1227
|
}
|
|
919
1228
|
function configPath() {
|
|
920
|
-
return
|
|
1229
|
+
return join6(maesttroHome(), "config.json");
|
|
921
1230
|
}
|
|
922
1231
|
function readMaesttroConfig() {
|
|
923
1232
|
try {
|
|
924
|
-
return JSON.parse(
|
|
1233
|
+
return JSON.parse(readFileSync5(configPath(), "utf8"));
|
|
925
1234
|
} catch {
|
|
926
1235
|
return {};
|
|
927
1236
|
}
|
|
928
1237
|
}
|
|
929
1238
|
function writeMaesttroConfig(config) {
|
|
930
|
-
|
|
1239
|
+
mkdirSync6(maesttroHome(), { recursive: true, mode: 448 });
|
|
931
1240
|
const temporary = `${configPath()}.tmp`;
|
|
932
|
-
|
|
1241
|
+
writeFileSync5(temporary, `${JSON.stringify(config, null, 2)}
|
|
933
1242
|
`);
|
|
934
1243
|
renameSync2(temporary, configPath());
|
|
935
1244
|
}
|
|
936
|
-
function fileSessionLog(path =
|
|
1245
|
+
function fileSessionLog(path = join6(maesttroHome(), "maesttro.log")) {
|
|
937
1246
|
return (event, detail) => {
|
|
938
1247
|
try {
|
|
939
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1284
|
+
return { path: join7(dir, latest), reused: true };
|
|
976
1285
|
}
|
|
977
1286
|
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
978
|
-
const path =
|
|
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
|
-
|
|
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
|
|
1069
|
-
mkdirSync as
|
|
1070
|
-
mkdtempSync,
|
|
1377
|
+
chmodSync as chmodSync3,
|
|
1378
|
+
mkdirSync as mkdirSync8,
|
|
1379
|
+
mkdtempSync as mkdtempSync2,
|
|
1071
1380
|
renameSync as renameSync3,
|
|
1072
|
-
rmSync as
|
|
1073
|
-
writeFileSync as
|
|
1381
|
+
rmSync as rmSync4,
|
|
1382
|
+
writeFileSync as writeFileSync6
|
|
1074
1383
|
} from "node:fs";
|
|
1075
|
-
import { tmpdir } from "node:os";
|
|
1076
|
-
import { join as
|
|
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
|
-
|
|
1081
|
-
|
|
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 =
|
|
1095
|
-
const root =
|
|
1096
|
-
|
|
1097
|
-
const ghDir =
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
writeAtomically(
|
|
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(
|
|
1105
|
-
writeStatus: (status) => writeAtomically(
|
|
1413
|
+
writeToken: (token, user) => writeAtomically(join8(ghDir, "hosts.yml"), hostsYaml(token, user)),
|
|
1414
|
+
writeStatus: (status) => writeAtomically(join8(ghDir, "status"), `${status}
|
|
1106
1415
|
`),
|
|
1107
|
-
remove: () =>
|
|
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
|
|
1559
|
+
existsSync as existsSync3,
|
|
1251
1560
|
lstatSync,
|
|
1252
|
-
mkdirSync as
|
|
1561
|
+
mkdirSync as mkdirSync9,
|
|
1253
1562
|
readdirSync as readdirSync3,
|
|
1254
|
-
readFileSync as
|
|
1563
|
+
readFileSync as readFileSync6
|
|
1255
1564
|
} from "node:fs";
|
|
1256
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
1699
|
+
walk(join9(dir, entry.name), name);
|
|
1391
1700
|
else if (entry.isFile())
|
|
1392
1701
|
names.add(name);
|
|
1393
1702
|
}
|
|
1394
1703
|
};
|
|
1395
|
-
walk(
|
|
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(
|
|
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(
|
|
1725
|
+
if (!lstatSync(join9(gitDir, "HEAD")).isFile())
|
|
1417
1726
|
return;
|
|
1418
|
-
text =
|
|
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 =
|
|
1440
|
-
if (
|
|
1748
|
+
const dest = join9(intoDir, entry.name);
|
|
1749
|
+
if (existsSync3(dest))
|
|
1441
1750
|
continue;
|
|
1442
1751
|
if (!created) {
|
|
1443
|
-
|
|
1752
|
+
mkdirSync9(intoDir, { recursive: true });
|
|
1444
1753
|
created = true;
|
|
1445
1754
|
}
|
|
1446
|
-
copyFileSync(
|
|
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 =
|
|
1762
|
+
const objects = join9(gitDir, "objects");
|
|
1454
1763
|
if (!realDir(objects))
|
|
1455
1764
|
return false;
|
|
1456
|
-
const into =
|
|
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(
|
|
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
|
|
1780
|
+
mkdirSync as mkdirSync10,
|
|
1472
1781
|
readdirSync as readdirSync4,
|
|
1473
|
-
readFileSync as
|
|
1782
|
+
readFileSync as readFileSync7,
|
|
1474
1783
|
renameSync as renameSync4,
|
|
1475
|
-
rmSync as
|
|
1476
|
-
writeFileSync as
|
|
1784
|
+
rmSync as rmSync5,
|
|
1785
|
+
writeFileSync as writeFileSync7
|
|
1477
1786
|
} from "node:fs";
|
|
1478
|
-
import { join as
|
|
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
|
|
1792
|
+
return join10(home, "sessions");
|
|
1484
1793
|
}
|
|
1485
1794
|
function saveSession(record, dir = sessionsDir()) {
|
|
1486
|
-
|
|
1487
|
-
const path =
|
|
1488
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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("
|
|
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 ??
|
|
1624
|
-
return
|
|
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
|
|
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 =
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
2014
|
-
import { join as
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ["
|
|
61
|
+
CMD ["opencode"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
maesttro runner
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maesttro",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Isolated Docker
|
|
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"
|
package/docker/sandbox-marker
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
maesttro sandbox
|