Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/sandbox/__tests__/createSandboxHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -73,6 +77,23 @@ describe("createSandboxHandler", () => {
fakeSandbox() as unknown as Awaited<ReturnType<typeof connectSandbox>>,
);
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 () => {
Expand Down
83 changes: 83 additions & 0 deletions lib/sandbox/__tests__/resolveGitUser.test.ts
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`);
});
});
8 changes: 8 additions & 0 deletions lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: resolveGitUser can throw before your guarded sandbox-provision block, introducing an uncaught failure path for POST /api/sandbox.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/createSandboxHandler.ts, line 90:

<comment>`resolveGitUser` can throw before your guarded sandbox-provision block, introducing an uncaught failure path for `POST /api/sandbox`.</comment>

<file context>
@@ -82,6 +83,12 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
+  // (`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);
+
   let sandbox;
</file context>
Suggested change
const gitUser = await resolveGitUser(auth.accountId);
let gitUser;
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`,
};
}


Comment on lines +86 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard resolveGitUser failures before provisioning.

At Line 90, resolveGitUser is outside the provisioning try/catch. If account/email lookup throws, this request skips your structured error response path.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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);
// 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.
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`,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/sandbox/createSandboxHandler.ts` around lines 86 - 91, The call to
resolveGitUser is currently executed outside the provisioning error handling
path, so any exception it throws bypasses the structured error response; wrap
the resolveGitUser call in the same try/catch used for provisioning (or add a
dedicated try/catch immediately around resolveGitUser) inside
createSandboxHandler, catch and convert failures into the same structured error
response/return used by the provisioning flow (same shape and logging), and only
proceed to call provisionSandbox (or the provisioning logic) when resolveGitUser
succeeds.

let sandbox;
try {
sandbox = await connectSandbox({
Expand All @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions lib/sandbox/resolveGitUser.ts
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate and normalize email with .trim() before accepting it; whitespace-only emails currently bypass the fallback and can produce invalid git config.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/resolveGitUser.ts, line 33:

<comment>Validate and normalize email with `.trim()` before accepting it; whitespace-only emails currently bypass the fallback and can produce invalid git config.</comment>

<file context>
@@ -0,0 +1,39 @@
+  ]);
+
+  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)}`;
</file context>


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Trim and validate the selected email before use.

At Line 33/Line 36, whitespace-only values can pass and be forwarded as gitUser.email. Normalize with trim() and fallback when empty.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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`;
const emailRow = emails.find(
row => typeof row.email === "string" && row.email.trim().length > 0,
);
const name = account?.name?.trim() || `recoupable-${accountId.slice(0, 8)}`;
const email = emailRow?.email?.trim() || `${accountId}@users.noreply.recoupable.com`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/sandbox/resolveGitUser.ts` around lines 33 - 37, The selected email may
be whitespace-only; update the logic that computes email (using emails,
emailRow, accountId, and name) to trim and validate the found email before using
it: derive a trimmedEmail from emailRow?.email, check that trimmedEmail is a
non-empty string, and only then use it; otherwise fall back to the default
`${accountId}@users.noreply.recoupable.com`. Ensure the existing name fallback
using account?.name?.trim() remains unchanged.

return { name, email };
}
Loading