-
Notifications
You must be signed in to change notification settings - Fork 9
feat(sandbox): plumb per-account gitUser into POST /api/sandbox #534
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<NextRe | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const startTime = Date.now(); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Per-account `gitUser` controls commit authorship inside the sandbox | ||||||||||||||||||||||||||||||||||||||||
| // (`git config user.name` / `user.email`). The push credential is a | ||||||||||||||||||||||||||||||||||||||||
| // separate hardcoded service token — `gitUser` is purely about who | ||||||||||||||||||||||||||||||||||||||||
| // each commit object is *authored* by. | ||||||||||||||||||||||||||||||||||||||||
| const gitUser = await resolveGitUser(auth.accountId); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+86
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard At Line 90, Proposed fix- const gitUser = await resolveGitUser(auth.accountId);
+ let gitUser: { name: string; email: string };
+ try {
+ gitUser = await resolveGitUser(auth.accountId);
+ } catch (error) {
+ console.error("[createSandboxHandler] resolveGitUser failed:", error);
+ gitUser = {
+ name: `recoupable-${auth.accountId.slice(0, 8)}`,
+ email: `${auth.accountId}@users.noreply.recoupable.com`,
+ };
+ }As per coding guidelines, “Handle errors gracefully”. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| let sandbox; | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| sandbox = await connectSandbox({ | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -100,6 +107,7 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe | |||||||||||||||||||||||||||||||||||||||
| timeout: DEFAULT_TIMEOUT_MS, | ||||||||||||||||||||||||||||||||||||||||
| ports: DEFAULT_PORTS, | ||||||||||||||||||||||||||||||||||||||||
| githubToken: getServiceGithubToken(), | ||||||||||||||||||||||||||||||||||||||||
| gitUser, | ||||||||||||||||||||||||||||||||||||||||
| ...(orgSnapshotId ? { baseSnapshotId: orgSnapshotId } : {}), | ||||||||||||||||||||||||||||||||||||||||
| persistent: !!sandboxName, | ||||||||||||||||||||||||||||||||||||||||
| resume: !!sandboxName, | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,39 @@ | ||||||||||||||||||||||
| import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; | ||||||||||||||||||||||
| import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| interface GitUser { | ||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||
| email: string; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Resolves the per-account `gitUser` identity that the sandbox should | ||||||||||||||||||||||
| * use for `git config user.name` / `user.email`. Driven by: | ||||||||||||||||||||||
| * - `accounts.name` for the display name | ||||||||||||||||||||||
| * - `account_emails.email` for the address | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * When either field is missing, falls back to a synthetic value derived | ||||||||||||||||||||||
| * from the account id. This keeps commit attribution consistent across | ||||||||||||||||||||||
| * sandbox restores without leaking PII when the account row is sparse. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * Note: this controls *commit authorship* only. The push credential | ||||||||||||||||||||||
| * (the GitHub token) is a single hardcoded service token issued via | ||||||||||||||||||||||
| * `getServiceGithubToken` and is unrelated. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @param accountId - The authenticated account id. | ||||||||||||||||||||||
| * @returns A `{ name, email }` pair safe to pass to `connectSandbox`. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export async function resolveGitUser(accountId: string): Promise<GitUser> { | ||||||||||||||||||||||
| 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); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Validate and normalize email with Prompt for AI agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const name = account?.name?.trim() || `recoupable-${accountId.slice(0, 8)}`; | ||||||||||||||||||||||
| const email = emailRow?.email ?? `${accountId}@users.noreply.recoupable.com`; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
Comment on lines
+33
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trim and validate the selected email before use. At Line 33/Line 36, whitespace-only values can pass and be forwarded as Proposed fix- const emailRow = emails.find(row => typeof row.email === "string" && row.email.length > 0);
+ const emailRow = emails.find(
+ row => typeof row.email === "string" && row.email.trim().length > 0,
+ );
@@
- const email = emailRow?.email ?? `${accountId}@users.noreply.recoupable.com`;
+ const email = emailRow?.email?.trim() || `${accountId}@users.noreply.recoupable.com`;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| return { name, email }; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
resolveGitUsercan throw before your guarded sandbox-provision block, introducing an uncaught failure path forPOST /api/sandbox.Prompt for AI agents