From bd4e946356629532933ae59b263639417f117135 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 7 May 2026 18:35:20 -0500 Subject: [PATCH] feat(sandbox): plumb per-account gitUser into POST /api/sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-agents passes a per-user `gitUser = { name, email }` to `connectSandbox`, which the sandbox runtime applies via `git config user.name` / `user.email` so commit objects carry that identity. The push credential (a single hardcoded service GitHub token) is unrelated — `gitUser` is purely about commit *authorship*. The api was the lone gap: it never resolved a `gitUser`, so commits made inside an api-provisioned sandbox would either fail (no author) or fall back to whatever was baked into the snapshot image. Adds `resolveGitUser(accountId)`: - looks up `accounts.name` for the display name - looks up `account_emails.email` for the address - falls back to `recoupable-{accountId.slice(0,8)}` / `{accountId}@users.noreply.recoupable.com` when either is missing - returns `{ name, email }` ready to forward into `connectSandbox` Wires it into `createSandboxHandler` so each request derives its gitUser from the validated `auth.accountId`. TDD: red test for `resolveGitUser` (5 cases — populated values, missing name fallback, missing email fallback, both-missing, null email row), then green; red test for handler integration, then green. Tests 2585 / 2585 pass. Lint + tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/createSandboxHandler.test.ts | 21 +++++ lib/sandbox/__tests__/resolveGitUser.test.ts | 83 +++++++++++++++++++ lib/sandbox/createSandboxHandler.ts | 8 ++ lib/sandbox/resolveGitUser.ts | 39 +++++++++ 4 files changed, 151 insertions(+) create mode 100644 lib/sandbox/__tests__/resolveGitUser.test.ts create mode 100644 lib/sandbox/resolveGitUser.ts diff --git a/lib/sandbox/__tests__/createSandboxHandler.test.ts b/lib/sandbox/__tests__/createSandboxHandler.test.ts index 2bf048a1..c48b0cb5 100644 --- a/lib/sandbox/__tests__/createSandboxHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxHandler.test.ts @@ -10,10 +10,14 @@ import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSk import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow"; import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), })); +vi.mock("@/lib/sandbox/resolveGitUser", () => ({ + resolveGitUser: vi.fn(), +})); vi.mock("@/lib/sandbox/validateCreateSandboxBody", () => ({ validateCreateSandboxBody: vi.fn(), })); @@ -73,6 +77,23 @@ describe("createSandboxHandler", () => { fakeSandbox() as unknown as Awaited>, ); vi.mocked(updateSession).mockResolvedValue({} as any); + vi.mocked(resolveGitUser).mockResolvedValue({ + name: "Ada Lovelace", + email: "ada@example.com", + }); + }); + + it("passes the resolved gitUser through to connectSandbox", async () => { + await createSandboxHandler(makeReq()); + + expect(resolveGitUser).toHaveBeenCalledWith(ACCOUNT_ID); + const args = vi.mocked(connectSandbox).mock.calls[0]?.[0] as { + options?: { gitUser?: { name: string; email: string } }; + }; + expect(args.options?.gitUser).toEqual({ + name: "Ada Lovelace", + email: "ada@example.com", + }); }); it("short-circuits with the validator's response on validation failure", async () => { diff --git a/lib/sandbox/__tests__/resolveGitUser.test.ts b/lib/sandbox/__tests__/resolveGitUser.test.ts new file mode 100644 index 00000000..04aef07f --- /dev/null +++ b/lib/sandbox/__tests__/resolveGitUser.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: vi.fn(), +})); +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +const ACCOUNT_ID = "11111111-2222-3333-4444-555555555555"; + +describe("resolveGitUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the account's name and email when both are populated", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: "Ada Lovelace", timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([ + { id: "e1", account_id: ACCOUNT_ID, email: "ada@example.com", updated_at: "" }, + ] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser).toEqual({ name: "Ada Lovelace", email: "ada@example.com" }); + }); + + it("falls back to a stable synthetic name when the account has no name", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: null, timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([ + { id: "e1", account_id: ACCOUNT_ID, email: "ada@example.com", updated_at: "" }, + ] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser.name).toBe(`recoupable-${ACCOUNT_ID.slice(0, 8)}`); + expect(gitUser.email).toBe("ada@example.com"); + }); + + it("falls back to a noreply email when no account_emails row exists", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: "Ada Lovelace", timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser.name).toBe("Ada Lovelace"); + expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.com`); + }); + + it("falls back on both fields when nothing is on file", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser).toEqual({ + name: `recoupable-${ACCOUNT_ID.slice(0, 8)}`, + email: `${ACCOUNT_ID}@users.noreply.recoupable.com`, + }); + }); + + it("ignores account_emails rows where email is null", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: "Ada Lovelace", timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([ + { id: "e1", account_id: ACCOUNT_ID, email: null, updated_at: "" }, + ] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.com`); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 399126d1..83922877 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -9,6 +9,7 @@ import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow"; import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; @@ -82,6 +83,12 @@ export async function createSandboxHandler(request: NextRequest): Promise { + const [accounts, emails] = await Promise.all([ + selectAccounts(accountId), + selectAccountEmails({ accountIds: accountId }), + ]); + + const account = accounts[0] ?? null; + const emailRow = emails.find(row => typeof row.email === "string" && row.email.length > 0); + + const name = account?.name?.trim() || `recoupable-${accountId.slice(0, 8)}`; + const email = emailRow?.email ?? `${accountId}@users.noreply.recoupable.com`; + + return { name, email }; +}