maesttro 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -42
- package/dist/maesttro.js +2106 -0
- package/docker/Dockerfile +47 -10
- package/docker/entrypoint.sh +18 -0
- package/docker/gitconfig +8 -0
- package/docker/sandbox-marker +1 -0
- package/package.json +11 -4
- package/bin/maesttro.js +0 -49
- package/src/clients.js +0 -26
- package/src/doctor.js +0 -78
- package/src/launch.js +0 -68
- package/src/paths.js +0 -12
- package/src/setup.js +0 -101
- package/src/update.js +0 -22
package/dist/maesttro.js
ADDED
|
@@ -0,0 +1,2106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/credential-store.ts
|
|
4
|
+
import {
|
|
5
|
+
chmodSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// src/run-command.ts
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
var runCommand = (command, args, input) => {
|
|
18
|
+
const result = spawnSync(command, args, {
|
|
19
|
+
input,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
status: result.error ? null : result.status,
|
|
25
|
+
stdout: result.stdout ?? ""
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
function commandExists(run, command) {
|
|
29
|
+
return run("sh", ["-c", `command -v ${command}`]).status === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/credential-store.ts
|
|
33
|
+
var SERVICE = "maesttro";
|
|
34
|
+
var ACCOUNT = "github";
|
|
35
|
+
function encode(token) {
|
|
36
|
+
return Buffer.from(JSON.stringify(token)).toString("base64");
|
|
37
|
+
}
|
|
38
|
+
function decode(encoded) {
|
|
39
|
+
try {
|
|
40
|
+
const token = JSON.parse(Buffer.from(encoded.trim(), "base64").toString("utf8"));
|
|
41
|
+
return typeof token.access_token === "string" ? token : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function keychainStore(run = runCommand) {
|
|
47
|
+
const identity = ["-s", SERVICE, "-a", ACCOUNT];
|
|
48
|
+
return {
|
|
49
|
+
kind: "macOS keychain",
|
|
50
|
+
load: () => {
|
|
51
|
+
const result = run("security", [
|
|
52
|
+
"find-generic-password",
|
|
53
|
+
...identity,
|
|
54
|
+
"-w"
|
|
55
|
+
]);
|
|
56
|
+
return result.status === 0 ? decode(result.stdout) : undefined;
|
|
57
|
+
},
|
|
58
|
+
save: (token) => {
|
|
59
|
+
const command = `add-generic-password -U ${identity.join(" ")} -w ${encode(token)}
|
|
60
|
+
`;
|
|
61
|
+
const result = run("security", ["-i"], command);
|
|
62
|
+
if (result.status !== 0)
|
|
63
|
+
throw new Error("Could not write to keychain");
|
|
64
|
+
},
|
|
65
|
+
clear: () => {
|
|
66
|
+
run("security", ["delete-generic-password", ...identity]);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function secretToolStore(run = runCommand) {
|
|
71
|
+
const attributes = ["service", SERVICE, "account", ACCOUNT];
|
|
72
|
+
return {
|
|
73
|
+
kind: "Secret Service (secret-tool)",
|
|
74
|
+
load: () => {
|
|
75
|
+
const result = run("secret-tool", ["lookup", ...attributes]);
|
|
76
|
+
return result.status === 0 ? decode(result.stdout) : undefined;
|
|
77
|
+
},
|
|
78
|
+
save: (token) => {
|
|
79
|
+
const result = run("secret-tool", ["store", `--label=${SERVICE}`, ...attributes], encode(token));
|
|
80
|
+
if (result.status !== 0) {
|
|
81
|
+
throw new Error("Could not write to the Secret Service");
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
clear: () => {
|
|
85
|
+
run("secret-tool", ["clear", ...attributes]);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function fileStore(path = join(homedir(), ".config", "maesttro", "credentials.json")) {
|
|
90
|
+
return {
|
|
91
|
+
kind: `file (${path})`,
|
|
92
|
+
load: () => {
|
|
93
|
+
try {
|
|
94
|
+
return decode(readFileSync(path, "utf8"));
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
save: (token) => {
|
|
100
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
101
|
+
const temporary = `${path}.tmp`;
|
|
102
|
+
writeFileSync(temporary, encode(token), { mode: 384 });
|
|
103
|
+
chmodSync(temporary, 384);
|
|
104
|
+
renameSync(temporary, path);
|
|
105
|
+
},
|
|
106
|
+
clear: () => {
|
|
107
|
+
rmSync(path, { force: true });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function selectCredentialStore({
|
|
112
|
+
platform = process.platform,
|
|
113
|
+
env = process.env,
|
|
114
|
+
run = runCommand
|
|
115
|
+
} = {}) {
|
|
116
|
+
if (env.MAESTTRO_CREDENTIAL_STORE === "file")
|
|
117
|
+
return fileStore();
|
|
118
|
+
if (platform === "darwin")
|
|
119
|
+
return keychainStore(run);
|
|
120
|
+
if (platform === "linux" && commandExists(run, "secret-tool")) {
|
|
121
|
+
return secretToolStore(run);
|
|
122
|
+
}
|
|
123
|
+
return fileStore();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/github-api.ts
|
|
127
|
+
function githubApiUrl(env = process.env) {
|
|
128
|
+
return env.MAESTTRO_GITHUB_API_URL?.trim() || "https://api.github.com";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/github-app.ts
|
|
132
|
+
var DEFAULT_GITHUB_APP_CLIENT_ID = "Iv23lig1wvF2RtVsAMWo";
|
|
133
|
+
function githubAppClientId(env = process.env) {
|
|
134
|
+
return env.MAESTTRO_GITHUB_CLIENT_ID?.trim() || DEFAULT_GITHUB_APP_CLIENT_ID;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/device-flow.ts
|
|
138
|
+
var DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
139
|
+
var ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
140
|
+
var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
141
|
+
var SLOW_DOWN_PENALTY_SECONDS = 5;
|
|
142
|
+
|
|
143
|
+
class DeviceFlowError extends Error {
|
|
144
|
+
code;
|
|
145
|
+
constructor(code) {
|
|
146
|
+
super(`GitHub login failed: ${code}`);
|
|
147
|
+
this.code = code;
|
|
148
|
+
this.name = "DeviceFlowError";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
152
|
+
async function postForm(fetchImpl, url, form) {
|
|
153
|
+
const response = await fetchImpl(url, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
Accept: "application/json",
|
|
157
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
158
|
+
},
|
|
159
|
+
body: new URLSearchParams(form),
|
|
160
|
+
signal: AbortSignal.timeout(15000)
|
|
161
|
+
});
|
|
162
|
+
const body = await response.json().catch(() => ({}));
|
|
163
|
+
if (!response.ok && typeof body.error !== "string") {
|
|
164
|
+
throw new DeviceFlowError(`http_${response.status}`);
|
|
165
|
+
}
|
|
166
|
+
return body;
|
|
167
|
+
}
|
|
168
|
+
function asGrant(body) {
|
|
169
|
+
if (typeof body.access_token !== "string") {
|
|
170
|
+
throw new DeviceFlowError(typeof body.error === "string" ? body.error : "malformed_response");
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
access_token: body.access_token,
|
|
174
|
+
expires_in: body.expires_in,
|
|
175
|
+
refresh_token: body.refresh_token,
|
|
176
|
+
refresh_token_expires_in: body.refresh_token_expires_in
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
async function requestDeviceCode({
|
|
180
|
+
clientId,
|
|
181
|
+
fetch: fetchImpl = fetch
|
|
182
|
+
}) {
|
|
183
|
+
const body = await postForm(fetchImpl, DEVICE_CODE_URL, {
|
|
184
|
+
client_id: clientId
|
|
185
|
+
});
|
|
186
|
+
if (typeof body.device_code !== "string") {
|
|
187
|
+
throw new DeviceFlowError(typeof body.error === "string" ? body.error : "malformed_response");
|
|
188
|
+
}
|
|
189
|
+
return body;
|
|
190
|
+
}
|
|
191
|
+
async function pollForGrant({
|
|
192
|
+
clientId,
|
|
193
|
+
deviceCode,
|
|
194
|
+
fetch: fetchImpl = fetch,
|
|
195
|
+
sleep = defaultSleep,
|
|
196
|
+
now = Date.now
|
|
197
|
+
}) {
|
|
198
|
+
const deadline = now() + deviceCode.expires_in * 1000;
|
|
199
|
+
let intervalSeconds = deviceCode.interval;
|
|
200
|
+
while (now() < deadline) {
|
|
201
|
+
await sleep(intervalSeconds * 1000);
|
|
202
|
+
const body = await postForm(fetchImpl, ACCESS_TOKEN_URL, {
|
|
203
|
+
client_id: clientId,
|
|
204
|
+
device_code: deviceCode.device_code,
|
|
205
|
+
grant_type: DEVICE_GRANT
|
|
206
|
+
});
|
|
207
|
+
if (body.error === "authorization_pending")
|
|
208
|
+
continue;
|
|
209
|
+
if (body.error === "slow_down") {
|
|
210
|
+
intervalSeconds = typeof body.interval === "number" ? body.interval : intervalSeconds + SLOW_DOWN_PENALTY_SECONDS;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
return asGrant(body);
|
|
214
|
+
}
|
|
215
|
+
throw new DeviceFlowError("expired_token");
|
|
216
|
+
}
|
|
217
|
+
async function refreshGrant({
|
|
218
|
+
clientId,
|
|
219
|
+
refreshToken,
|
|
220
|
+
fetch: fetchImpl = fetch
|
|
221
|
+
}) {
|
|
222
|
+
const body = await postForm(fetchImpl, ACCESS_TOKEN_URL, {
|
|
223
|
+
client_id: clientId,
|
|
224
|
+
grant_type: "refresh_token",
|
|
225
|
+
refresh_token: refreshToken
|
|
226
|
+
});
|
|
227
|
+
return asGrant(body);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/user-token.ts
|
|
231
|
+
var EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
|
232
|
+
function isoAfter(now, seconds) {
|
|
233
|
+
return seconds === undefined ? undefined : new Date(now + seconds * 1000).toISOString();
|
|
234
|
+
}
|
|
235
|
+
function userTokenFromGrant(grant, now) {
|
|
236
|
+
return {
|
|
237
|
+
access_token: grant.access_token,
|
|
238
|
+
expires_at: isoAfter(now, grant.expires_in),
|
|
239
|
+
refresh_token: grant.refresh_token,
|
|
240
|
+
refresh_expires_at: isoAfter(now, grant.refresh_token_expires_in)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function stillValid(expiresAt, now) {
|
|
244
|
+
return expiresAt === undefined || Date.parse(expiresAt) - EXPIRY_MARGIN_MS > now;
|
|
245
|
+
}
|
|
246
|
+
function isAccessValid(token, now) {
|
|
247
|
+
return stillValid(token.expires_at, now);
|
|
248
|
+
}
|
|
249
|
+
function isRefreshable(token, now) {
|
|
250
|
+
return token.refresh_token !== undefined && stillValid(token.refresh_expires_at, now);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/github-login.ts
|
|
254
|
+
async function deviceLogin(dependencies) {
|
|
255
|
+
const now = dependencies.now ?? Date.now;
|
|
256
|
+
const deviceCode = await requestDeviceCode(dependencies);
|
|
257
|
+
dependencies.announce(`To give maesttro GitHub access, open ${deviceCode.verification_uri} and enter code ${deviceCode.user_code}`);
|
|
258
|
+
const grant = await pollForGrant({ ...dependencies, deviceCode });
|
|
259
|
+
const token = userTokenFromGrant(grant, now());
|
|
260
|
+
dependencies.store.save(token);
|
|
261
|
+
return token;
|
|
262
|
+
}
|
|
263
|
+
async function refreshUserToken(dependencies) {
|
|
264
|
+
const now = dependencies.now ?? Date.now;
|
|
265
|
+
const grant = await refreshGrant({
|
|
266
|
+
...dependencies,
|
|
267
|
+
refreshToken: dependencies.token.refresh_token
|
|
268
|
+
});
|
|
269
|
+
const token = userTokenFromGrant(grant, now());
|
|
270
|
+
dependencies.store.save(token);
|
|
271
|
+
return token;
|
|
272
|
+
}
|
|
273
|
+
async function currentUserToken(dependencies) {
|
|
274
|
+
const now = (dependencies.now ?? Date.now)();
|
|
275
|
+
const stored = dependencies.store.load();
|
|
276
|
+
if (stored && isAccessValid(stored, now))
|
|
277
|
+
return stored;
|
|
278
|
+
if (stored && isRefreshable(stored, now)) {
|
|
279
|
+
try {
|
|
280
|
+
return await refreshUserToken({ ...dependencies, token: stored });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (!(error instanceof DeviceFlowError))
|
|
283
|
+
throw error;
|
|
284
|
+
dependencies.store.clear();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return dependencies.interactive ? deviceLogin(dependencies) : undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/account.ts
|
|
291
|
+
async function githubLogin(token, fetchImpl = fetch) {
|
|
292
|
+
const response = await fetchImpl(new URL("/user", githubApiUrl()), {
|
|
293
|
+
headers: {
|
|
294
|
+
Accept: "application/vnd.github+json",
|
|
295
|
+
Authorization: `Bearer ${token}`,
|
|
296
|
+
"User-Agent": "maesttro"
|
|
297
|
+
},
|
|
298
|
+
signal: AbortSignal.timeout(1e4)
|
|
299
|
+
});
|
|
300
|
+
if (!response.ok)
|
|
301
|
+
return;
|
|
302
|
+
const body = await response.json();
|
|
303
|
+
return body.login;
|
|
304
|
+
}
|
|
305
|
+
function loginDependencies() {
|
|
306
|
+
return {
|
|
307
|
+
clientId: githubAppClientId(),
|
|
308
|
+
store: selectCredentialStore(),
|
|
309
|
+
announce: (message) => console.log(message)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function describeExpiry(iso) {
|
|
313
|
+
return iso ? new Date(iso).toLocaleString() : "never";
|
|
314
|
+
}
|
|
315
|
+
async function login() {
|
|
316
|
+
const dependencies = loginDependencies();
|
|
317
|
+
const token = await deviceLogin(dependencies);
|
|
318
|
+
const name = await githubLogin(token.access_token);
|
|
319
|
+
console.log(`Logged in to GitHub${name ? ` as ${name}` : ""}. Stored in ${dependencies.store.kind}.`);
|
|
320
|
+
}
|
|
321
|
+
async function ensureLogin() {
|
|
322
|
+
const dependencies = loginDependencies();
|
|
323
|
+
const token = await currentUserToken({ ...dependencies, interactive: true });
|
|
324
|
+
const name = token && await githubLogin(token.access_token);
|
|
325
|
+
console.log(name ? `Logged in to GitHub as ${name}. Stored in ${dependencies.store.kind}.` : "GitHub login did not complete. Run `maesttro login` to retry.");
|
|
326
|
+
}
|
|
327
|
+
function logout() {
|
|
328
|
+
const { store } = loginDependencies();
|
|
329
|
+
store.clear();
|
|
330
|
+
console.log(`Logged out. Removed GitHub credentials from ${store.kind}.`);
|
|
331
|
+
}
|
|
332
|
+
async function status() {
|
|
333
|
+
const dependencies = loginDependencies();
|
|
334
|
+
const token = await currentUserToken({ ...dependencies, interactive: false });
|
|
335
|
+
if (!token) {
|
|
336
|
+
console.log("Not logged in to GitHub. Run `maesttro login`.");
|
|
337
|
+
process.exitCode = 1;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const name = await githubLogin(token.access_token);
|
|
341
|
+
console.log([
|
|
342
|
+
`GitHub: ${name ? `logged in as ${name}` : "token rejected — run `maesttro login`"}`,
|
|
343
|
+
`Store: ${dependencies.store.kind}`,
|
|
344
|
+
`Access token expires: ${describeExpiry(token.expires_at)}`,
|
|
345
|
+
`Refresh token expires: ${describeExpiry(token.refresh_expires_at)}`
|
|
346
|
+
].join(`
|
|
347
|
+
`));
|
|
348
|
+
if (!name)
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/configure.ts
|
|
353
|
+
import {
|
|
354
|
+
cpSync,
|
|
355
|
+
existsSync,
|
|
356
|
+
mkdirSync as mkdirSync2,
|
|
357
|
+
readdirSync,
|
|
358
|
+
readFileSync as readFileSync2,
|
|
359
|
+
writeFileSync as writeFileSync2
|
|
360
|
+
} from "node:fs";
|
|
361
|
+
import { homedir as homedir2 } from "node:os";
|
|
362
|
+
import { dirname as dirname3, join as join3 } from "node:path";
|
|
363
|
+
|
|
364
|
+
// src/clients.ts
|
|
365
|
+
var CLIENTS = {
|
|
366
|
+
claude: {
|
|
367
|
+
label: "Claude Code",
|
|
368
|
+
npmPackage: "@anthropic-ai/claude-code",
|
|
369
|
+
binary: "claude",
|
|
370
|
+
configDir: "/home/agent/.claude"
|
|
371
|
+
},
|
|
372
|
+
opencode: {
|
|
373
|
+
label: "opencode",
|
|
374
|
+
npmPackage: "opencode-ai",
|
|
375
|
+
binary: "opencode",
|
|
376
|
+
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
|
+
}
|
|
384
|
+
};
|
|
385
|
+
var DEFAULT_CLIENT = "claude";
|
|
386
|
+
function isClientKey(value) {
|
|
387
|
+
return Object.hasOwn(CLIENTS, value);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/host-instructions.ts
|
|
391
|
+
var HOST_STAGING_DIR = "/opt/maesttro-host";
|
|
392
|
+
var STAGED_DIRECTORIES = [
|
|
393
|
+
".claude/agents",
|
|
394
|
+
".claude/commands",
|
|
395
|
+
".claude/skills"
|
|
396
|
+
];
|
|
397
|
+
var INSTRUCTION_FILES = [
|
|
398
|
+
".claude/CLAUDE.md",
|
|
399
|
+
".config/opencode/AGENTS.md",
|
|
400
|
+
".codex/AGENTS.md"
|
|
401
|
+
];
|
|
402
|
+
var STAGED_PATHS = [...INSTRUCTION_FILES, ...STAGED_DIRECTORIES];
|
|
403
|
+
|
|
404
|
+
// src/paths.ts
|
|
405
|
+
import { dirname as dirname2, join as join2, resolve } from "node:path";
|
|
406
|
+
import { fileURLToPath } from "node:url";
|
|
407
|
+
var PACKAGE_ROOT = resolve(dirname2(fileURLToPath(import.meta.url)), "..");
|
|
408
|
+
var DOCKERFILE_DIR = join2(PACKAGE_ROOT, "docker");
|
|
409
|
+
var IMAGE = "maesttro-runner:latest";
|
|
410
|
+
var VOLUME = process.env.MAESTTRO_VOLUME?.trim() || "maesttro-home";
|
|
411
|
+
|
|
412
|
+
// src/sandbox-context.ts
|
|
413
|
+
function sandboxContextFromEnv(env) {
|
|
414
|
+
return {
|
|
415
|
+
sessionId: env.MAESTTRO_SESSION_ID,
|
|
416
|
+
base: env.MAESTTRO_BASE,
|
|
417
|
+
repo: env.MAESTTRO_REPO,
|
|
418
|
+
githubAccess: env.MAESTTRO_GITHUB === "on"
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function sandboxInstructions(context) {
|
|
422
|
+
const base = context.base ?? "the default branch";
|
|
423
|
+
const github = context.githubAccess ? [
|
|
424
|
+
`- \`gh\` works for ${context.repo ?? "this repo"} only: issues, pull requests, comments. Its token is short-lived and refreshed for you.`,
|
|
425
|
+
"- Merging pull requests and deleting remote branches are not possible from here. A person merges."
|
|
426
|
+
] : [
|
|
427
|
+
"- GitHub access is unavailable in this session, so `gh` has no token. Local git work still works."
|
|
428
|
+
];
|
|
429
|
+
return [
|
|
430
|
+
"# Maesttro sandbox",
|
|
431
|
+
"",
|
|
432
|
+
"You are running inside Maesttro's isolated sandbox container. These rules describe what the environment allows, so plan around them.",
|
|
433
|
+
"",
|
|
434
|
+
"## Your workspace",
|
|
435
|
+
"",
|
|
436
|
+
`- 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
|
+
"- Before your first commit, create a branch for the task: `git switch -c <short-descriptive-name>`. Keep one branch per task.",
|
|
438
|
+
"- 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.",
|
|
440
|
+
"",
|
|
441
|
+
"## Pushing",
|
|
442
|
+
"",
|
|
443
|
+
"- `git push` does not work here: the sandbox has read-only access to the remote. This is deliberate.",
|
|
444
|
+
'- When a branch is ready, commit everything, then tell the user: "Ready to push: <branch>. Run `maesttro push <branch>` from the repository root."',
|
|
445
|
+
"- 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
|
+
"",
|
|
447
|
+
"## GitHub",
|
|
448
|
+
"",
|
|
449
|
+
...github,
|
|
450
|
+
""
|
|
451
|
+
].join(`
|
|
452
|
+
`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 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
|
+
}
|
|
460
|
+
function withoutComments(value) {
|
|
461
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith("_comment")).map(([key, inner]) => [
|
|
462
|
+
key,
|
|
463
|
+
isObject(inner) ? withoutComments(inner) : inner
|
|
464
|
+
]));
|
|
465
|
+
}
|
|
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
|
+
function applyProviderConfigs({
|
|
483
|
+
home,
|
|
484
|
+
configsDir = join3(DOCKERFILE_DIR, "configs")
|
|
485
|
+
}) {
|
|
486
|
+
const written = [];
|
|
487
|
+
for (const [key, client] of Object.entries(CLIENTS)) {
|
|
488
|
+
const source = join3(configsDir, key);
|
|
489
|
+
if (!existsSync(source))
|
|
490
|
+
continue;
|
|
491
|
+
const targetDir = join3(home, client.configDir.replace(/^\/home\/agent\/?/, ""));
|
|
492
|
+
for (const name of readdirSync(source)) {
|
|
493
|
+
if (!name.endsWith(".json"))
|
|
494
|
+
continue;
|
|
495
|
+
const curated = withoutComments(readJson(join3(source, name)));
|
|
496
|
+
if (Object.keys(curated).length === 0)
|
|
497
|
+
continue;
|
|
498
|
+
const target = join3(targetDir, name);
|
|
499
|
+
mkdirSync2(targetDir, { recursive: true });
|
|
500
|
+
const merged = mergeJson(readJson(target), curated);
|
|
501
|
+
writeFileSync2(target, `${JSON.stringify(merged, null, 2)}
|
|
502
|
+
`);
|
|
503
|
+
written.push(target);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return written;
|
|
507
|
+
}
|
|
508
|
+
function stageHostInstructions({
|
|
509
|
+
home,
|
|
510
|
+
staging = HOST_STAGING_DIR,
|
|
511
|
+
context
|
|
512
|
+
}) {
|
|
513
|
+
for (const directory of STAGED_DIRECTORIES) {
|
|
514
|
+
const source = join3(staging, directory);
|
|
515
|
+
if (!existsSync(source))
|
|
516
|
+
continue;
|
|
517
|
+
try {
|
|
518
|
+
cpSync(source, join3(home, directory), { recursive: true, force: true });
|
|
519
|
+
} catch {}
|
|
520
|
+
}
|
|
521
|
+
const instructions = sandboxInstructions(context);
|
|
522
|
+
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()}
|
|
528
|
+
|
|
529
|
+
${instructions}` : instructions);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function configure({
|
|
533
|
+
home = homedir2(),
|
|
534
|
+
inSandbox = existsSync(SANDBOX_MARKER),
|
|
535
|
+
env = process.env
|
|
536
|
+
} = {}) {
|
|
537
|
+
applyProviderConfigs({ home });
|
|
538
|
+
if (inSandbox) {
|
|
539
|
+
stageHostInstructions({ home, context: sandboxContextFromEnv(env) });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/doctor.ts
|
|
544
|
+
import { execSync as execSync2 } from "node:child_process";
|
|
545
|
+
|
|
546
|
+
// src/broker-client.ts
|
|
547
|
+
var DEFAULT_BROKER_URL = "http://100.64.0.2";
|
|
548
|
+
function brokerUrl(env = process.env) {
|
|
549
|
+
return env.MAESTTRO_BROKER_URL?.trim() || DEFAULT_BROKER_URL;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
class BrokerError extends Error {
|
|
553
|
+
code;
|
|
554
|
+
status;
|
|
555
|
+
constructor(code, status) {
|
|
556
|
+
super(code);
|
|
557
|
+
this.code = code;
|
|
558
|
+
this.status = status;
|
|
559
|
+
this.name = "BrokerError";
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function isSessionToken(body) {
|
|
563
|
+
const candidate = body;
|
|
564
|
+
return typeof candidate?.token === "string" && typeof candidate.expires_at === "string" && typeof candidate.committer?.name === "string" && typeof candidate.committer?.email === "string";
|
|
565
|
+
}
|
|
566
|
+
async function requestSessionToken({
|
|
567
|
+
broker,
|
|
568
|
+
userToken,
|
|
569
|
+
repo,
|
|
570
|
+
fetch: fetchImpl = fetch
|
|
571
|
+
}) {
|
|
572
|
+
let response;
|
|
573
|
+
try {
|
|
574
|
+
response = await fetchImpl(new URL("/stagehand/token", broker), {
|
|
575
|
+
method: "POST",
|
|
576
|
+
headers: {
|
|
577
|
+
Authorization: `Bearer ${userToken}`,
|
|
578
|
+
"Content-Type": "application/json"
|
|
579
|
+
},
|
|
580
|
+
body: JSON.stringify({ repo }),
|
|
581
|
+
signal: AbortSignal.timeout(1e4)
|
|
582
|
+
});
|
|
583
|
+
} catch {
|
|
584
|
+
throw new BrokerError("broker_unreachable");
|
|
585
|
+
}
|
|
586
|
+
const body = await response.json().catch(() => null);
|
|
587
|
+
if (response.ok && isSessionToken(body))
|
|
588
|
+
return body;
|
|
589
|
+
const code = body?.error;
|
|
590
|
+
throw new BrokerError(typeof code === "string" ? code : "broker_unreachable", response.status);
|
|
591
|
+
}
|
|
592
|
+
async function brokerHealthy(broker, fetchImpl = fetch, timeoutMs = 2000) {
|
|
593
|
+
try {
|
|
594
|
+
const response = await fetchImpl(new URL("/stagehand/health", broker), {
|
|
595
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
596
|
+
});
|
|
597
|
+
const body = await response.json();
|
|
598
|
+
return response.ok && body?.status === "ok";
|
|
599
|
+
} catch {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async function revokeSessionToken(token, fetchImpl = fetch, apiUrl = githubApiUrl()) {
|
|
604
|
+
try {
|
|
605
|
+
await fetchImpl(new URL("/installation/token", apiUrl), {
|
|
606
|
+
method: "DELETE",
|
|
607
|
+
headers: {
|
|
608
|
+
Accept: "application/vnd.github+json",
|
|
609
|
+
Authorization: `Bearer ${token}`,
|
|
610
|
+
"User-Agent": "maesttro"
|
|
611
|
+
},
|
|
612
|
+
signal: AbortSignal.timeout(5000)
|
|
613
|
+
});
|
|
614
|
+
} catch {}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/host-identity.ts
|
|
618
|
+
function hostIdentityEnv() {
|
|
619
|
+
if (typeof process.getuid !== "function" || typeof process.getgid !== "function") {
|
|
620
|
+
return {};
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
MAESTTRO_HOST_UID: String(process.getuid()),
|
|
624
|
+
MAESTTRO_HOST_GID: String(process.getgid())
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function hostIdentityFlags() {
|
|
628
|
+
return Object.entries(hostIdentityEnv()).map(([name, value]) => `-e ${name}=${value}`).join(" ");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/legacy-token.ts
|
|
632
|
+
import { execSync } from "node:child_process";
|
|
633
|
+
var LEGACY_HOSTS = "~/.config/gh/hosts.yml";
|
|
634
|
+
var PURGE_LEGACY_TOKEN = [
|
|
635
|
+
`if [ -e ${LEGACY_HOSTS} ]; then rm -f ${LEGACY_HOSTS}; echo FOUND; fi`,
|
|
636
|
+
"git config --global --remove-section credential.https://github.com 2>/dev/null || true",
|
|
637
|
+
"git config --global --remove-section credential.https://gist.github.com 2>/dev/null || true"
|
|
638
|
+
].join("; ");
|
|
639
|
+
var DETECT_LEGACY_TOKEN = `if [ -e ${LEGACY_HOSTS} ]; then echo FOUND; else echo CLEAN; fi`;
|
|
640
|
+
function inVolume(command) {
|
|
641
|
+
return execSync(`docker run --rm ${hostIdentityFlags()} -v ${VOLUME}:/home/agent ${IMAGE} "${command}"`).toString();
|
|
642
|
+
}
|
|
643
|
+
function purgeLegacyToken() {
|
|
644
|
+
return inVolume(PURGE_LEGACY_TOKEN).includes("FOUND");
|
|
645
|
+
}
|
|
646
|
+
function hasLegacyToken() {
|
|
647
|
+
return inVolume(DETECT_LEGACY_TOKEN).includes("FOUND");
|
|
648
|
+
}
|
|
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";
|
|
650
|
+
function purgeLegacyTokenOnce({
|
|
651
|
+
config,
|
|
652
|
+
saveConfig,
|
|
653
|
+
purge = purgeLegacyToken,
|
|
654
|
+
announce
|
|
655
|
+
}) {
|
|
656
|
+
if (config.legacyTokenPurged)
|
|
657
|
+
return;
|
|
658
|
+
if (purge())
|
|
659
|
+
announce(REVOKE_NOTICE);
|
|
660
|
+
saveConfig({ ...config, legacyTokenPurged: true });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/repo.ts
|
|
664
|
+
import { appendFileSync, mkdirSync as mkdirSync3, realpathSync } from "node:fs";
|
|
665
|
+
import { join as join4 } from "node:path";
|
|
666
|
+
var MINIMUM_GIT = [2, 48];
|
|
667
|
+
var WORKTREES_DIR = ".worktrees";
|
|
668
|
+
|
|
669
|
+
class LaunchError extends Error {
|
|
670
|
+
constructor(message) {
|
|
671
|
+
super(message);
|
|
672
|
+
this.name = "LaunchError";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function git(run, cwd, args) {
|
|
676
|
+
const result = run("git", ["-C", cwd, ...args]);
|
|
677
|
+
return { ok: result.status === 0, out: result.stdout.trim() };
|
|
678
|
+
}
|
|
679
|
+
function parseGitVersion(output) {
|
|
680
|
+
const match = output.match(/git version (\d+)\.(\d+)/);
|
|
681
|
+
return match ? [Number(match[1]), Number(match[2])] : undefined;
|
|
682
|
+
}
|
|
683
|
+
function meetsMinimumGit(version) {
|
|
684
|
+
const [major, minor] = version;
|
|
685
|
+
return major > MINIMUM_GIT[0] || major === MINIMUM_GIT[0] && minor >= MINIMUM_GIT[1];
|
|
686
|
+
}
|
|
687
|
+
function requireGit(run) {
|
|
688
|
+
const version = parseGitVersion(run("git", ["--version"]).stdout);
|
|
689
|
+
const minimum = MINIMUM_GIT.join(".");
|
|
690
|
+
if (!version) {
|
|
691
|
+
throw new LaunchError(`git not found. maesttro needs git ${minimum} or newer.`);
|
|
692
|
+
}
|
|
693
|
+
if (!meetsMinimumGit(version)) {
|
|
694
|
+
throw new LaunchError(`git ${version.join(".")} is too old. maesttro needs git ${minimum} or newer; please upgrade.`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function repoRoot(run, cwd) {
|
|
698
|
+
const top = git(run, cwd, ["rev-parse", "--show-toplevel"]);
|
|
699
|
+
if (!top.ok) {
|
|
700
|
+
throw new LaunchError("Run maesttro from the root of a git repository.");
|
|
701
|
+
}
|
|
702
|
+
const commonDir = git(run, cwd, [
|
|
703
|
+
"rev-parse",
|
|
704
|
+
"--path-format=absolute",
|
|
705
|
+
"--git-common-dir"
|
|
706
|
+
]).out;
|
|
707
|
+
const root = realpathSync(top.out);
|
|
708
|
+
if (realpathSync(commonDir) !== join4(root, ".git")) {
|
|
709
|
+
throw new LaunchError("This is a git worktree. Run maesttro from the main checkout; each session gets its own isolated clone.");
|
|
710
|
+
}
|
|
711
|
+
if (realpathSync(cwd) !== root) {
|
|
712
|
+
throw new LaunchError(`Run maesttro from the repository root: ${root}`);
|
|
713
|
+
}
|
|
714
|
+
return root;
|
|
715
|
+
}
|
|
716
|
+
function fetchOrigin(run, root) {
|
|
717
|
+
return git(run, root, ["fetch", "--quiet", "origin"]).ok;
|
|
718
|
+
}
|
|
719
|
+
function defaultBranch(run, root) {
|
|
720
|
+
const head = git(run, root, [
|
|
721
|
+
"symbolic-ref",
|
|
722
|
+
"--short",
|
|
723
|
+
"refs/remotes/origin/HEAD"
|
|
724
|
+
]);
|
|
725
|
+
if (head.ok && head.out)
|
|
726
|
+
return head.out;
|
|
727
|
+
for (const candidate of ["origin/main", "origin/master"]) {
|
|
728
|
+
if (git(run, root, ["rev-parse", "--verify", "--quiet", candidate]).ok) {
|
|
729
|
+
return candidate;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
throw new LaunchError("Can't find the default branch of origin. Run `git remote set-head origin --auto`.");
|
|
733
|
+
}
|
|
734
|
+
function ensureWorktreesDir(run, root) {
|
|
735
|
+
mkdirSync3(join4(root, WORKTREES_DIR), { recursive: true });
|
|
736
|
+
if (git(run, root, ["check-ignore", "-q", `${WORKTREES_DIR}/`]).ok)
|
|
737
|
+
return;
|
|
738
|
+
const info = join4(root, ".git", "info");
|
|
739
|
+
mkdirSync3(info, { recursive: true });
|
|
740
|
+
appendFileSync(join4(info, "exclude"), `
|
|
741
|
+
${WORKTREES_DIR}/
|
|
742
|
+
`);
|
|
743
|
+
}
|
|
744
|
+
function snapshotRefs(run, root) {
|
|
745
|
+
const listed = git(run, root, [
|
|
746
|
+
"for-each-ref",
|
|
747
|
+
"--format=%(refname) %(objectname)",
|
|
748
|
+
"refs/heads",
|
|
749
|
+
"refs/tags"
|
|
750
|
+
]);
|
|
751
|
+
const refs = {};
|
|
752
|
+
for (const line of listed.out.split(`
|
|
753
|
+
`)) {
|
|
754
|
+
const [name, sha] = line.split(" ");
|
|
755
|
+
if (name && sha)
|
|
756
|
+
refs[name] = sha;
|
|
757
|
+
}
|
|
758
|
+
return refs;
|
|
759
|
+
}
|
|
760
|
+
function revision(run, root, rev) {
|
|
761
|
+
const parsed = git(run, root, [
|
|
762
|
+
"rev-parse",
|
|
763
|
+
"--verify",
|
|
764
|
+
"--quiet",
|
|
765
|
+
`${rev}^{commit}`
|
|
766
|
+
]);
|
|
767
|
+
return parsed.ok ? parsed.out : undefined;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/doctor.ts
|
|
771
|
+
async function check(label, probe) {
|
|
772
|
+
try {
|
|
773
|
+
const detail = await probe();
|
|
774
|
+
console.log(` [ok] ${label}${detail ? ` — ${detail}` : ""}`);
|
|
775
|
+
return true;
|
|
776
|
+
} catch (error) {
|
|
777
|
+
const reason = error instanceof Error && error.message ? error.message : "";
|
|
778
|
+
console.log(` [fail] ${label}${reason ? ` — ${reason}` : ""}`);
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function inSandbox(command, { withVolume = true } = {}) {
|
|
783
|
+
const volume = withVolume ? `-v ${VOLUME}:/home/agent ` : "";
|
|
784
|
+
return execSync2(`docker run --rm ${hostIdentityFlags()} ${volume}${IMAGE} "${command}"`).toString();
|
|
785
|
+
}
|
|
786
|
+
async function doctor() {
|
|
787
|
+
console.log(`== maesttro doctor ==
|
|
788
|
+
`);
|
|
789
|
+
const broker = brokerUrl();
|
|
790
|
+
const checks = [
|
|
791
|
+
[
|
|
792
|
+
"Docker daemon reachable",
|
|
793
|
+
() => {
|
|
794
|
+
execSync2("docker info", { stdio: "ignore" });
|
|
795
|
+
}
|
|
796
|
+
],
|
|
797
|
+
[
|
|
798
|
+
"git on this machine is new enough",
|
|
799
|
+
() => {
|
|
800
|
+
requireGit(runCommand);
|
|
801
|
+
return runCommand("git", ["--version"]).stdout.trim();
|
|
802
|
+
}
|
|
803
|
+
],
|
|
804
|
+
[
|
|
805
|
+
"Sandbox image built",
|
|
806
|
+
() => `built ${execSync2(`docker image inspect ${IMAGE} --format "{{.Created}}"`).toString().trim()}`
|
|
807
|
+
],
|
|
808
|
+
[
|
|
809
|
+
"Persistent volume exists",
|
|
810
|
+
() => {
|
|
811
|
+
execSync2(`docker volume inspect ${VOLUME}`, { stdio: "ignore" });
|
|
812
|
+
}
|
|
813
|
+
],
|
|
814
|
+
[
|
|
815
|
+
"No long-lived GitHub token in the volume",
|
|
816
|
+
() => {
|
|
817
|
+
if (hasLegacyToken())
|
|
818
|
+
throw new Error("run `maesttro setup` to remove it");
|
|
819
|
+
}
|
|
820
|
+
],
|
|
821
|
+
[
|
|
822
|
+
"Logged in to GitHub",
|
|
823
|
+
async () => {
|
|
824
|
+
const token = await currentUserToken({
|
|
825
|
+
clientId: githubAppClientId(),
|
|
826
|
+
store: selectCredentialStore(),
|
|
827
|
+
announce: () => {},
|
|
828
|
+
interactive: false
|
|
829
|
+
});
|
|
830
|
+
if (!token)
|
|
831
|
+
throw new Error("run `maesttro login`");
|
|
832
|
+
const name = await githubLogin(token.access_token);
|
|
833
|
+
if (!name)
|
|
834
|
+
throw new Error("token rejected, run `maesttro login`");
|
|
835
|
+
return name;
|
|
836
|
+
}
|
|
837
|
+
],
|
|
838
|
+
[
|
|
839
|
+
"Token broker reachable",
|
|
840
|
+
async () => {
|
|
841
|
+
if (!await brokerHealthy(broker)) {
|
|
842
|
+
throw new Error(`${broker} (on the tailnet?)`);
|
|
843
|
+
}
|
|
844
|
+
return broker;
|
|
845
|
+
}
|
|
846
|
+
],
|
|
847
|
+
[
|
|
848
|
+
"Personal SSH key NOT visible inside sandbox (isolation intact)",
|
|
849
|
+
() => {
|
|
850
|
+
const out = inSandbox("cat ~/.ssh/id_rsa 2>&1 || echo MISSING");
|
|
851
|
+
if (!out.includes("MISSING") && !out.includes("No such file")) {
|
|
852
|
+
throw new Error("isolation broken");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
],
|
|
856
|
+
...Object.values(CLIENTS).map((client) => [
|
|
857
|
+
`Client available: ${client.label}`,
|
|
858
|
+
() => {
|
|
859
|
+
inSandbox(`which ${client.binary}`, { withVolume: false });
|
|
860
|
+
}
|
|
861
|
+
])
|
|
862
|
+
];
|
|
863
|
+
const results = [];
|
|
864
|
+
for (const [label, probe] of checks)
|
|
865
|
+
results.push(await check(label, probe));
|
|
866
|
+
console.log();
|
|
867
|
+
if (results.every(Boolean)) {
|
|
868
|
+
console.log("All checks passed.");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
console.log("Some checks failed — run `maesttro setup` or `maesttro update` to fix, or check the failing item above.");
|
|
872
|
+
process.exit(1);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/help.ts
|
|
876
|
+
var HELP = `maesttro — isolated sandbox for local coding agents
|
|
877
|
+
|
|
878
|
+
Usage:
|
|
879
|
+
maesttro Start a session from the repo root: new clone, default client (claude)
|
|
880
|
+
maesttro <client> Same, with a specific client — see \`maesttro list\`
|
|
881
|
+
maesttro resume <id> Continue a kept session in its clone
|
|
882
|
+
maesttro sessions List this repo's sessions (\`sessions remove <id>\` to drop one)
|
|
883
|
+
maesttro push [branch] Push a branch a session created, after showing what goes out
|
|
884
|
+
maesttro list Show available clients
|
|
885
|
+
maesttro setup One-time: build image, create volume, log in to GitHub
|
|
886
|
+
maesttro login Log in to GitHub (device flow) so sessions get repo access
|
|
887
|
+
maesttro logout Remove the stored GitHub credentials
|
|
888
|
+
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
|
|
891
|
+
maesttro doctor Health check — Docker, image, volume, GitHub login, broker, isolation, clients
|
|
892
|
+
maesttro help Show this message
|
|
893
|
+
`;
|
|
894
|
+
|
|
895
|
+
// src/launch.ts
|
|
896
|
+
import { execSync as execSync3, spawn } from "node:child_process";
|
|
897
|
+
import { existsSync as existsSync3, rmSync as rmSync5 } from "node:fs";
|
|
898
|
+
import { constants, homedir as homedir4 } from "node:os";
|
|
899
|
+
import { join as join10 } from "node:path";
|
|
900
|
+
|
|
901
|
+
// src/backup.ts
|
|
902
|
+
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";
|
|
905
|
+
|
|
906
|
+
// src/maesttro-home.ts
|
|
907
|
+
import {
|
|
908
|
+
appendFileSync as appendFileSync2,
|
|
909
|
+
mkdirSync as mkdirSync4,
|
|
910
|
+
readFileSync as readFileSync3,
|
|
911
|
+
renameSync as renameSync2,
|
|
912
|
+
writeFileSync as writeFileSync3
|
|
913
|
+
} from "node:fs";
|
|
914
|
+
import { homedir as homedir3 } from "node:os";
|
|
915
|
+
import { join as join5 } from "node:path";
|
|
916
|
+
function maesttroHome() {
|
|
917
|
+
return join5(homedir3(), ".config", "maesttro");
|
|
918
|
+
}
|
|
919
|
+
function configPath() {
|
|
920
|
+
return join5(maesttroHome(), "config.json");
|
|
921
|
+
}
|
|
922
|
+
function readMaesttroConfig() {
|
|
923
|
+
try {
|
|
924
|
+
return JSON.parse(readFileSync3(configPath(), "utf8"));
|
|
925
|
+
} catch {
|
|
926
|
+
return {};
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function writeMaesttroConfig(config) {
|
|
930
|
+
mkdirSync4(maesttroHome(), { recursive: true, mode: 448 });
|
|
931
|
+
const temporary = `${configPath()}.tmp`;
|
|
932
|
+
writeFileSync3(temporary, `${JSON.stringify(config, null, 2)}
|
|
933
|
+
`);
|
|
934
|
+
renameSync2(temporary, configPath());
|
|
935
|
+
}
|
|
936
|
+
function fileSessionLog(path = join5(maesttroHome(), "maesttro.log")) {
|
|
937
|
+
return (event, detail) => {
|
|
938
|
+
try {
|
|
939
|
+
mkdirSync4(maesttroHome(), { recursive: true, mode: 448 });
|
|
940
|
+
appendFileSync2(path, `${new Date().toISOString()} ${event}${detail ? ` ${detail}` : ""}
|
|
941
|
+
`);
|
|
942
|
+
} catch {}
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/backup.ts
|
|
947
|
+
var BACKUPS_KEPT = 5;
|
|
948
|
+
function shortHash(value) {
|
|
949
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
950
|
+
}
|
|
951
|
+
function backupDir(repoRoot, home = maesttroHome()) {
|
|
952
|
+
return join6(home, "backups", `${basename(repoRoot)}-${shortHash(repoRoot)}`);
|
|
953
|
+
}
|
|
954
|
+
function refsFingerprint(refs) {
|
|
955
|
+
const sorted = Object.keys(refs).sort().map((ref) => `${ref} ${refs[ref]}`);
|
|
956
|
+
return shortHash(sorted.join(`
|
|
957
|
+
`));
|
|
958
|
+
}
|
|
959
|
+
function backupRepo({
|
|
960
|
+
run,
|
|
961
|
+
repoRoot,
|
|
962
|
+
refs,
|
|
963
|
+
sessionId,
|
|
964
|
+
dir = backupDir(repoRoot),
|
|
965
|
+
now = new Date,
|
|
966
|
+
keep = BACKUPS_KEPT
|
|
967
|
+
}) {
|
|
968
|
+
if (Object.keys(refs).length === 0)
|
|
969
|
+
return;
|
|
970
|
+
mkdirSync5(dir, { recursive: true, mode: 448 });
|
|
971
|
+
const fingerprint = refsFingerprint(refs);
|
|
972
|
+
const existing = readdirSync2(dir).filter((name) => name.endsWith(".bundle")).sort();
|
|
973
|
+
const latest = existing.at(-1);
|
|
974
|
+
if (latest?.endsWith(`-${fingerprint}.bundle`)) {
|
|
975
|
+
return { path: join6(dir, latest), reused: true };
|
|
976
|
+
}
|
|
977
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
978
|
+
const path = join6(dir, `${stamp}-${sessionId}-${fingerprint}.bundle`);
|
|
979
|
+
if (!git(run, repoRoot, ["bundle", "create", "--quiet", path, "--all"]).ok) {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
for (const stale of [...existing, basename(path)].sort().slice(0, -keep)) {
|
|
983
|
+
rmSync2(join6(dir, stale), { force: true });
|
|
984
|
+
}
|
|
985
|
+
return { path, reused: false };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/refresher.ts
|
|
989
|
+
var REFRESH_LEAD_MS = 10 * 60 * 1000;
|
|
990
|
+
var WALL_CLOCK_TICK_MS = 60 * 1000;
|
|
991
|
+
var FIRST_BACKOFF_MS = 5 * 1000;
|
|
992
|
+
var MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
993
|
+
var nodeTimers = {
|
|
994
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms).unref(),
|
|
995
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
996
|
+
setInterval: (callback, ms) => setInterval(callback, ms).unref(),
|
|
997
|
+
clearInterval: (handle) => clearInterval(handle)
|
|
998
|
+
};
|
|
999
|
+
function startRefresher({
|
|
1000
|
+
initial,
|
|
1001
|
+
refresh,
|
|
1002
|
+
onRefreshed,
|
|
1003
|
+
onFailed,
|
|
1004
|
+
now = Date.now,
|
|
1005
|
+
timers = nodeTimers
|
|
1006
|
+
}) {
|
|
1007
|
+
let current = initial;
|
|
1008
|
+
let nextAttemptAt = 0;
|
|
1009
|
+
let backoffMs = FIRST_BACKOFF_MS;
|
|
1010
|
+
let inFlight;
|
|
1011
|
+
let stopped = false;
|
|
1012
|
+
let pending;
|
|
1013
|
+
function refreshDueAt(token) {
|
|
1014
|
+
const expiresAt = Date.parse(token.expires_at);
|
|
1015
|
+
const halfRemaining = (expiresAt - now()) / 2;
|
|
1016
|
+
return expiresAt - Math.min(REFRESH_LEAD_MS, Math.max(halfRemaining, 0));
|
|
1017
|
+
}
|
|
1018
|
+
nextAttemptAt = refreshDueAt(initial);
|
|
1019
|
+
function scheduleAt(at) {
|
|
1020
|
+
timers.clearTimeout(pending);
|
|
1021
|
+
pending = timers.setTimeout(check, Math.max(at - now(), 0));
|
|
1022
|
+
}
|
|
1023
|
+
async function attempt() {
|
|
1024
|
+
try {
|
|
1025
|
+
const token = await refresh();
|
|
1026
|
+
if (stopped)
|
|
1027
|
+
return;
|
|
1028
|
+
current = token;
|
|
1029
|
+
backoffMs = FIRST_BACKOFF_MS;
|
|
1030
|
+
nextAttemptAt = refreshDueAt(token);
|
|
1031
|
+
onRefreshed(token);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (stopped)
|
|
1034
|
+
return;
|
|
1035
|
+
nextAttemptAt = now() + backoffMs;
|
|
1036
|
+
onFailed(error, backoffMs);
|
|
1037
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
1038
|
+
}
|
|
1039
|
+
scheduleAt(nextAttemptAt);
|
|
1040
|
+
}
|
|
1041
|
+
function check() {
|
|
1042
|
+
if (stopped)
|
|
1043
|
+
return Promise.resolve();
|
|
1044
|
+
if (inFlight)
|
|
1045
|
+
return inFlight;
|
|
1046
|
+
if (now() < nextAttemptAt)
|
|
1047
|
+
return Promise.resolve();
|
|
1048
|
+
inFlight = attempt().finally(() => {
|
|
1049
|
+
inFlight = undefined;
|
|
1050
|
+
});
|
|
1051
|
+
return inFlight;
|
|
1052
|
+
}
|
|
1053
|
+
const tick = timers.setInterval(check, WALL_CLOCK_TICK_MS);
|
|
1054
|
+
scheduleAt(nextAttemptAt);
|
|
1055
|
+
return {
|
|
1056
|
+
current: () => current,
|
|
1057
|
+
check,
|
|
1058
|
+
stop: () => {
|
|
1059
|
+
stopped = true;
|
|
1060
|
+
timers.clearTimeout(pending);
|
|
1061
|
+
timers.clearInterval(tick);
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// src/session-dir.ts
|
|
1067
|
+
import {
|
|
1068
|
+
chmodSync as chmodSync2,
|
|
1069
|
+
mkdirSync as mkdirSync6,
|
|
1070
|
+
mkdtempSync,
|
|
1071
|
+
renameSync as renameSync3,
|
|
1072
|
+
rmSync as rmSync3,
|
|
1073
|
+
writeFileSync as writeFileSync4
|
|
1074
|
+
} from "node:fs";
|
|
1075
|
+
import { tmpdir } from "node:os";
|
|
1076
|
+
import { join as join7 } from "node:path";
|
|
1077
|
+
var CONTAINER_GH_CONFIG_DIR = "/home/agent/.config/gh-maesttro";
|
|
1078
|
+
function writeAtomically(path, content) {
|
|
1079
|
+
const temporary = `${path}.tmp`;
|
|
1080
|
+
writeFileSync4(temporary, content, { mode: 420 });
|
|
1081
|
+
chmodSync2(temporary, 420);
|
|
1082
|
+
renameSync3(temporary, path);
|
|
1083
|
+
}
|
|
1084
|
+
function hostsYaml(token, user) {
|
|
1085
|
+
return [
|
|
1086
|
+
"github.com:",
|
|
1087
|
+
` oauth_token: ${JSON.stringify(token)}`,
|
|
1088
|
+
` user: ${JSON.stringify(user)}`,
|
|
1089
|
+
" git_protocol: https",
|
|
1090
|
+
""
|
|
1091
|
+
].join(`
|
|
1092
|
+
`);
|
|
1093
|
+
}
|
|
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"
|
|
1101
|
+
`);
|
|
1102
|
+
return {
|
|
1103
|
+
ghDir,
|
|
1104
|
+
writeToken: (token, user) => writeAtomically(join7(ghDir, "hosts.yml"), hostsYaml(token, user)),
|
|
1105
|
+
writeStatus: (status) => writeAtomically(join7(ghDir, "status"), `${status}
|
|
1106
|
+
`),
|
|
1107
|
+
remove: () => rmSync3(root, { recursive: true, force: true })
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/github-session.ts
|
|
1112
|
+
var UNAVAILABLE = "GitHub access unavailable";
|
|
1113
|
+
var WITHOUT = "running without gh";
|
|
1114
|
+
function refusalNotice(code, repo) {
|
|
1115
|
+
switch (code) {
|
|
1116
|
+
case "broker_unreachable":
|
|
1117
|
+
return `${UNAVAILABLE} (broker unreachable) — ${WITHOUT}`;
|
|
1118
|
+
case "app_not_installed":
|
|
1119
|
+
return `${UNAVAILABLE}: the GitHub App is not installed on ${repo} — ${WITHOUT}`;
|
|
1120
|
+
case "repo_not_visible_to_user":
|
|
1121
|
+
return `${UNAVAILABLE}: your GitHub account cannot see ${repo} — ${WITHOUT}`;
|
|
1122
|
+
case "user_token_invalid":
|
|
1123
|
+
case "not_logged_in":
|
|
1124
|
+
return `${UNAVAILABLE}: GitHub login expired, run \`maesttro login\` — ${WITHOUT}`;
|
|
1125
|
+
default:
|
|
1126
|
+
return `${UNAVAILABLE} (${code}) — ${WITHOUT}`;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function identityEnv(author, committer) {
|
|
1130
|
+
const env = {};
|
|
1131
|
+
if (author) {
|
|
1132
|
+
env.GIT_AUTHOR_NAME = author.name;
|
|
1133
|
+
env.GIT_AUTHOR_EMAIL = author.email;
|
|
1134
|
+
}
|
|
1135
|
+
const effectiveCommitter = committer ?? author;
|
|
1136
|
+
if (effectiveCommitter) {
|
|
1137
|
+
env.GIT_COMMITTER_NAME = effectiveCommitter.name;
|
|
1138
|
+
env.GIT_COMMITTER_EMAIL = effectiveCommitter.email;
|
|
1139
|
+
}
|
|
1140
|
+
return env;
|
|
1141
|
+
}
|
|
1142
|
+
function withEnvFlags(dockerArgs, env) {
|
|
1143
|
+
return [...dockerArgs, ...Object.keys(env).flatMap((name) => ["-e", name])];
|
|
1144
|
+
}
|
|
1145
|
+
function errorCode(error) {
|
|
1146
|
+
if (error instanceof BrokerError)
|
|
1147
|
+
return error.code;
|
|
1148
|
+
if (error instanceof Error && error.name === "DeviceFlowError") {
|
|
1149
|
+
return "not_logged_in";
|
|
1150
|
+
}
|
|
1151
|
+
return error instanceof Error && error.message === "not_logged_in" ? "not_logged_in" : "unexpected_error";
|
|
1152
|
+
}
|
|
1153
|
+
function withoutGitHub(author, notice) {
|
|
1154
|
+
const env = {
|
|
1155
|
+
GH_CONFIG_DIR: CONTAINER_GH_CONFIG_DIR,
|
|
1156
|
+
...identityEnv(author, undefined)
|
|
1157
|
+
};
|
|
1158
|
+
return {
|
|
1159
|
+
dockerArgs: withEnvFlags([], env),
|
|
1160
|
+
env,
|
|
1161
|
+
notice,
|
|
1162
|
+
close: async () => {}
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
async function openGitHubSession({
|
|
1166
|
+
repo,
|
|
1167
|
+
author,
|
|
1168
|
+
broker,
|
|
1169
|
+
login,
|
|
1170
|
+
log,
|
|
1171
|
+
fetch: fetchImpl = fetch,
|
|
1172
|
+
createDir = createSessionDir
|
|
1173
|
+
}) {
|
|
1174
|
+
if (!repo)
|
|
1175
|
+
return withoutGitHub(author);
|
|
1176
|
+
const mint = async ({ interactive }) => {
|
|
1177
|
+
if (!await brokerHealthy(broker, fetchImpl)) {
|
|
1178
|
+
throw new BrokerError("broker_unreachable");
|
|
1179
|
+
}
|
|
1180
|
+
const userToken = await currentUserToken({ ...login, interactive });
|
|
1181
|
+
if (!userToken)
|
|
1182
|
+
throw new Error("not_logged_in");
|
|
1183
|
+
return requestSessionToken({
|
|
1184
|
+
broker,
|
|
1185
|
+
userToken: userToken.access_token,
|
|
1186
|
+
repo,
|
|
1187
|
+
fetch: fetchImpl
|
|
1188
|
+
});
|
|
1189
|
+
};
|
|
1190
|
+
let initial;
|
|
1191
|
+
try {
|
|
1192
|
+
initial = await mint({ interactive: true });
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
const code = errorCode(error);
|
|
1195
|
+
log("session.github_unavailable", `${repo} ${code}`);
|
|
1196
|
+
return withoutGitHub(author, refusalNotice(code, repo));
|
|
1197
|
+
}
|
|
1198
|
+
const dir = createDir();
|
|
1199
|
+
const publish = (token) => {
|
|
1200
|
+
dir.writeToken(token.token, token.committer.name);
|
|
1201
|
+
dir.writeStatus(`ok, token expires ${token.expires_at}`);
|
|
1202
|
+
};
|
|
1203
|
+
publish(initial);
|
|
1204
|
+
log("session.token_minted", `${repo} expires ${initial.expires_at}`);
|
|
1205
|
+
const refresher = startRefresher({
|
|
1206
|
+
initial,
|
|
1207
|
+
refresh: () => mint({ interactive: false }),
|
|
1208
|
+
onRefreshed: (token) => {
|
|
1209
|
+
publish(token);
|
|
1210
|
+
log("session.token_refreshed", `${repo} expires ${token.expires_at}`);
|
|
1211
|
+
},
|
|
1212
|
+
onFailed: (error, retryInMs) => {
|
|
1213
|
+
const code = errorCode(error);
|
|
1214
|
+
dir.writeStatus(`refresh failing (${code}), retrying in ${Math.round(retryInMs / 1000)}s`);
|
|
1215
|
+
log("session.refresh_failed", `${repo} ${code}`);
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
const env = {
|
|
1219
|
+
GH_CONFIG_DIR: CONTAINER_GH_CONFIG_DIR,
|
|
1220
|
+
...identityEnv(author, initial.committer)
|
|
1221
|
+
};
|
|
1222
|
+
return {
|
|
1223
|
+
dockerArgs: withEnvFlags(["-v", `${dir.ghDir}:${CONTAINER_GH_CONFIG_DIR}:ro`], env),
|
|
1224
|
+
env,
|
|
1225
|
+
close: async () => {
|
|
1226
|
+
refresher.stop();
|
|
1227
|
+
await revokeSessionToken(refresher.current().token, fetchImpl);
|
|
1228
|
+
dir.remove();
|
|
1229
|
+
log("session.closed", repo);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/prompt.ts
|
|
1235
|
+
import { createInterface } from "node:readline";
|
|
1236
|
+
function ask(question) {
|
|
1237
|
+
const readline = createInterface({
|
|
1238
|
+
input: process.stdin,
|
|
1239
|
+
output: process.stdout
|
|
1240
|
+
});
|
|
1241
|
+
return new Promise((resolve) => readline.question(question, (answer) => {
|
|
1242
|
+
readline.close();
|
|
1243
|
+
resolve(answer.trim());
|
|
1244
|
+
}));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/session-clone.ts
|
|
1248
|
+
import {
|
|
1249
|
+
copyFileSync,
|
|
1250
|
+
existsSync as existsSync2,
|
|
1251
|
+
lstatSync,
|
|
1252
|
+
mkdirSync as mkdirSync7,
|
|
1253
|
+
readdirSync as readdirSync3,
|
|
1254
|
+
readFileSync as readFileSync4
|
|
1255
|
+
} from "node:fs";
|
|
1256
|
+
import { join as join8 } from "node:path";
|
|
1257
|
+
|
|
1258
|
+
// src/sanitize.ts
|
|
1259
|
+
function stripControl(text) {
|
|
1260
|
+
let out = "";
|
|
1261
|
+
for (const char of text) {
|
|
1262
|
+
const code = char.codePointAt(0) ?? 0;
|
|
1263
|
+
if (code >= 32 && code !== 127)
|
|
1264
|
+
out += char;
|
|
1265
|
+
}
|
|
1266
|
+
return out;
|
|
1267
|
+
}
|
|
1268
|
+
var VALID_BRANCH = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9_-])?$/;
|
|
1269
|
+
function isSafeBranchName(name) {
|
|
1270
|
+
if (name.length === 0 || name.length > 255)
|
|
1271
|
+
return false;
|
|
1272
|
+
if (!VALID_BRANCH.test(name))
|
|
1273
|
+
return false;
|
|
1274
|
+
if (name.includes(".."))
|
|
1275
|
+
return false;
|
|
1276
|
+
if (name.includes("//"))
|
|
1277
|
+
return false;
|
|
1278
|
+
if (name.includes("@{"))
|
|
1279
|
+
return false;
|
|
1280
|
+
if (name.endsWith(".lock"))
|
|
1281
|
+
return false;
|
|
1282
|
+
for (const segment of name.split("/")) {
|
|
1283
|
+
if (segment.length === 0 || segment.startsWith(".") || segment.endsWith(".")) {
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// src/session-clone.ts
|
|
1291
|
+
var HARDENING = [
|
|
1292
|
+
"-c",
|
|
1293
|
+
"core.fsmonitor=",
|
|
1294
|
+
"-c",
|
|
1295
|
+
"core.hooksPath=/dev/null",
|
|
1296
|
+
"-c",
|
|
1297
|
+
"core.pager=cat",
|
|
1298
|
+
"-c",
|
|
1299
|
+
"protocol.file.allow=never"
|
|
1300
|
+
];
|
|
1301
|
+
var SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
1302
|
+
var OBJECT_SUBDIR = /^[0-9a-f]{2}$/;
|
|
1303
|
+
var HEADS_PREFIX = "refs/heads/";
|
|
1304
|
+
function agentGit(run, dir, args) {
|
|
1305
|
+
return git(run, dir, [...HARDENING, ...args]);
|
|
1306
|
+
}
|
|
1307
|
+
function createSessionClone(run, root, clonePath, baseSha) {
|
|
1308
|
+
const cloned = git(run, root, [
|
|
1309
|
+
"clone",
|
|
1310
|
+
"--quiet",
|
|
1311
|
+
"--shared",
|
|
1312
|
+
"--no-checkout",
|
|
1313
|
+
root,
|
|
1314
|
+
clonePath
|
|
1315
|
+
]);
|
|
1316
|
+
if (!cloned.ok) {
|
|
1317
|
+
throw new LaunchError(`Could not create the session clone at ${clonePath}`);
|
|
1318
|
+
}
|
|
1319
|
+
const heads = agentGit(run, clonePath, [
|
|
1320
|
+
"for-each-ref",
|
|
1321
|
+
"--format=delete %(refname)",
|
|
1322
|
+
"refs/heads"
|
|
1323
|
+
]);
|
|
1324
|
+
if (heads.out) {
|
|
1325
|
+
run("git", ["-C", clonePath, "update-ref", "--stdin"], `${heads.out}
|
|
1326
|
+
`);
|
|
1327
|
+
}
|
|
1328
|
+
const detached = agentGit(run, clonePath, ["checkout", "--detach", baseSha]);
|
|
1329
|
+
if (!detached.ok) {
|
|
1330
|
+
throw new LaunchError(`Could not check out ${baseSha} in the session clone`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
function realDir(path) {
|
|
1334
|
+
try {
|
|
1335
|
+
return lstatSync(path).isDirectory();
|
|
1336
|
+
} catch {
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
function dirents(dir) {
|
|
1341
|
+
try {
|
|
1342
|
+
return readdirSync3(dir, { withFileTypes: true });
|
|
1343
|
+
} catch {
|
|
1344
|
+
return [];
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
function cloneGitDir(clonePath) {
|
|
1348
|
+
const gitDir = join8(clonePath, ".git");
|
|
1349
|
+
return realDir(gitDir) ? gitDir : undefined;
|
|
1350
|
+
}
|
|
1351
|
+
function readSha(path) {
|
|
1352
|
+
try {
|
|
1353
|
+
if (!lstatSync(path).isFile())
|
|
1354
|
+
return;
|
|
1355
|
+
const text = readFileSync4(path, "utf8").trim();
|
|
1356
|
+
return SHA.test(text) ? text : undefined;
|
|
1357
|
+
} catch {
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
function packedRefs(gitDir) {
|
|
1362
|
+
const refs = new Map;
|
|
1363
|
+
let text;
|
|
1364
|
+
try {
|
|
1365
|
+
text = readFileSync4(join8(gitDir, "packed-refs"), "utf8");
|
|
1366
|
+
} catch {
|
|
1367
|
+
return refs;
|
|
1368
|
+
}
|
|
1369
|
+
for (const line of text.split(`
|
|
1370
|
+
`)) {
|
|
1371
|
+
if (!line || line.startsWith("#") || line.startsWith("^"))
|
|
1372
|
+
continue;
|
|
1373
|
+
const [sha, ref] = line.split(" ");
|
|
1374
|
+
if (sha && ref && SHA.test(sha))
|
|
1375
|
+
refs.set(ref, sha);
|
|
1376
|
+
}
|
|
1377
|
+
return refs;
|
|
1378
|
+
}
|
|
1379
|
+
function cloneBranches(clonePath) {
|
|
1380
|
+
const gitDir = cloneGitDir(clonePath);
|
|
1381
|
+
if (!gitDir)
|
|
1382
|
+
return [];
|
|
1383
|
+
const names = new Set;
|
|
1384
|
+
const walk = (dir, prefix) => {
|
|
1385
|
+
for (const entry of dirents(dir)) {
|
|
1386
|
+
if (entry.isSymbolicLink())
|
|
1387
|
+
continue;
|
|
1388
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1389
|
+
if (entry.isDirectory())
|
|
1390
|
+
walk(join8(dir, entry.name), name);
|
|
1391
|
+
else if (entry.isFile())
|
|
1392
|
+
names.add(name);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
walk(join8(gitDir, "refs", "heads"), "");
|
|
1396
|
+
for (const ref of packedRefs(gitDir).keys()) {
|
|
1397
|
+
if (ref.startsWith(HEADS_PREFIX))
|
|
1398
|
+
names.add(ref.slice(HEADS_PREFIX.length));
|
|
1399
|
+
}
|
|
1400
|
+
return [...names].filter(isSafeBranchName).sort();
|
|
1401
|
+
}
|
|
1402
|
+
function branchTip(clonePath, branch) {
|
|
1403
|
+
if (!isSafeBranchName(branch))
|
|
1404
|
+
return;
|
|
1405
|
+
const gitDir = cloneGitDir(clonePath);
|
|
1406
|
+
if (!gitDir)
|
|
1407
|
+
return;
|
|
1408
|
+
return readSha(join8(gitDir, "refs", "heads", branch)) ?? packedRefs(gitDir).get(`${HEADS_PREFIX}${branch}`);
|
|
1409
|
+
}
|
|
1410
|
+
function cloneHead(clonePath) {
|
|
1411
|
+
const gitDir = cloneGitDir(clonePath);
|
|
1412
|
+
if (!gitDir)
|
|
1413
|
+
return;
|
|
1414
|
+
let text;
|
|
1415
|
+
try {
|
|
1416
|
+
if (!lstatSync(join8(gitDir, "HEAD")).isFile())
|
|
1417
|
+
return;
|
|
1418
|
+
text = readFileSync4(join8(gitDir, "HEAD"), "utf8").trim();
|
|
1419
|
+
} catch {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
if (text.startsWith("ref:")) {
|
|
1423
|
+
const ref = text.slice(4).trim();
|
|
1424
|
+
return ref.startsWith(HEADS_PREFIX) ? { branch: ref.slice(HEADS_PREFIX.length) } : {};
|
|
1425
|
+
}
|
|
1426
|
+
return SHA.test(text) ? { detached: text } : {};
|
|
1427
|
+
}
|
|
1428
|
+
function cloneHasWork(clonePath, baseSha) {
|
|
1429
|
+
if (cloneBranches(clonePath).length > 0)
|
|
1430
|
+
return true;
|
|
1431
|
+
const head = cloneHead(clonePath);
|
|
1432
|
+
return head?.detached !== undefined && head.detached !== baseSha;
|
|
1433
|
+
}
|
|
1434
|
+
function copyObjectFiles(fromDir, intoDir) {
|
|
1435
|
+
let created = false;
|
|
1436
|
+
for (const entry of dirents(fromDir)) {
|
|
1437
|
+
if (!entry.isFile())
|
|
1438
|
+
continue;
|
|
1439
|
+
const dest = join8(intoDir, entry.name);
|
|
1440
|
+
if (existsSync2(dest))
|
|
1441
|
+
continue;
|
|
1442
|
+
if (!created) {
|
|
1443
|
+
mkdirSync7(intoDir, { recursive: true });
|
|
1444
|
+
created = true;
|
|
1445
|
+
}
|
|
1446
|
+
copyFileSync(join8(fromDir, entry.name), dest);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
function importObjects(run, root, clonePath, tip) {
|
|
1450
|
+
const gitDir = cloneGitDir(clonePath);
|
|
1451
|
+
if (!gitDir)
|
|
1452
|
+
return false;
|
|
1453
|
+
const objects = join8(gitDir, "objects");
|
|
1454
|
+
if (!realDir(objects))
|
|
1455
|
+
return false;
|
|
1456
|
+
const into = join8(root, ".git", "objects");
|
|
1457
|
+
for (const entry of dirents(objects)) {
|
|
1458
|
+
if (entry.name === "info" || entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (!OBJECT_SUBDIR.test(entry.name) && entry.name !== "pack")
|
|
1462
|
+
continue;
|
|
1463
|
+
copyObjectFiles(join8(objects, entry.name), join8(into, entry.name));
|
|
1464
|
+
}
|
|
1465
|
+
return git(run, root, ["cat-file", "-e", `${tip}^{commit}`]).ok;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// src/sessions.ts
|
|
1469
|
+
import { randomBytes } from "node:crypto";
|
|
1470
|
+
import {
|
|
1471
|
+
mkdirSync as mkdirSync8,
|
|
1472
|
+
readdirSync as readdirSync4,
|
|
1473
|
+
readFileSync as readFileSync5,
|
|
1474
|
+
renameSync as renameSync4,
|
|
1475
|
+
rmSync as rmSync4,
|
|
1476
|
+
writeFileSync as writeFileSync5
|
|
1477
|
+
} from "node:fs";
|
|
1478
|
+
import { join as join9 } from "node:path";
|
|
1479
|
+
function newSessionId() {
|
|
1480
|
+
return `maesttro-${randomBytes(4).toString("hex")}`;
|
|
1481
|
+
}
|
|
1482
|
+
function sessionsDir(home = maesttroHome()) {
|
|
1483
|
+
return join9(home, "sessions");
|
|
1484
|
+
}
|
|
1485
|
+
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)}
|
|
1489
|
+
`);
|
|
1490
|
+
renameSync4(`${path}.tmp`, path);
|
|
1491
|
+
}
|
|
1492
|
+
function readSession(id, dir = sessionsDir()) {
|
|
1493
|
+
try {
|
|
1494
|
+
return JSON.parse(readFileSync5(join9(dir, `${id}.json`), "utf8"));
|
|
1495
|
+
} catch {
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
function deleteSession(id, dir = sessionsDir()) {
|
|
1500
|
+
rmSync4(join9(dir, `${id}.json`), { force: true });
|
|
1501
|
+
}
|
|
1502
|
+
function sessionsForRepo(repoRoot, dir = sessionsDir()) {
|
|
1503
|
+
let names;
|
|
1504
|
+
try {
|
|
1505
|
+
names = readdirSync4(dir).filter((name) => name.endsWith(".json"));
|
|
1506
|
+
} catch {
|
|
1507
|
+
return [];
|
|
1508
|
+
}
|
|
1509
|
+
return names.map((name) => readSession(name.slice(0, -".json".length), dir)).filter((record) => record?.repoRoot === repoRoot).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// src/workspace-git.ts
|
|
1513
|
+
var GITHUB_REMOTE = /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/;
|
|
1514
|
+
function githubRepoFromRemote(remote) {
|
|
1515
|
+
const match = remote.trim().match(GITHUB_REMOTE);
|
|
1516
|
+
return match ? `${match[1]}/${match[2]}` : undefined;
|
|
1517
|
+
}
|
|
1518
|
+
function gitOutput(run, workspace, args) {
|
|
1519
|
+
const result = run("git", ["-C", workspace, ...args]);
|
|
1520
|
+
const value = result.stdout.trim();
|
|
1521
|
+
return result.status === 0 && value ? value : undefined;
|
|
1522
|
+
}
|
|
1523
|
+
function workspaceRepo(run, workspace) {
|
|
1524
|
+
const expanded = gitOutput(run, workspace, ["remote", "get-url", "origin"]);
|
|
1525
|
+
const configured = gitOutput(run, workspace, [
|
|
1526
|
+
"config",
|
|
1527
|
+
"--get",
|
|
1528
|
+
"remote.origin.url"
|
|
1529
|
+
]);
|
|
1530
|
+
return [expanded, configured].map((remote) => remote ? githubRepoFromRemote(remote) : undefined).find(Boolean);
|
|
1531
|
+
}
|
|
1532
|
+
function workspaceAuthor(run, workspace) {
|
|
1533
|
+
const name = gitOutput(run, workspace, ["config", "user.name"]);
|
|
1534
|
+
const email = gitOutput(run, workspace, ["config", "user.email"]);
|
|
1535
|
+
return name && email ? { name, email } : undefined;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// src/launch.ts
|
|
1539
|
+
var CONTAINER_REPO = "/workspace";
|
|
1540
|
+
var CONTAINER_PACKAGE = "/opt/maesttro";
|
|
1541
|
+
function resolveCommand(args) {
|
|
1542
|
+
const [first, ...rest] = args;
|
|
1543
|
+
if (first === undefined)
|
|
1544
|
+
return CLIENTS[DEFAULT_CLIENT].binary;
|
|
1545
|
+
if (isClientKey(first))
|
|
1546
|
+
return [CLIENTS[first].binary, ...rest].join(" ");
|
|
1547
|
+
return args.join(" ");
|
|
1548
|
+
}
|
|
1549
|
+
function containerCommand(command) {
|
|
1550
|
+
return `node ${CONTAINER_PACKAGE}/dist/maesttro.js configure || echo "maesttro: in-container setup failed" >&2; ${command}`;
|
|
1551
|
+
}
|
|
1552
|
+
function sessionMounts({
|
|
1553
|
+
repoRoot,
|
|
1554
|
+
sessionDir,
|
|
1555
|
+
packageRoot,
|
|
1556
|
+
hostHome,
|
|
1557
|
+
stagedPaths,
|
|
1558
|
+
sharedSkills
|
|
1559
|
+
}) {
|
|
1560
|
+
const objects = join10(repoRoot, ".git", "objects");
|
|
1561
|
+
const mounts = [
|
|
1562
|
+
{ host: VOLUME, container: "/home/agent" },
|
|
1563
|
+
{ host: objects, container: objects, readOnly: true },
|
|
1564
|
+
{ host: sessionDir, container: CONTAINER_REPO },
|
|
1565
|
+
{ host: packageRoot, container: CONTAINER_PACKAGE, readOnly: true },
|
|
1566
|
+
...stagedPaths.map((path) => ({
|
|
1567
|
+
host: join10(hostHome, path),
|
|
1568
|
+
container: `${HOST_STAGING_DIR}/${path}`,
|
|
1569
|
+
readOnly: true
|
|
1570
|
+
}))
|
|
1571
|
+
];
|
|
1572
|
+
if (sharedSkills) {
|
|
1573
|
+
mounts.push({
|
|
1574
|
+
host: sharedSkills,
|
|
1575
|
+
container: "/home/agent/.claude/skills",
|
|
1576
|
+
readOnly: true
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
return { mounts, workdir: CONTAINER_REPO };
|
|
1580
|
+
}
|
|
1581
|
+
function dockerRunArgs({
|
|
1582
|
+
command,
|
|
1583
|
+
mounts,
|
|
1584
|
+
workdir,
|
|
1585
|
+
extra = []
|
|
1586
|
+
}) {
|
|
1587
|
+
const volumes = mounts.flatMap(({ host, container, readOnly }) => [
|
|
1588
|
+
"-v",
|
|
1589
|
+
`${host}:${container}${readOnly ? ":ro" : ""}`
|
|
1590
|
+
]);
|
|
1591
|
+
return [
|
|
1592
|
+
"run",
|
|
1593
|
+
"-it",
|
|
1594
|
+
"--rm",
|
|
1595
|
+
...volumes,
|
|
1596
|
+
...extra,
|
|
1597
|
+
"-w",
|
|
1598
|
+
workdir,
|
|
1599
|
+
IMAGE,
|
|
1600
|
+
command
|
|
1601
|
+
];
|
|
1602
|
+
}
|
|
1603
|
+
function listClients() {
|
|
1604
|
+
const rows = Object.entries(CLIENTS).map(([key, client]) => ` ${key.padEnd(10)} ${client.label} (${client.npmPackage})`);
|
|
1605
|
+
return [
|
|
1606
|
+
`Available clients:
|
|
1607
|
+
`,
|
|
1608
|
+
...rows,
|
|
1609
|
+
`
|
|
1610
|
+
Default: ${DEFAULT_CLIENT}. Run \`maesttro <client>\` to pick one, or \`maesttro <client> -- <args>\` to pass args through.`
|
|
1611
|
+
].join(`
|
|
1612
|
+
`);
|
|
1613
|
+
}
|
|
1614
|
+
function ensureReady() {
|
|
1615
|
+
try {
|
|
1616
|
+
execSync3(`docker image inspect ${IMAGE}`, { stdio: "ignore" });
|
|
1617
|
+
execSync3(`docker volume inspect ${VOLUME}`, { stdio: "ignore" });
|
|
1618
|
+
} catch {
|
|
1619
|
+
throw new LaunchError("Sandbox isn't set up yet. Run `maesttro setup` first.");
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function sharedSkillsDir() {
|
|
1623
|
+
const dir = process.env.AGENT_SHARED_SKILLS ?? join10(homedir4(), "agent-shared", "skills");
|
|
1624
|
+
return existsSync3(dir) ? dir : undefined;
|
|
1625
|
+
}
|
|
1626
|
+
async function resolveAuthor(workspace) {
|
|
1627
|
+
const fromRepo = workspaceAuthor(runCommand, workspace);
|
|
1628
|
+
if (fromRepo)
|
|
1629
|
+
return fromRepo;
|
|
1630
|
+
const config = readMaesttroConfig();
|
|
1631
|
+
if (config.author)
|
|
1632
|
+
return config.author;
|
|
1633
|
+
if (!process.stdin.isTTY)
|
|
1634
|
+
return;
|
|
1635
|
+
console.log("No git identity found for this repo. Commits in the sandbox need an author (stored in ~/.config/maesttro/config.json).");
|
|
1636
|
+
const name = await ask("Author name: ");
|
|
1637
|
+
const email = await ask("Author email: ");
|
|
1638
|
+
if (!name || !email)
|
|
1639
|
+
return;
|
|
1640
|
+
writeMaesttroConfig({ ...config, author: { name, email } });
|
|
1641
|
+
return { name, email };
|
|
1642
|
+
}
|
|
1643
|
+
function startSession(run, root) {
|
|
1644
|
+
ensureWorktreesDir(run, root);
|
|
1645
|
+
if (!fetchOrigin(run, root)) {
|
|
1646
|
+
console.log("Couldn't fetch origin; starting from the last fetched state.");
|
|
1647
|
+
}
|
|
1648
|
+
const base = defaultBranch(run, root);
|
|
1649
|
+
const baseSha = revision(run, root, base);
|
|
1650
|
+
if (!baseSha)
|
|
1651
|
+
throw new LaunchError(`Can't resolve ${base}.`);
|
|
1652
|
+
const id = newSessionId();
|
|
1653
|
+
const sessionDir = join10(root, WORKTREES_DIR, id);
|
|
1654
|
+
createSessionClone(run, root, sessionDir, baseSha);
|
|
1655
|
+
return {
|
|
1656
|
+
id,
|
|
1657
|
+
repoRoot: root,
|
|
1658
|
+
worktree: sessionDir,
|
|
1659
|
+
base,
|
|
1660
|
+
baseSha,
|
|
1661
|
+
createdAt: new Date().toISOString(),
|
|
1662
|
+
refs: {}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
function resumeSession(root, id) {
|
|
1666
|
+
const session = readSession(id);
|
|
1667
|
+
if (!session || session.repoRoot !== root) {
|
|
1668
|
+
throw new LaunchError(`No session ${id} in this repo. See \`maesttro sessions\`.`);
|
|
1669
|
+
}
|
|
1670
|
+
if (!existsSync3(session.worktree)) {
|
|
1671
|
+
throw new LaunchError(`The clone for ${id} is gone: ${session.worktree}`);
|
|
1672
|
+
}
|
|
1673
|
+
return session;
|
|
1674
|
+
}
|
|
1675
|
+
function preserveBranches(run, session) {
|
|
1676
|
+
const preserved = [];
|
|
1677
|
+
for (const branch of cloneBranches(session.worktree)) {
|
|
1678
|
+
const tip = branchTip(session.worktree, branch);
|
|
1679
|
+
if (!tip)
|
|
1680
|
+
continue;
|
|
1681
|
+
if (importObjects(run, session.repoRoot, session.worktree, tip)) {
|
|
1682
|
+
git(run, session.repoRoot, [
|
|
1683
|
+
"update-ref",
|
|
1684
|
+
`refs/maesttro/${session.id}/${branch}`,
|
|
1685
|
+
tip
|
|
1686
|
+
]);
|
|
1687
|
+
preserved.push(branch);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return preserved;
|
|
1691
|
+
}
|
|
1692
|
+
function finishSession(run, session) {
|
|
1693
|
+
if (!existsSync3(session.worktree)) {
|
|
1694
|
+
deleteSession(session.id);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const preserved = preserveBranches(run, session);
|
|
1698
|
+
if (!cloneHasWork(session.worktree, session.baseSha)) {
|
|
1699
|
+
rmSync5(session.worktree, { recursive: true, force: true });
|
|
1700
|
+
deleteSession(session.id);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const head = cloneHead(session.worktree);
|
|
1704
|
+
const detachedWork = head?.detached !== undefined && head.detached !== session.baseSha;
|
|
1705
|
+
console.log(`
|
|
1706
|
+
Session ${session.id} kept. Continue with \`maesttro resume ${session.id}\`.`);
|
|
1707
|
+
if (preserved.length > 0) {
|
|
1708
|
+
console.log(`Branches from this session: ${preserved.join(", ")}. Push with \`maesttro push\`.`);
|
|
1709
|
+
} else if (detachedWork) {
|
|
1710
|
+
console.log("You have commits on a detached HEAD. Resume and `git switch -c <name>` to keep them.");
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function exitCodeOf(code, signal) {
|
|
1714
|
+
if (code !== null)
|
|
1715
|
+
return code;
|
|
1716
|
+
return signal ? 128 + (constants.signals[signal] ?? 0) : 1;
|
|
1717
|
+
}
|
|
1718
|
+
var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
1719
|
+
async function launch({
|
|
1720
|
+
args,
|
|
1721
|
+
resume
|
|
1722
|
+
}) {
|
|
1723
|
+
const run = runCommand;
|
|
1724
|
+
ensureReady();
|
|
1725
|
+
requireGit(run);
|
|
1726
|
+
const root = repoRoot(run, process.cwd());
|
|
1727
|
+
purgeLegacyTokenOnce({
|
|
1728
|
+
config: readMaesttroConfig(),
|
|
1729
|
+
saveConfig: writeMaesttroConfig,
|
|
1730
|
+
announce: (message) => console.log(message)
|
|
1731
|
+
});
|
|
1732
|
+
const started = resume ? resumeSession(root, resume) : startSession(run, root);
|
|
1733
|
+
const backup = backupRepo({
|
|
1734
|
+
run,
|
|
1735
|
+
repoRoot: root,
|
|
1736
|
+
refs: snapshotRefs(run, root),
|
|
1737
|
+
sessionId: started.id
|
|
1738
|
+
});
|
|
1739
|
+
const saved = { ...started, backup: started.backup ?? backup?.path };
|
|
1740
|
+
saveSession(saved);
|
|
1741
|
+
console.log(`Session ${saved.id}: clone .worktrees/${saved.id} at ${saved.base}${backup ? `, backup ${backup.reused ? "reused" : "written"}` : ""}.`);
|
|
1742
|
+
const repo = workspaceRepo(run, root);
|
|
1743
|
+
if (repo && !resume) {
|
|
1744
|
+
git(run, saved.worktree, [
|
|
1745
|
+
"remote",
|
|
1746
|
+
"set-url",
|
|
1747
|
+
"origin",
|
|
1748
|
+
`https://github.com/${repo}.git`
|
|
1749
|
+
]);
|
|
1750
|
+
}
|
|
1751
|
+
const github = await openGitHubSession({
|
|
1752
|
+
repo,
|
|
1753
|
+
author: await resolveAuthor(root),
|
|
1754
|
+
broker: brokerUrl(),
|
|
1755
|
+
login: {
|
|
1756
|
+
clientId: githubAppClientId(),
|
|
1757
|
+
store: selectCredentialStore(),
|
|
1758
|
+
announce: (message) => console.log(message)
|
|
1759
|
+
},
|
|
1760
|
+
log: fileSessionLog()
|
|
1761
|
+
});
|
|
1762
|
+
if (github.notice)
|
|
1763
|
+
console.log(github.notice);
|
|
1764
|
+
const hostHome = homedir4();
|
|
1765
|
+
const { mounts, workdir } = sessionMounts({
|
|
1766
|
+
repoRoot: root,
|
|
1767
|
+
sessionDir: saved.worktree,
|
|
1768
|
+
packageRoot: PACKAGE_ROOT,
|
|
1769
|
+
hostHome,
|
|
1770
|
+
stagedPaths: STAGED_PATHS.filter((path) => existsSync3(join10(hostHome, path))),
|
|
1771
|
+
sharedSkills: sharedSkillsDir()
|
|
1772
|
+
});
|
|
1773
|
+
const sessionEnv = {
|
|
1774
|
+
...github.env,
|
|
1775
|
+
MAESTTRO_SESSION_ID: saved.id,
|
|
1776
|
+
MAESTTRO_BASE: saved.base,
|
|
1777
|
+
MAESTTRO_GITHUB: github.notice || !repo ? "off" : "on",
|
|
1778
|
+
...hostIdentityEnv(),
|
|
1779
|
+
...repo ? { MAESTTRO_REPO: repo } : {}
|
|
1780
|
+
};
|
|
1781
|
+
const envFlags = [
|
|
1782
|
+
"MAESTTRO_SESSION_ID",
|
|
1783
|
+
"MAESTTRO_BASE",
|
|
1784
|
+
"MAESTTRO_GITHUB",
|
|
1785
|
+
"MAESTTRO_HOST_UID",
|
|
1786
|
+
"MAESTTRO_HOST_GID"
|
|
1787
|
+
].concat(repo ? ["MAESTTRO_REPO"] : []).flatMap((name) => ["-e", name]);
|
|
1788
|
+
const child = spawn("docker", dockerRunArgs({
|
|
1789
|
+
command: containerCommand(resolveCommand(args)),
|
|
1790
|
+
mounts,
|
|
1791
|
+
workdir,
|
|
1792
|
+
extra: [...github.dockerArgs, ...envFlags]
|
|
1793
|
+
}), { stdio: "inherit", env: { ...process.env, ...sessionEnv } });
|
|
1794
|
+
const forward = (signal) => child.kill(signal);
|
|
1795
|
+
for (const signal of FORWARDED_SIGNALS)
|
|
1796
|
+
process.on(signal, forward);
|
|
1797
|
+
const exitCode = await new Promise((resolve) => {
|
|
1798
|
+
child.on("exit", (code, signal) => resolve(exitCodeOf(code, signal)));
|
|
1799
|
+
child.on("error", () => resolve(127));
|
|
1800
|
+
});
|
|
1801
|
+
for (const signal of FORWARDED_SIGNALS)
|
|
1802
|
+
process.off(signal, forward);
|
|
1803
|
+
await github.close();
|
|
1804
|
+
finishSession(run, saved);
|
|
1805
|
+
process.exit(exitCode);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// src/push.ts
|
|
1809
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1810
|
+
function pushCandidates(sessions) {
|
|
1811
|
+
const candidates = new Map;
|
|
1812
|
+
for (const session of sessions) {
|
|
1813
|
+
const defaultBranch = session.base.replace(/^origin\//, "");
|
|
1814
|
+
for (const branch of cloneBranches(session.worktree)) {
|
|
1815
|
+
if (branch === defaultBranch || !isSafeBranchName(branch))
|
|
1816
|
+
continue;
|
|
1817
|
+
candidates.set(branch, {
|
|
1818
|
+
branch,
|
|
1819
|
+
base: session.base,
|
|
1820
|
+
baseSha: session.baseSha,
|
|
1821
|
+
sessionId: session.id,
|
|
1822
|
+
dir: session.worktree
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return candidates;
|
|
1827
|
+
}
|
|
1828
|
+
function planPush({
|
|
1829
|
+
requested,
|
|
1830
|
+
candidates
|
|
1831
|
+
}) {
|
|
1832
|
+
const names = [...candidates.keys()];
|
|
1833
|
+
if (requested) {
|
|
1834
|
+
const candidate = candidates.get(requested);
|
|
1835
|
+
if (candidate)
|
|
1836
|
+
return { ok: true, candidate };
|
|
1837
|
+
return {
|
|
1838
|
+
ok: false,
|
|
1839
|
+
candidates: names,
|
|
1840
|
+
reason: `${requested} wasn't created by a maesttro session in this repo (or is the default branch).`
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
const [only] = names;
|
|
1844
|
+
if (names.length === 1 && only) {
|
|
1845
|
+
return { ok: true, candidate: candidates.get(only) };
|
|
1846
|
+
}
|
|
1847
|
+
return {
|
|
1848
|
+
ok: false,
|
|
1849
|
+
candidates: names,
|
|
1850
|
+
reason: names.length === 0 ? "No session in this repo has a branch ready to push." : "Several session branches are ready; pick one."
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
async function choose(candidates) {
|
|
1854
|
+
if (!process.stdin.isTTY)
|
|
1855
|
+
return;
|
|
1856
|
+
console.log(candidates.map((name, index) => ` ${index + 1}) ${name}`).join(`
|
|
1857
|
+
`));
|
|
1858
|
+
const answer = await ask("Branch to push: ");
|
|
1859
|
+
return candidates[Number(answer) - 1];
|
|
1860
|
+
}
|
|
1861
|
+
function describe(run, root, branch, base, tip) {
|
|
1862
|
+
const commits = git(run, root, [
|
|
1863
|
+
"log",
|
|
1864
|
+
"--format= %h %s (%an / %cn)",
|
|
1865
|
+
`${base}..${tip}`
|
|
1866
|
+
]).out;
|
|
1867
|
+
const stat = git(run, root, ["diff", "--stat", `${base}...${tip}`]).out;
|
|
1868
|
+
return [
|
|
1869
|
+
`Pushing ${branch} to origin (compared with ${base}):`,
|
|
1870
|
+
stripControl(commits) || " (no new commits)",
|
|
1871
|
+
stat ? `
|
|
1872
|
+
${stripControl(stat)}` : ""
|
|
1873
|
+
].join(`
|
|
1874
|
+
`);
|
|
1875
|
+
}
|
|
1876
|
+
async function push({ args }) {
|
|
1877
|
+
const run = runCommand;
|
|
1878
|
+
const yes = args.includes("--yes") || args.includes("-y");
|
|
1879
|
+
const requested = args.find((arg) => !arg.startsWith("-"));
|
|
1880
|
+
let root;
|
|
1881
|
+
try {
|
|
1882
|
+
root = repoRoot(run, process.cwd());
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
throw error instanceof LaunchError ? error : new LaunchError(String(error));
|
|
1885
|
+
}
|
|
1886
|
+
const candidates = pushCandidates(sessionsForRepo(root));
|
|
1887
|
+
let plan = planPush({ requested, candidates });
|
|
1888
|
+
if (!plan.ok && !requested && plan.candidates.length > 1) {
|
|
1889
|
+
const picked = await choose(plan.candidates);
|
|
1890
|
+
if (picked)
|
|
1891
|
+
plan = planPush({ requested: picked, candidates });
|
|
1892
|
+
}
|
|
1893
|
+
if (!plan.ok) {
|
|
1894
|
+
console.error(plan.reason);
|
|
1895
|
+
process.exitCode = 1;
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
const { branch, base, baseSha, dir, sessionId } = plan.candidate;
|
|
1899
|
+
const tip = branchTip(dir, branch);
|
|
1900
|
+
if (!tip || !importObjects(run, root, dir, tip)) {
|
|
1901
|
+
console.error(`Could not read ${branch} from session ${sessionId}.`);
|
|
1902
|
+
process.exitCode = 1;
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
git(run, root, ["update-ref", `refs/maesttro/${sessionId}/${branch}`, tip]);
|
|
1906
|
+
console.log(describe(run, root, branch, base, tip));
|
|
1907
|
+
if (!yes) {
|
|
1908
|
+
if (!process.stdin.isTTY) {
|
|
1909
|
+
console.error("Not a terminal; re-run with --yes to push without confirming.");
|
|
1910
|
+
process.exitCode = 1;
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const answer = await ask(`
|
|
1914
|
+
Push? [y/N] `);
|
|
1915
|
+
if (!/^y(es)?$/i.test(answer)) {
|
|
1916
|
+
console.log("Not pushed.");
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const pushed = spawnSync2("git", ["-C", root, "push", "origin", `${tip}:refs/heads/${branch}`], { stdio: "inherit" });
|
|
1921
|
+
if (pushed.status === 0) {
|
|
1922
|
+
console.log(`Pushed ${branch}. Base for comparison was ${baseSha.slice(0, 8)}.`);
|
|
1923
|
+
}
|
|
1924
|
+
process.exitCode = pushed.status ?? 1;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// src/sessions-command.ts
|
|
1928
|
+
import { existsSync as existsSync4, rmSync as rmSync6 } from "node:fs";
|
|
1929
|
+
function sessionsCommand({ args }) {
|
|
1930
|
+
const run = runCommand;
|
|
1931
|
+
const root = repoRoot(run, process.cwd());
|
|
1932
|
+
const [action, id] = args;
|
|
1933
|
+
if (action === "remove") {
|
|
1934
|
+
const session = id ? readSession(id) : undefined;
|
|
1935
|
+
if (!session || session.repoRoot !== root) {
|
|
1936
|
+
console.error(`No session ${id ?? "(missing id)"} in this repo.`);
|
|
1937
|
+
process.exitCode = 1;
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (existsSync4(session.worktree)) {
|
|
1941
|
+
const preserved = preserveBranches(run, session);
|
|
1942
|
+
const head = cloneHead(session.worktree);
|
|
1943
|
+
if (preserved.length === 0 && head?.detached !== undefined && head.detached !== session.baseSha) {
|
|
1944
|
+
console.error(`${session.id} has commits on a detached HEAD not on any branch; resume and create a branch first.`);
|
|
1945
|
+
process.exitCode = 1;
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
rmSync6(session.worktree, { recursive: true, force: true });
|
|
1949
|
+
const kept = preserved.length > 0 ? ` Its branches are kept as refs/maesttro/${session.id}/*.` : "";
|
|
1950
|
+
console.log(`Removed session ${session.id}.${kept}`);
|
|
1951
|
+
} else {
|
|
1952
|
+
console.log(`Removed session ${session.id}.`);
|
|
1953
|
+
}
|
|
1954
|
+
deleteSession(session.id);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
const sessions = sessionsForRepo(root);
|
|
1958
|
+
if (sessions.length === 0) {
|
|
1959
|
+
console.log("No maesttro sessions in this repo.");
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
for (const session of sessions) {
|
|
1963
|
+
const branches = existsSync4(session.worktree) ? cloneBranches(session.worktree) : undefined;
|
|
1964
|
+
const where = branches === undefined ? "clone missing" : branches.length > 0 ? `branches: ${branches.join(", ")}` : "no branches yet";
|
|
1965
|
+
console.log(` ${session.id} ${session.createdAt.slice(0, 16)} ${where}`);
|
|
1966
|
+
}
|
|
1967
|
+
console.log("\n`maesttro resume <id>` continues one; `maesttro sessions remove <id>` removes its clone.");
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// src/setup.ts
|
|
1971
|
+
import { execSync as execSync4 } from "node:child_process";
|
|
1972
|
+
function run(command) {
|
|
1973
|
+
execSync4(command, { stdio: "inherit" });
|
|
1974
|
+
}
|
|
1975
|
+
function ensureDocker() {
|
|
1976
|
+
try {
|
|
1977
|
+
execSync4("docker info", { stdio: "ignore" });
|
|
1978
|
+
} catch {
|
|
1979
|
+
console.error("Docker doesn't seem to be running. Start Docker Desktop (or the docker daemon) and try again.");
|
|
1980
|
+
process.exit(1);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
async function setup({ args }) {
|
|
1984
|
+
console.log(`== maesttro setup ==
|
|
1985
|
+
`);
|
|
1986
|
+
ensureDocker();
|
|
1987
|
+
console.log("-> Building sandbox image (this pulls node:22-slim + installs CLIs, first run only)...");
|
|
1988
|
+
run(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`);
|
|
1989
|
+
console.log(`
|
|
1990
|
+
-> Ensuring persistent volume exists...`);
|
|
1991
|
+
execSync4(`docker volume create ${VOLUME}`, { stdio: "ignore" });
|
|
1992
|
+
console.log(`
|
|
1993
|
+
-> Removing any long-lived GitHub token from the volume...`);
|
|
1994
|
+
if (purgeLegacyToken())
|
|
1995
|
+
console.log(` ${REVOKE_NOTICE}`);
|
|
1996
|
+
writeMaesttroConfig({ ...readMaesttroConfig(), legacyTokenPurged: true });
|
|
1997
|
+
if (args[0]) {
|
|
1998
|
+
console.log(" Ignoring the token argument: sessions now get short-lived tokens through `maesttro login`.");
|
|
1999
|
+
}
|
|
2000
|
+
console.log(`
|
|
2001
|
+
-> Logging in to GitHub...`);
|
|
2002
|
+
await ensureLogin();
|
|
2003
|
+
console.log(`
|
|
2004
|
+
-> Verifying isolation...`);
|
|
2005
|
+
console.log(" personal SSH key should NOT be visible:");
|
|
2006
|
+
run(`docker run --rm ${hostIdentityFlags()} -v ${VOLUME}:/home/agent ${IMAGE} "cat ~/.ssh/id_rsa 2>&1 || true"`);
|
|
2007
|
+
console.log("\nSetup complete. Run `maesttro` from any project directory to start a session,");
|
|
2008
|
+
console.log("or `maesttro list` to see available clients.");
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// src/update.ts
|
|
2012
|
+
import { execSync as execSync5 } from "node:child_process";
|
|
2013
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
2014
|
+
import { join as join11 } from "node:path";
|
|
2015
|
+
function installedPackageName() {
|
|
2016
|
+
const manifest = JSON.parse(readFileSync6(join11(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
2017
|
+
return manifest.name;
|
|
2018
|
+
}
|
|
2019
|
+
function update() {
|
|
2020
|
+
console.log(`== maesttro update ==
|
|
2021
|
+
`);
|
|
2022
|
+
const packageName = installedPackageName();
|
|
2023
|
+
console.log(`-> Updating ${packageName} package...`);
|
|
2024
|
+
execSync5(`npm i -g ${packageName}@latest`, { stdio: "inherit" });
|
|
2025
|
+
console.log(`
|
|
2026
|
+
-> Rebuilding sandbox image with refreshed client configs...`);
|
|
2027
|
+
execSync5(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`, { stdio: "inherit" });
|
|
2028
|
+
console.log(`
|
|
2029
|
+
Update complete. Bot identity and your project files are untouched —`);
|
|
2030
|
+
console.log("only the image (client versions + curated configs) was refreshed.");
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// src/main.ts
|
|
2034
|
+
var defaultCommands = {
|
|
2035
|
+
launch,
|
|
2036
|
+
push,
|
|
2037
|
+
sessions: sessionsCommand,
|
|
2038
|
+
configure: () => configure(),
|
|
2039
|
+
setup,
|
|
2040
|
+
update,
|
|
2041
|
+
doctor,
|
|
2042
|
+
login,
|
|
2043
|
+
logout,
|
|
2044
|
+
status,
|
|
2045
|
+
print: (text) => console.log(text)
|
|
2046
|
+
};
|
|
2047
|
+
async function main(argv, commands = defaultCommands) {
|
|
2048
|
+
const [command, ...rest] = argv;
|
|
2049
|
+
switch (command) {
|
|
2050
|
+
case undefined:
|
|
2051
|
+
await commands.launch({ args: [] });
|
|
2052
|
+
return;
|
|
2053
|
+
case "setup":
|
|
2054
|
+
await commands.setup({ args: rest });
|
|
2055
|
+
return;
|
|
2056
|
+
case "update":
|
|
2057
|
+
await commands.update();
|
|
2058
|
+
return;
|
|
2059
|
+
case "doctor":
|
|
2060
|
+
await commands.doctor();
|
|
2061
|
+
return;
|
|
2062
|
+
case "login":
|
|
2063
|
+
await commands.login();
|
|
2064
|
+
return;
|
|
2065
|
+
case "logout":
|
|
2066
|
+
await commands.logout();
|
|
2067
|
+
return;
|
|
2068
|
+
case "status":
|
|
2069
|
+
await commands.status();
|
|
2070
|
+
return;
|
|
2071
|
+
case "push":
|
|
2072
|
+
await commands.push({ args: rest });
|
|
2073
|
+
return;
|
|
2074
|
+
case "sessions":
|
|
2075
|
+
await commands.sessions({ args: rest });
|
|
2076
|
+
return;
|
|
2077
|
+
case "resume": {
|
|
2078
|
+
const [id, ...clientArgs] = rest;
|
|
2079
|
+
if (!id) {
|
|
2080
|
+
commands.print("Usage: maesttro resume <session-id> [client] [args...]");
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
await commands.launch({ args: clientArgs, resume: id });
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
case "configure":
|
|
2087
|
+
await commands.configure();
|
|
2088
|
+
return;
|
|
2089
|
+
case "list":
|
|
2090
|
+
commands.print(listClients());
|
|
2091
|
+
return;
|
|
2092
|
+
case "help":
|
|
2093
|
+
case "--help":
|
|
2094
|
+
case "-h":
|
|
2095
|
+
commands.print(HELP);
|
|
2096
|
+
return;
|
|
2097
|
+
default:
|
|
2098
|
+
await commands.launch({ args: argv });
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// src/cli.ts
|
|
2103
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
2104
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2105
|
+
process.exit(1);
|
|
2106
|
+
});
|