Skip to content

Commit fe6dfcd

Browse files
committed
fix(modal): prefer the control-plane-minted clone_token over Modal-side resolution
api_create_sandbox + api_restore_sandbox computed clone_token = _resolve_clone_token() (Modal-side: hardcoded GITHUB_CLONE_TOKEN env or generate_clone_token_from_env), which overrode and discarded the clone_token the control plane sends in the request. The worker is the authoritative credential resolver (it mints a repo-scoped GitHub App installation token from the org's installation + per-tenant context), so its token was silently dropped and private repos cloned UNAUTHENTICATED (sandbox: 'fatal: could not read Username' -> with the clone-failure fix, a clear error). Now: clone_token = request.get('clone_token') or _resolve_clone_token() (prefer worker, fall back to Modal-side). Diagnostic: scripts/gh-app-info.ts (mint App JWT, list installs, test a repo's clone token) — proved the App is installed on omoios/all and the token mints.
1 parent 1a96178 commit fe6dfcd

2 files changed

Lines changed: 115 additions & 2 deletions

File tree

packages/modal-infra/src/web_api.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,12 @@ async def api_create_sandbox(
150150

151151
manager = SandboxManager()
152152

153-
clone_token = _resolve_clone_token()
153+
# Prefer the control-plane-minted token: the worker is the authoritative
154+
# credential resolver (it holds the GitHub App creds, the per-tenant context,
155+
# and the entitlement decision, and mints a repo-scoped installation token).
156+
# Fall back to Modal-side resolution (hardcoded PAT / env) only when the
157+
# request omits one (e.g. older control planes).
158+
clone_token = request.get("clone_token") or _resolve_clone_token()
154159

155160
session_config = SessionConfig(
156161
session_id=request.get("session_id"),
@@ -489,7 +494,9 @@ async def api_restore_sandbox(
489494
timeout_seconds = int(request.get("timeout_seconds", DEFAULT_SANDBOX_TIMEOUT_SECONDS))
490495

491496
manager = SandboxManager()
492-
clone_token = _resolve_clone_token()
497+
# Prefer the control-plane-minted token (see api_create_sandbox), else
498+
# fall back to Modal-side resolution.
499+
clone_token = request.get("clone_token") or _resolve_clone_token()
493500

494501
code_server_enabled = bool(request.get("code_server_enabled", False))
495502
sandbox_settings = request.get("sandbox_settings") or None

scripts/gh-app-info.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Diagnostic: mint a GitHub App JWT from .dev.vars (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY)
2+
// and report the App's install URL + every org/account it's currently installed on.
3+
// Read-only (GET /app, GET /app/installations). Run: npx tsx scripts/gh-app-info.ts
4+
import { readFileSync } from "node:fs";
5+
import { createSign } from "node:crypto";
6+
7+
function fromDevVars(name: string): string {
8+
const raw = readFileSync(".dev.vars", "utf8");
9+
// Match KEY=value where value may be double-quoted and span multiple lines.
10+
const quoted = new RegExp(`^\\s*${name}="([\\s\\S]*?)"\\s*$`, "m").exec(raw);
11+
if (quoted) return quoted[1].replace(/\\n/g, "\n");
12+
const bare = new RegExp(`^\\s*${name}=(.+)$`, "m").exec(raw);
13+
if (bare) return bare[1].trim().replace(/^"|"$/g, "").replace(/\\n/g, "\n");
14+
throw new Error(`${name} not found in .dev.vars`);
15+
}
16+
17+
function b64url(buf: Buffer | string): string {
18+
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19+
}
20+
21+
function appJwt(appId: string, pemRaw: string): string {
22+
// .dev.vars stores the key with no line breaks (-----BEGIN-----<base64>-----END-----);
23+
// the Worker decodes the raw DER, but Node's PEM parser needs wrapped lines. Re-wrap.
24+
const body = pemRaw.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, "").replace(/\s/g, "");
25+
const pem = `-----BEGIN PRIVATE KEY-----\n${(body.match(/.{1,64}/g) ?? []).join("\n")}\n-----END PRIVATE KEY-----\n`;
26+
const now = Math.floor(Date.now() / 1000);
27+
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
28+
const payload = b64url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }));
29+
const data = `${header}.${payload}`;
30+
const sig = createSign("RSA-SHA256").update(data).end().sign(pem);
31+
return `${data}.${b64url(sig)}`;
32+
}
33+
34+
async function gh(path: string, jwt: string): Promise<unknown> {
35+
const res = await fetch(`https://api.github.com${path}`, {
36+
headers: {
37+
Accept: "application/vnd.github+json",
38+
Authorization: `Bearer ${jwt}`,
39+
"X-GitHub-Api-Version": "2022-11-28",
40+
"User-Agent": "agent-coordinator",
41+
},
42+
});
43+
const body = await res.text();
44+
if (!res.ok) throw new Error(`GET ${path} -> ${res.status} ${body.slice(0, 200)}`);
45+
return JSON.parse(body);
46+
}
47+
48+
async function main(): Promise<void> {
49+
const appId = process.env.GITHUB_APP_ID ?? "3939828"; // wrangler.jsonc var, not a .dev.vars secret
50+
const pem = fromDevVars("GITHUB_APP_PRIVATE_KEY");
51+
const jwt = appJwt(appId, pem);
52+
53+
const app = (await gh("/app", jwt)) as { slug?: string; name?: string; html_url?: string };
54+
console.log(`App: ${app.name} (slug=${app.slug}, id=${appId})`);
55+
console.log(`Install URL: https://github.com/apps/${app.slug}/installations/new`);
56+
57+
const insts = (await gh("/app/installations", jwt)) as Array<{
58+
id?: number;
59+
account?: { login?: string; type?: string };
60+
repository_selection?: string;
61+
}>;
62+
console.log(`\nCurrently installed on ${insts.length} account(s):`);
63+
for (const i of insts) {
64+
console.log(` - ${i.account?.login} (${i.account?.type}) installation_id=${i.id} repos=${i.repository_selection}`);
65+
}
66+
if (insts.length === 0) console.log(" (none — the App isn't installed anywhere yet)");
67+
68+
// Replicate resolveInstallationToken (src/lib/github-token-resolver.ts) for a repo,
69+
// to confirm a clone token is obtainable: GET /repos/:o/:r/installation -> POST tokens.
70+
const tokenRepo = process.env.TOKEN_REPO; // "owner/repo"
71+
if (tokenRepo) {
72+
const [owner, repo] = tokenRepo.split("/");
73+
console.log(`\n=== Resolve clone token for ${owner}/${repo} (repo-based lookup) ===`);
74+
try {
75+
const inst = (await gh(`/repos/${owner}/${repo}/installation`, jwt)) as { id?: number };
76+
console.log(` lookup -> installation_id=${inst.id}`);
77+
const res = await fetch(`https://api.github.com/app/installations/${inst.id}/access_tokens`, {
78+
method: "POST",
79+
headers: {
80+
Accept: "application/vnd.github+json",
81+
Authorization: `Bearer ${jwt}`,
82+
"X-GitHub-Api-Version": "2022-11-28",
83+
"User-Agent": "agent-coordinator",
84+
},
85+
body: JSON.stringify({
86+
repositories: [repo],
87+
permissions: { contents: "write", metadata: "read", pull_requests: "write", workflows: "write" },
88+
}),
89+
});
90+
const body = await res.text();
91+
if (res.ok) {
92+
const tok = JSON.parse(body) as { token?: string; expires_at?: string };
93+
console.log(` TOKEN OBTAINED ✓ ${tok.token?.slice(0, 8)}… expires ${tok.expires_at}`);
94+
} else {
95+
console.log(` TOKEN FAILED ✗ ${res.status} ${body.slice(0, 200)}`);
96+
}
97+
} catch (e) {
98+
console.log(` LOOKUP FAILED ✗ ${e instanceof Error ? e.message : e}`);
99+
}
100+
}
101+
}
102+
103+
main().catch((e) => {
104+
console.error("gh-app-info failed:", e instanceof Error ? e.message : e);
105+
process.exit(1);
106+
});

0 commit comments

Comments
 (0)