Skip to content

Commit 3db32e6

Browse files
feat(agentos-server): inject group-scoped git PAT into harness clone (private GAP repos via UI)
UI live-chat dispatches through the TS harness, whose GAP loader cloned private repos with no auth — git fell back to an interactive username read and failed with "could not read Username ... No such device or address". The §2.6c credential store was only wired into the Python SDK. agentos-server now resolves the group-scoped PAT (agent.ownerGroup → host, falling back to the actor's groups for legacy unowned agents) and injects the same GIT_CONFIG_* http extraHeader the Python substrate uses. The harness subprocess inherits these envs (runtime-local spawns it with {...process.env, ...envs}), so the spawned git authenticates with no harness-side change. The token rides an env-based extraHeader, never the clone URL — so unlike the legacy gitToken path it never lands in .git/config (and therefore never in the workdir's S3 snapshot). Skips injection when an explicit gitToken is set, for SSH/local sources, when AGENTOS_CREDENTIALS_KEY is unset, or when no credential matches — all degrade to the unchanged unauthenticated clone. Never throws on a store hiccup. sandboxBodyFor/runBodyFor are now async; all four callers await. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d8c758 commit 3db32e6

6 files changed

Lines changed: 209 additions & 8 deletions

File tree

packages/agentos-server/src/agent-defs.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { IdentitySource, type IdentitySource as IdentitySourceT } from "@open-gi
1414
import { agentPoliciesColl, registryColl, type RegistryDoc } from "./mongo.js";
1515
import { canRead, isSuperuser } from "./auth/ownership.js";
1616
import type { Principal } from "./auth/principal.js";
17+
import { gitCredentialStore, normalizeGitHost } from "./stores/git-credential-store.js";
18+
import { credentialsKeyConfigured } from "./crypto/secret-box.js";
1719

1820
export interface AgentDef {
1921
/** Surrogate key — the registry doc's ObjectId, stringified. The public
@@ -220,11 +222,82 @@ export function normalizeSource(raw: unknown): { source: IdentitySourceT | strin
220222
return { source: str, sourceUrl: str || null };
221223
}
222224

225+
/**
226+
* Bare host for an https git clone, or null when no http extraHeader applies.
227+
*
228+
* Returns null for SSH (`git@…` / `ssh://` — authenticates by key, not token),
229+
* for local paths, and for anything without a real `host.tld`. The harness's
230+
* own `normalizeGitUrl` turns bare `github.com/o/r` and `https://…` into an
231+
* `https://…` clone URL, so the extraHeader we key on (`http.https://<host>/`)
232+
* matches both forms.
233+
*/
234+
function gitCloneHttpsHost(raw: string): string | null {
235+
const s = (raw ?? "").trim();
236+
if (!s) return null;
237+
if (s.startsWith("git@") || s.startsWith("ssh://") || s.startsWith("git://")) return null;
238+
if (s.startsWith("/") || s.startsWith(".") || s.startsWith("~")) return null;
239+
const host = normalizeGitHost(s);
240+
return host.includes(".") ? host : null;
241+
}
242+
243+
/**
244+
* Resolve a group-scoped git PAT for the agent's private GAP repo and return the
245+
* `GIT_CONFIG_*` env that makes `git clone` send it as an http `extraHeader`.
246+
*
247+
* The token is injected via env (never the clone URL), so — unlike the legacy
248+
* `gitToken` URL-embed path — it never lands in `.git/config` and therefore
249+
* never in the workdir's S3 snapshot. This mirrors the Python SDK's
250+
* `substrates/local.py` credential injection exactly, and the harness subprocess
251+
* inherits these vars (runtime-local spawns it with `{ ...process.env, ...envs }`),
252+
* so the spawned `git` honours them with no harness-side code change.
253+
*
254+
* Returns `{}` (→ unauthenticated clone, unchanged public-repo behaviour) when:
255+
* the agent already carries an explicit `gitToken`, `AGENTOS_CREDENTIALS_KEY`
256+
* is unset, the source isn't an https git URL, there's no owning group, or no
257+
* credential matches the (group, host). Never throws — a credential lookup
258+
* failure must not block dispatch.
259+
*/
260+
export async function gitCredentialEnvsFor(
261+
agent: AgentDef,
262+
actor?: Principal,
263+
): Promise<Record<string, string>> {
264+
// The legacy gitToken path already injects auth (URL-embedded). Don't double up.
265+
if (agent.gitToken) return {};
266+
if (!credentialsKeyConfigured()) return {};
267+
268+
const { sourceUrl } = normalizeSource(agent.source);
269+
const host = sourceUrl ? gitCloneHttpsHost(sourceUrl) : null;
270+
if (!host) return {};
271+
272+
// Scope to the group that owns the agent (the group that configured the PAT);
273+
// fall back to the chatting actor's groups for legacy unowned agents.
274+
const ownerGroups = agent.ownerGroup ? [agent.ownerGroup] : actor?.groups ?? [];
275+
if (ownerGroups.length === 0) return {};
276+
277+
let found: Awaited<ReturnType<typeof gitCredentialStore.resolve>> = null;
278+
try {
279+
found = await gitCredentialStore.resolve({ ownerGroups, host });
280+
} catch {
281+
return {}; // never fail a run on a credential-store hiccup
282+
}
283+
if (!found) return {};
284+
285+
const username = found.doc.username || "x-access-token";
286+
const basic = Buffer.from(`${username}:${found.token}`).toString("base64");
287+
return {
288+
GIT_TERMINAL_PROMPT: "0",
289+
GIT_CONFIG_NOSYSTEM: "1",
290+
GIT_CONFIG_COUNT: "1",
291+
GIT_CONFIG_KEY_0: `http.https://${host}/.extraHeader`,
292+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
293+
};
294+
}
295+
223296
/** Build the POST /sandboxes body the harness expects. Mirrors the contract
224297
* from examples/slack-bot.ts:sandboxBodyForBot so Slack and the dashboard
225298
* create identically-configured sandboxes. */
226-
export function sandboxBodyFor(agent: AgentDef, sessionId: string, actor?: Principal): Record<string, unknown> {
227-
const envs = { ...defaultEnvsFor(agent.harness), ...(agent.envs ?? {}) };
299+
export async function sandboxBodyFor(agent: AgentDef, sessionId: string, actor?: Principal): Promise<Record<string, unknown>> {
300+
const envs = { ...defaultEnvsFor(agent.harness), ...(agent.envs ?? {}), ...(await gitCredentialEnvsFor(agent, actor)) };
228301
const body: Record<string, unknown> = {
229302
source: agent.source,
230303
harness: agent.harness,
@@ -251,8 +324,8 @@ export function sandboxBodyFor(agent: AgentDef, sessionId: string, actor?: Princ
251324
}
252325

253326
/** Build the POST /run body for one-shot runs. */
254-
export function runBodyFor(agent: AgentDef, message: string, actor?: Principal): Record<string, unknown> {
255-
const envs = { ...defaultEnvsFor(agent.harness), ...(agent.envs ?? {}) };
327+
export async function runBodyFor(agent: AgentDef, message: string, actor?: Principal): Promise<Record<string, unknown>> {
328+
const envs = { ...defaultEnvsFor(agent.harness), ...(agent.envs ?? {}), ...(await gitCredentialEnvsFor(agent, actor)) };
256329
const body: Record<string, unknown> = {
257330
source: agent.source,
258331
harness: agent.harness,

packages/agentos-server/src/eval-runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export async function runAgainstHarness(
184184
agent: NonNullable<Awaited<ReturnType<typeof resolveAgent>>>,
185185
prompt: string,
186186
): Promise<Captured> {
187-
const body = runBodyFor(agent, prompt) as Record<string, unknown>;
187+
const body = (await runBodyFor(agent, prompt)) as Record<string, unknown>;
188188
const policy = await srsPolicyForAgent(agent.name);
189189
if (policy) body.policy = policy;
190190

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { gitCredentialEnvsFor, type AgentDef } from "./agent-defs.js";
3+
import { gitCredentialStore } from "./stores/git-credential-store.js";
4+
import { resetKeyringForTests } from "./crypto/secret-box.js";
5+
import type { Principal } from "./auth/principal.js";
6+
7+
// A valid 32-byte base64 key so credentialsKeyConfigured() returns true.
8+
const KEY = Buffer.alloc(32, 7).toString("base64");
9+
10+
function agent(over: Partial<AgentDef>): AgentDef {
11+
return {
12+
id: "a1",
13+
name: "Docs Agent",
14+
label: "Docs Agent",
15+
harness: "claude-agent-sdk",
16+
source: "github.com/acme/private-docs",
17+
ownerGroup: "acme",
18+
...over,
19+
};
20+
}
21+
22+
function resolveReturns(doc: { username?: string | null } | null, token = "ghp_secret") {
23+
return vi
24+
.spyOn(gitCredentialStore, "resolve")
25+
.mockResolvedValue(doc ? ({ doc: doc as never, token }) : null);
26+
}
27+
28+
beforeEach(() => {
29+
process.env.AGENTOS_CREDENTIALS_KEY = KEY;
30+
resetKeyringForTests();
31+
});
32+
afterEach(() => {
33+
vi.restoreAllMocks();
34+
delete process.env.AGENTOS_CREDENTIALS_KEY;
35+
resetKeyringForTests();
36+
});
37+
38+
describe("gitCredentialEnvsFor", () => {
39+
it("injects GIT_CONFIG_* extraHeader for a resolvable private https repo", async () => {
40+
const spy = resolveReturns({ username: "x-access-token" }, "ghp_abc");
41+
const envs = await gitCredentialEnvsFor(agent({}));
42+
43+
expect(spy).toHaveBeenCalledWith({ ownerGroups: ["acme"], host: "github.com" });
44+
expect(envs.GIT_TERMINAL_PROMPT).toBe("0");
45+
expect(envs.GIT_CONFIG_NOSYSTEM).toBe("1");
46+
expect(envs.GIT_CONFIG_COUNT).toBe("1");
47+
expect(envs.GIT_CONFIG_KEY_0).toBe("http.https://github.com/.extraHeader");
48+
const expected = Buffer.from("x-access-token:ghp_abc").toString("base64");
49+
expect(envs.GIT_CONFIG_VALUE_0).toBe(`Authorization: Basic ${expected}`);
50+
// The raw token never appears verbatim in any env value.
51+
expect(JSON.stringify(envs)).not.toContain("ghp_abc");
52+
});
53+
54+
it("defaults the basic-auth username to x-access-token when the cred has none", async () => {
55+
resolveReturns({ username: null }, "tok");
56+
const envs = await gitCredentialEnvsFor(agent({}));
57+
const expected = Buffer.from("x-access-token:tok").toString("base64");
58+
expect(envs.GIT_CONFIG_VALUE_0).toBe(`Authorization: Basic ${expected}`);
59+
});
60+
61+
it("honours a custom credential username", async () => {
62+
resolveReturns({ username: "oauth2" }, "tok");
63+
const envs = await gitCredentialEnvsFor(agent({}));
64+
const expected = Buffer.from("oauth2:tok").toString("base64");
65+
expect(envs.GIT_CONFIG_VALUE_0).toBe(`Authorization: Basic ${expected}`);
66+
});
67+
68+
it("scopes resolution to the agent's owner group", async () => {
69+
const spy = resolveReturns({ username: "x-access-token" });
70+
await gitCredentialEnvsFor(agent({ ownerGroup: "team-x" }));
71+
expect(spy).toHaveBeenCalledWith({ ownerGroups: ["team-x"], host: "github.com" });
72+
});
73+
74+
it("falls back to the actor's groups for a legacy unowned agent", async () => {
75+
const spy = resolveReturns({ username: "x-access-token" });
76+
const actor = { id: "u1", groups: ["g1", "g2"] } as Principal;
77+
await gitCredentialEnvsFor(agent({ ownerGroup: null }), actor);
78+
expect(spy).toHaveBeenCalledWith({ ownerGroups: ["g1", "g2"], host: "github.com" });
79+
});
80+
81+
it("returns {} (unauthenticated) when no credential matches", async () => {
82+
resolveReturns(null);
83+
expect(await gitCredentialEnvsFor(agent({}))).toEqual({});
84+
});
85+
86+
it("returns {} when the agent already carries a legacy gitToken", async () => {
87+
const spy = resolveReturns({ username: "x" });
88+
expect(await gitCredentialEnvsFor(agent({ gitToken: "ghp_legacy" }))).toEqual({});
89+
expect(spy).not.toHaveBeenCalled();
90+
});
91+
92+
it("returns {} when AGENTOS_CREDENTIALS_KEY is unset", async () => {
93+
delete process.env.AGENTOS_CREDENTIALS_KEY;
94+
resetKeyringForTests();
95+
const spy = resolveReturns({ username: "x" });
96+
expect(await gitCredentialEnvsFor(agent({}))).toEqual({});
97+
expect(spy).not.toHaveBeenCalled();
98+
});
99+
100+
it("skips SSH sources (key auth, no http extraHeader)", async () => {
101+
const spy = resolveReturns({ username: "x" });
102+
expect(await gitCredentialEnvsFor(agent({ source: "git@github.com:acme/private-docs.git" }))).toEqual({});
103+
expect(spy).not.toHaveBeenCalled();
104+
});
105+
106+
it("skips local-path sources", async () => {
107+
const spy = resolveReturns({ username: "x" });
108+
expect(await gitCredentialEnvsFor(agent({ source: "/srv/agents/docs" }))).toEqual({});
109+
expect(spy).not.toHaveBeenCalled();
110+
});
111+
112+
it("returns {} when there is no owning group and no actor", async () => {
113+
const spy = resolveReturns({ username: "x" });
114+
expect(await gitCredentialEnvsFor(agent({ ownerGroup: null }))).toEqual({});
115+
expect(spy).not.toHaveBeenCalled();
116+
});
117+
118+
it("never throws — a store error degrades to an unauthenticated clone", async () => {
119+
vi.spyOn(gitCredentialStore, "resolve").mockRejectedValue(new Error("mongo down"));
120+
expect(await gitCredentialEnvsFor(agent({}))).toEqual({});
121+
});
122+
123+
it("resolves an https:// URL form to the same host", async () => {
124+
const spy = resolveReturns({ username: "x-access-token" });
125+
await gitCredentialEnvsFor(agent({ source: "https://github.com/acme/private-docs.git" }));
126+
expect(spy).toHaveBeenCalledWith({ ownerGroups: ["acme"], host: "github.com" });
127+
});
128+
});

packages/agentos-server/src/routes/chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ chatRouter.post("/agents/:id/chat-sandbox", authorize("agents:run"), async (req,
6464
const tryCreate = async (sid: string) => {
6565
let body: Record<string, unknown>;
6666
try {
67-
body = sandboxBodyFor(agent, sid, res.locals.principal);
67+
body = await sandboxBodyFor(agent, sid, res.locals.principal);
6868
} catch (err) {
6969
// defaultEnvsFor throws if ANTHROPIC_API_KEY missing for Claude.
7070
const status = (err as any)?.status ?? 503;

packages/agentos-server/src/routes/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ runRouter.post("/agents/:id/run", authorize("agents:run"), async (req, res, next
2121

2222
let body: Record<string, unknown>;
2323
try {
24-
body = runBodyFor(agent, message, res.locals.principal);
24+
body = await runBodyFor(agent, message, res.locals.principal);
2525
} catch (err) {
2626
const status = (err as any)?.status ?? 503;
2727
return res.status(status).json({ error: { code: "AGENT_CONFIG", message: (err as Error).message } });

packages/agentos-server/src/scheduler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { resolveAgent, runBodyFor, type AgentDef } from "./agent-defs.js";
1515
export async function runAgentOnce(agent: AgentDef, prompt: string): Promise<{ ok: boolean; text: string }> {
1616
let body: Record<string, unknown>;
1717
try {
18-
body = runBodyFor(agent, prompt);
18+
body = await runBodyFor(agent, prompt);
1919
} catch (err) {
2020
return { ok: false, text: `bad agent config: ${(err as Error).message}` };
2121
}

0 commit comments

Comments
 (0)