diff --git a/.changeset/fix-dev-session-logging.md b/.changeset/fix-dev-session-logging.md new file mode 100644 index 000000000..a0393cd69 --- /dev/null +++ b/.changeset/fix-dev-session-logging.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add [dev-session] log when auto-binding email in CLI runner; fix TS narrowing in db-reset-dev-owner; remove redundant trim in zeroChangesHint. diff --git a/packages/core/src/scripts/db/exec.ts b/packages/core/src/scripts/db/exec.ts index a1bc61abf..a30afa1a1 100644 --- a/packages/core/src/scripts/db/exec.ts +++ b/packages/core/src/scripts/db/exec.ts @@ -487,7 +487,32 @@ function printResult( if (result.lastInsertRowid && changes > 0) { console.log(`Last Insert Row ID: ${result.lastInsertRowid}`); } + if (changes === 0) { + console.log(zeroChangesHint(sql)); + } + } +} + +/** + * Hint emitted when an UPDATE/DELETE/REPLACE matches zero rows. Matches the + * wording used by db-patch's "no rows matched" error so the agent gets the + * same scoping nudge from both tools — without this hint, the agent reports + * "Changes: 0" as success and the user sees no UI update because the row + * either didn't exist or wasn't visible to the current user under per-user + * scoping. + */ +function zeroChangesHint(sql: string): string { + const upper = sql.toUpperCase(); // leading whitespace already stripped by normalizeUserSql + if (upper.startsWith("INSERT")) { + // INSERT changes=0 means INSERT OR IGNORE skipped a duplicate — different + // failure mode, not a scoping issue. + return "Hint: 0 rows inserted. The row likely violated a UNIQUE / PRIMARY KEY constraint and was skipped (INSERT OR IGNORE)."; } + return ( + "Hint: 0 rows changed. The WHERE clause matched no rows — either the row " + + "doesn't exist, or it exists but is owned by a different user (per-user " + + "and per-org scoping is automatic for db-exec)." + ); } function printBatchResult(results: DbExecResult[], format?: string): void { @@ -542,7 +567,11 @@ function printBatchResult(results: DbExecResult[], format?: string): void { console.log(`[${result.index}] Returned ${result.rows.length} row(s):`); console.log(JSON.stringify(result.rows, null, 2)); } else { - console.log(`[${result.index}] Changes: ${result.changes ?? 0}`); + const changes = Number(result.changes ?? 0); + console.log(`[${result.index}] Changes: ${changes}`); + if (changes === 0) { + console.log(`[${result.index}] ${zeroChangesHint(result.sql)}`); + } } } console.log(`Total changes: ${totalChanges}`); diff --git a/packages/core/src/scripts/db/index.ts b/packages/core/src/scripts/db/index.ts index a9216ea8e..38f334045 100644 --- a/packages/core/src/scripts/db/index.ts +++ b/packages/core/src/scripts/db/index.ts @@ -10,4 +10,6 @@ export const coreDbScripts: Record Promise> = import("./wipe-leaked-builder-keys.js").then((m) => m.default(args)), "db-migrate-user-api-keys": (args) => import("./migrate-user-api-keys.js").then((m) => m.default(args)), + "db-reset-dev-owner": (args) => + import("./reset-dev-owner.js").then((m) => m.default(args)), }; diff --git a/packages/core/src/scripts/db/parameterized.spec.ts b/packages/core/src/scripts/db/parameterized.spec.ts index e6c171397..58fcb72c1 100644 --- a/packages/core/src/scripts/db/parameterized.spec.ts +++ b/packages/core/src/scripts/db/parameterized.spec.ts @@ -45,6 +45,7 @@ describe("db scripts parameterized SQL", () => { } it("passes db-query bind args through to libsql", async () => { + vi.stubEnv("AGENT_USER_EMAIL", "params+qa@test.com"); const execute = vi.fn(async (input: unknown) => { if (typeof input === "object" && input) { return { rows: [["ada"]], columns: ["name"] }; @@ -71,6 +72,7 @@ describe("db scripts parameterized SQL", () => { }); it("passes db-exec bind args through to libsql", async () => { + vi.stubEnv("AGENT_USER_EMAIL", "params+qa@test.com"); const execute = vi.fn(async () => ({ rows: [], columns: [], @@ -97,12 +99,21 @@ describe("db scripts parameterized SQL", () => { }); it("executes db-exec statement batches in one SQLite transaction", async () => { - const execute = vi.fn(async () => ({ - rows: [], - columns: [], - rowsAffected: 1, - lastInsertRowid: undefined, - })); + vi.stubEnv("AGENT_USER_EMAIL", "params+qa@test.com"); + // Return an empty sqlite_master so scoping introspection doesn't generate + // setup views — keeps this test focused on the BEGIN/INSERT/UPDATE/COMMIT + // ordering. The first call is the introspection SELECT that returns []. + const execute = vi.fn(async (input: unknown) => { + if (typeof input === "string" && input.includes("sqlite_master")) { + return { rows: [], columns: [] }; + } + return { + rows: [], + columns: [], + rowsAffected: 1, + lastInsertRowid: undefined, + }; + }); mockSqliteClient(execute); const { default: dbExec } = await import("./exec.js"); @@ -123,16 +134,19 @@ describe("db scripts parameterized SQL", () => { "json", ]); - expect(execute).toHaveBeenNthCalledWith(1, "BEGIN"); - expect(execute).toHaveBeenNthCalledWith(2, { + const txCalls = execute.mock.calls.filter( + ([arg]) => !(typeof arg === "string" && arg.includes("sqlite_master")), + ); + expect(txCalls[0]?.[0]).toBe("BEGIN"); + expect(txCalls[1]?.[0]).toEqual({ sql: "INSERT INTO notes (id, title) VALUES (?, ?)", args: ["note-1", "One"], }); - expect(execute).toHaveBeenNthCalledWith(3, { + expect(txCalls[2]?.[0]).toEqual({ sql: "UPDATE notes SET title = ? WHERE id = ?", args: ["Two", "note-1"], }); - expect(execute).toHaveBeenNthCalledWith(4, "COMMIT"); + expect(txCalls[3]?.[0]).toBe("COMMIT"); }); it("rejects ad-hoc schema changes through db-exec", async () => { diff --git a/packages/core/src/scripts/db/reset-dev-owner.ts b/packages/core/src/scripts/db/reset-dev-owner.ts new file mode 100644 index 000000000..563aa5cf0 --- /dev/null +++ b/packages/core/src/scripts/db/reset-dev-owner.ts @@ -0,0 +1,283 @@ +/** + * Core script: db-reset-dev-owner + * + * One-shot fix for local DBs that accumulated rows owned by the dev + * sentinel `local@localhost`. Pre-changes-53, db-exec / db-query / + * db-patch silently fell back to that owner when no real identity was + * present, so any data created via CLI runs (or by older versions of + * the runner) landed under the sentinel and is now invisible to the + * actual signed-in user. + * + * This script discovers every ownable table (those with an + * `owner_email` column), then re-points each `local@localhost` row to + * the email passed via `--to`. Optionally restricted to a single table + * with `--table`. + * + * Local-dev-only safety: refuses to run when `NODE_ENV=production` or + * when targeting a non-`file:` SQLite URL (no Postgres / Turso / + * shared-DB writes). + * + * Usage: + * pnpm action db-reset-dev-owner --to matthew@builder.io + * pnpm action db-reset-dev-owner --to matthew@builder.io --dry-run + * pnpm action db-reset-dev-owner --to matthew@builder.io --table decks + * pnpm action db-reset-dev-owner --to matthew@builder.io --db ./data/app.db + */ + +import path from "path"; +import { createClient } from "@libsql/client"; +import { getDatabaseUrl, getDatabaseAuthToken } from "../../db/client.js"; +import { parseArgs } from "../utils.js"; + +const DEV_FALLBACK_EMAIL = "local@localhost"; // guard:allow-localhost-fallback — script intentionally targets these rows + +function isPostgresUrl(url: string): boolean { + return url.startsWith("postgres://") || url.startsWith("postgresql://"); +} + +interface Args { + to: string; + table?: string; + dryRun: boolean; + dbPath?: string; +} + +function parseScriptArgs(args: string[]): Args | null { + const parsed = parseArgs(args); + if (parsed.help === "true") return null; + + const to = parsed.to?.trim(); + if (!to || !to.includes("@")) { + console.error( + "Error: --to is required and must look like an email address.", + ); + return null; + } + if (to === DEV_FALLBACK_EMAIL) { + console.error( + `Error: --to cannot be ${DEV_FALLBACK_EMAIL} (that's the sentinel we're fixing).`, + ); + return null; + } + + return { + to, + table: parsed.table?.trim() || undefined, + dryRun: parsed["dry-run"] === "true", + dbPath: parsed.db?.trim() || undefined, + }; +} + +function printHelp(): void { + console.log(`Usage: pnpm action db-reset-dev-owner --to [options] + +Reassigns rows owned by '${DEV_FALLBACK_EMAIL}' to the given email across +every table that has an 'owner_email' column. Use this once when an old +local DB still has rows that the new (post-changes-53) scoping won't show +to the actual signed-in user. + +Required: + --to Target email — usually the address you sign in with locally + +Options: + --table Only reset one table (default: every ownable table) + --dry-run Print what would change without writing + --db SQLite database path (default: DATABASE_URL or ./data/app.db) + --help Show this help message + +Refuses to run when NODE_ENV=production or against a non-local DB URL.`); +} + +export default async function dbResetDevOwner(args: string[]): Promise { + if (args.includes("--help") || args.length === 0) { + printHelp(); + return; + } + + const parsed = parseScriptArgs(args); + if (!parsed) { + // parseScriptArgs already printed the error; exit non-zero. + throw new Error("invalid arguments"); + } + + if (process.env.NODE_ENV === "production") { + console.error( + "Error: refusing to run db-reset-dev-owner with NODE_ENV=production.", + ); + process.exit(1); + } + + // Resolve target DB URL — same precedence as wipe-leaked-builder-keys. + let url: string; + if (parsed.dbPath) { + url = "file:" + path.resolve(parsed.dbPath); + } else if (getDatabaseUrl()) { + url = getDatabaseUrl(); + } else { + url = "file:" + path.resolve(process.cwd(), "data", "app.db"); + } + + const isPostgres = isPostgresUrl(url); + const isLocalSqlite = url.startsWith("file:"); + + if (!isPostgres && !isLocalSqlite) { + console.error( + `Error: refusing to run against shared DB URL ${url}. ` + + "This script is only for local SQLite files.", + ); + process.exit(1); + } + if (isPostgres && process.env.AN_ALLOW_PG_DEV_OWNER_RESET !== "1") { + console.error( + "Error: refusing to run against a Postgres DB. Set " + + "AN_ALLOW_PG_DEV_OWNER_RESET=1 to override (only do this on a " + + "local Postgres you fully own — never on Neon/prod).", + ); + process.exit(1); + } + + const dbLabel = isLocalSqlite + ? url.slice("file:".length) + : (() => { + try { + return new URL(url).host || url; + } catch { + return url; + } + })(); + + console.log( + `[reset-dev-owner] target: ${dbLabel}` + + `${parsed.dryRun ? " (dry-run)" : ""}`, + ); + console.log( + `[reset-dev-owner] reassigning '${DEV_FALLBACK_EMAIL}' → '${parsed.to}'`, + ); + + if (isPostgres) { + await runPostgres(url, parsed); + } else { + await runSqlite(url, parsed); + } +} + +async function runSqlite(url: string, args: Args): Promise { + const client = createClient({ url, authToken: getDatabaseAuthToken() }); + try { + const tables = args.table + ? [args.table] + : await discoverSqliteOwnerTables(client); + + if (tables.length === 0) { + console.log( + "[reset-dev-owner] no tables with owner_email column — nothing to do.", + ); + return; + } + + let totalUpdated = 0; + for (const table of tables) { + const escaped = table.replace(/"/g, '""'); + const countRes = await client.execute({ + sql: `SELECT COUNT(*) AS c FROM "${escaped}" WHERE owner_email = ?`, + args: [DEV_FALLBACK_EMAIL], + }); + const count = Number((countRes.rows[0] as any)?.c ?? 0); + if (count === 0) { + console.log(` ${table}: 0 rows`); + continue; + } + console.log( + ` ${table}: ${count} row(s)${args.dryRun ? " (dry-run)" : ""}`, + ); + if (args.dryRun) continue; + const updateRes = await client.execute({ + sql: `UPDATE "${escaped}" SET owner_email = ? WHERE owner_email = ?`, + args: [args.to, DEV_FALLBACK_EMAIL], + }); + totalUpdated += updateRes.rowsAffected; + } + + console.log( + args.dryRun + ? `[reset-dev-owner] dry-run complete.` + : `[reset-dev-owner] reassigned ${totalUpdated} row(s) across ${tables.length} table(s).`, + ); + } finally { + client.close(); + } +} + +async function runPostgres(url: string, args: Args): Promise { + const { default: pg } = await import("postgres"); + const sql = pg(url); + try { + const tables = args.table + ? [args.table] + : await discoverPostgresOwnerTables(sql); + + if (tables.length === 0) { + console.log( + "[reset-dev-owner] no tables with owner_email column — nothing to do.", + ); + return; + } + + let totalUpdated = 0; + for (const table of tables) { + const countRes = (await sql.unsafe( + `SELECT COUNT(*)::int AS c FROM "${table.replace(/"/g, '""')}" WHERE owner_email = $1`, + [DEV_FALLBACK_EMAIL], + )) as unknown as Array<{ c: number }>; + const count = countRes[0]?.c ?? 0; + if (count === 0) { + console.log(` ${table}: 0 rows`); + continue; + } + console.log( + ` ${table}: ${count} row(s)${args.dryRun ? " (dry-run)" : ""}`, + ); + if (args.dryRun) continue; + const updateRes = (await sql.unsafe( + `UPDATE "${table.replace(/"/g, '""')}" SET owner_email = $1 WHERE owner_email = $2`, + [args.to, DEV_FALLBACK_EMAIL], + )) as unknown as { count?: number }; + totalUpdated += updateRes.count ?? 0; + } + + console.log( + args.dryRun + ? `[reset-dev-owner] dry-run complete.` + : `[reset-dev-owner] reassigned ${totalUpdated} row(s) across ${tables.length} table(s).`, + ); + } finally { + await sql.end(); + } +} + +async function discoverSqliteOwnerTables(client: any): Promise { + const tablesRes = await client.execute( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`, + ); + const out: string[] = []; + for (const row of tablesRes.rows) { + const table = (row.name ?? row[0]) as string; + const escaped = table.replace(/"/g, '""'); + const colsRes = await client.execute(`PRAGMA table_info("${escaped}")`); + const hasOwner = colsRes.rows.some( + (r: any) => (r.name ?? r[1]) === "owner_email", + ); + if (hasOwner) out.push(table); + } + return out; +} + +async function discoverPostgresOwnerTables(sql: any): Promise { + const rows = (await sql` + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'owner_email' + ORDER BY table_name + `) as unknown as Array<{ table_name: string }>; + return Array.from(rows).map((r) => r.table_name); +} diff --git a/packages/core/src/scripts/db/scoping.spec.ts b/packages/core/src/scripts/db/scoping.spec.ts index 8f50b24b1..00a98daf0 100644 --- a/packages/core/src/scripts/db/scoping.spec.ts +++ b/packages/core/src/scripts/db/scoping.spec.ts @@ -36,18 +36,19 @@ describe("scoping", () => { expect(ctx.setup.length).toBeGreaterThan(0); }); - it("returns inactive scoping when there is no request user", async () => { + it("throws when there is no request user (no inactive fallback — would silently land writes with the dev sentinel owner_email)", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("AGENT_USER_EMAIL", ""); const { buildScopingSqlite } = await import("./scoping.js"); const mockClient = { - execute: vi.fn().mockResolvedValue({ rows: [] }), + execute: vi.fn(), }; - const ctx = await buildScopingSqlite(mockClient); - expect(ctx.active).toBe(false); - expect(ctx.userEmail).toBeNull(); + await expect(buildScopingSqlite(mockClient)).rejects.toThrow( + "require an authenticated user identity", + ); + expect(mockClient.execute).not.toHaveBeenCalled(); }); it("builds scoping views for core tables in prod mode", async () => { @@ -298,7 +299,7 @@ describe("scoping", () => { }; await expect(buildScopingSqlite(mockClient)).rejects.toThrow( - "requires a real user identity", + "require an authenticated user identity", ); expect(mockClient.execute).not.toHaveBeenCalled(); }); @@ -346,14 +347,16 @@ describe("scoping", () => { expect(ctx.userEmail).toBe("user+qa@test.com"); }); - it("returns inactive scoping when there is no request user", async () => { + it("throws when there is no request user (matches sqlite path — refuses to run unscoped against a multi-user database)", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("AGENT_USER_EMAIL", ""); const { buildScopingPostgres } = await import("./scoping.js"); - const mockPgSql: any = {}; - const ctx = await buildScopingPostgres(mockPgSql); - expect(ctx.active).toBe(false); + const mockPgSql = vi.fn(); + await expect(buildScopingPostgres(mockPgSql)).rejects.toThrow( + "require an authenticated user identity", + ); + expect(mockPgSql).not.toHaveBeenCalled(); }); it("refuses to scope Postgres DB scripts to the local fallback identity", async () => { @@ -364,7 +367,7 @@ describe("scoping", () => { const mockPgSql = vi.fn(); await expect(buildScopingPostgres(mockPgSql)).rejects.toThrow( - "requires a real user identity", + "require an authenticated user identity", ); expect(mockPgSql).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/scripts/db/scoping.ts b/packages/core/src/scripts/db/scoping.ts index d4a777c4b..da1b838a9 100644 --- a/packages/core/src/scripts/db/scoping.ts +++ b/packages/core/src/scripts/db/scoping.ts @@ -41,11 +41,18 @@ interface ScopedTable { viewSql: string; } -function getUserEmail(): string | null { +function getUserEmail(): string { const userEmail = getRequestUserEmail() || null; - if (userEmail === DEV_FALLBACK_EMAIL) { + if (!userEmail || userEmail === DEV_FALLBACK_EMAIL) { throw new Error( - "DB script scoping requires a real user identity; refusing to run with local@localhost.", + "db-exec / db-query / db-patch require an authenticated user identity. " + + "Easiest fix: open the app at http://localhost:3000 and sign in — " + + "the CLI then auto-loads your session. Otherwise set " + + "AGENT_USER_EMAIL= in the env, or invoke through an HTTP " + + "action that runs under runWithRequestContext. Refusing to run unscoped — " + + "an unscoped UPDATE/DELETE would touch every user's rows, and an " + + "unscoped INSERT would land with the dev sentinel owner and be invisible " + + "to the UI.", ); } return userEmail; @@ -218,21 +225,12 @@ export interface ScopingContext { export async function buildScopingPostgres( pgSql: any, ): Promise { - const inactive: ScopingContext = { - setup: [], - teardown: [], - active: false, - userEmail: null, - orgId: null, - ownerEmailTables: new Set(), - orgIdTables: new Set(), - }; - - // Scoping is always active when there is a request user (dev, preview, and - // prod). Previously this short-circuited outside production, which created - // a cross-user read in dev mode. See audit 05-tools-sandbox.md (C3.d). + // getUserEmail() throws when there is no authenticated user (no request + // context AND no AGENT_USER_EMAIL env) or when it resolves to the dev + // sentinel `local@localhost`. We let that throw propagate: the script + // refuses to run unscoped rather than silently writing rows that the UI + // then can't see, or running an UPDATE/DELETE across every user's data. const userEmail = getUserEmail(); - if (!userEmail) return inactive; const orgId = getOrgId(); const allColumns = await discoverColumnsPostgres(pgSql); @@ -268,21 +266,8 @@ export async function buildScopingPostgres( * Returns setup/teardown SQL to run before/after the user's query. */ export async function buildScopingSqlite(client: any): Promise { - const inactive: ScopingContext = { - setup: [], - teardown: [], - active: false, - userEmail: null, - orgId: null, - ownerEmailTables: new Set(), - orgIdTables: new Set(), - }; - - // Scoping is always active when there is a request user (dev, preview, and - // prod). Previously this short-circuited outside production, which created - // a cross-user read in dev mode. See audit 05-tools-sandbox.md (C3.d). + // See buildScopingPostgres: getUserEmail() throws on no user / dev sentinel. const userEmail = getUserEmail(); - if (!userEmail) return inactive; const orgId = getOrgId(); const allColumns = await discoverColumnsSqlite(client); diff --git a/packages/core/src/scripts/dev-session.spec.ts b/packages/core/src/scripts/dev-session.spec.ts new file mode 100644 index 000000000..def6102a8 --- /dev/null +++ b/packages/core/src/scripts/dev-session.spec.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("resolveDevUserEmail", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("returns AGENT_USER_EMAIL when explicitly set, without touching the DB", async () => { + vi.stubEnv("AGENT_USER_EMAIL", "explicit@test.com"); + const execute = vi.fn(); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBe("explicit@test.com"); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns undefined in production regardless of sessions table", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "production"); + const execute = vi.fn(); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns undefined when AUTH_MODE is set to a non-local mode", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("AUTH_MODE", "google"); + const execute = vi.fn(); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns the latest sessions.email row in dev with AUTH_MODE unset", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi.fn().mockResolvedValue({ + rows: [{ email: "matthew@builder.io" }], + }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBe("matthew@builder.io"); + expect(execute).toHaveBeenCalledOnce(); + const call = execute.mock.calls[0][0]; + expect(call.sql).toContain("FROM sessions"); + expect(call.sql).toContain("ORDER BY created_at DESC"); + // Sentinel must be excluded from the result set + expect(call.args).toEqual(["local@localhost"]); + }); + + it("returns the latest sessions.email row when AUTH_MODE === 'local'", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("AUTH_MODE", "local"); + const execute = vi.fn().mockResolvedValue({ + rows: [{ email: "alice@local" }], + }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBe("alice@local"); + }); + + it("returns undefined when sessions table is empty", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi.fn().mockResolvedValue({ rows: [] }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + }); + + it("returns undefined when sessions table is missing (DB throws)", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi + .fn() + .mockRejectedValue(new Error("no such table: sessions")); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + }); + + it("ignores blank emails in the sessions row", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi.fn().mockResolvedValue({ rows: [{ email: " " }] }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/scripts/dev-session.ts b/packages/core/src/scripts/dev-session.ts new file mode 100644 index 000000000..c263ab9e3 --- /dev/null +++ b/packages/core/src/scripts/dev-session.ts @@ -0,0 +1,82 @@ +/** + * Dev-only session bootstrap for `pnpm action ` (and any other CLI + * caller of `runScript`). + * + * After changes-53, db-exec / db-query / db-patch refuse to run unless + * `getRequestUserEmail()` returns a real identity. In an HTTP request the + * Nitro plugin wraps the handler in `runWithRequestContext({ userEmail })` + * so scoping just works. CLI invocations have no such wrapper, so without + * this helper every db-* CLI run hands the user a stack trace. + * + * What this does: when the runner is about to dispatch, resolve a real + * email by reading the most-recent row from the legacy `sessions` table + * (the same table that `addSession()` writes from google-oauth.ts and the + * A2A receiver fallback already consults). The runner then wraps dispatch + * in `runWithRequestContext({ userEmail })` so the action sees a real + * identity. + * + * SHARED-DEV-BOX CAVEAT: the `SELECT email FROM sessions ORDER BY + * created_at DESC LIMIT 1` query is unscoped — on a machine where + * multiple developers have signed in (or after a `pnpm action …` run + * from another team's app), this will bind to whoever signed in most + * recently across *all* sessions in the DB. If that is wrong, set + * `AGENT_USER_EMAIL=` in your shell or `.env`; explicit env + * always wins. A `[dev-session]` log line is emitted so wrong-binding + * is easy to spot. + * + * Strict gating mirrors the A2A precedent in + * `server/agent-chat-plugin.ts` (search for "latest session"): + * - NODE_ENV !== "production". + * - AUTH_MODE unset or === "local" — don't auto-impersonate when an + * admin or hosted auth mode is in use. + * + * If `process.env.AGENT_USER_EMAIL` is already set we return it unchanged + * — explicit env wins over any DB-derived guess (matches how + * `getRequestUserEmail()` itself behaves). + */ + +const DEV_FALLBACK_EMAIL = "local@localhost"; // guard:allow-localhost-fallback — sentinel intentionally rejected so the resolver doesn't return it + +/** + * Resolve the local dev user's email for the current CLI invocation. + * + * Returns the resolved email, or `undefined` when no real identity is + * available. Callers should let the downstream "no authenticated user" + * error propagate — its message points the user at the two fixes + * (sign in via the running app, or set `AGENT_USER_EMAIL`). + */ +export async function resolveDevUserEmail(): Promise { + const explicit = process.env.AGENT_USER_EMAIL; + if (explicit) return explicit; + + // Hard refusal: this helper must never source identity in prod. + if (process.env.NODE_ENV === "production") return undefined; + + // AUTH_MODE may be unset (default dev shim) or "local". Anything else + // means a non-dev auth mode is in play; don't try to fish a session + // out of the DB on its behalf. + const authMode = process.env.AUTH_MODE; + if (authMode && authMode !== "local") return undefined; + + try { + const { getDbExec } = await import("../db/client.js"); + const { rows } = await getDbExec().execute({ + sql: `SELECT email FROM sessions + WHERE email IS NOT NULL AND email <> ? + ORDER BY created_at DESC LIMIT 1`, + args: [DEV_FALLBACK_EMAIL], + }); + const email = rows[0]?.email as string | undefined; + if (!email || email.trim().length === 0) return undefined; + console.log( + `[dev-session] auto-bound to ${email} (set AGENT_USER_EMAIL to override)`, + ); + return email; + } catch { + // The sessions table doesn't exist yet (fresh install where the web + // server has never booted) or the DB isn't reachable. Either way, + // we can't produce an identity — let the caller throw with the + // friendlier "sign in first" hint. + return undefined; + } +} diff --git a/packages/core/src/scripts/runner.ts b/packages/core/src/scripts/runner.ts index 2d815b439..c4ebe082e 100644 --- a/packages/core/src/scripts/runner.ts +++ b/packages/core/src/scripts/runner.ts @@ -16,6 +16,8 @@ import { pathToFileURL } from "url"; import { coreScripts, getCoreScriptNames } from "./core-scripts.js"; import { closeDbExec } from "../db/client.js"; import { loadEnv } from "./utils.js"; +import { runWithRequestContext } from "../server/request-context.js"; +import { resolveDevUserEmail } from "./dev-session.js"; // Load .env from cwd so DATABASE_URL and other vars are available to all actions. loadEnv(); @@ -81,6 +83,32 @@ export async function runScript(): Promise { const args = process.argv.slice(3); + // Establish a request context for the duration of this CLI run. Without + // it, db-exec / db-query / db-patch and any action that calls + // `getRequestUserEmail()` see no identity and refuse to run. The + // resolver picks up `AGENT_USER_EMAIL` if explicitly set, otherwise + // reads the most-recent signed-in session from the DB (dev-only, + // narrowly gated — see dev-session.ts). + // + // This wrap is intentionally a single point of injection: it covers + // both the local-action branch and the fall-through to core scripts + // (db-query, db-exec, …) so every CLI entrypoint runs scoped to a real + // user. It uses `runWithRequestContext` rather than mutating + // `process.env.AGENT_USER_EMAIL` because env mutation leaks across + // boundaries — see the cautionary comment in + // `server/request-context.ts` about exactly that pattern. + const userEmail = await resolveDevUserEmail(); + const orgId = process.env.AGENT_ORG_ID || undefined; + + return runWithRequestContext({ userEmail, orgId }, () => + dispatchAction(actionName, args), + ); +} + +async function dispatchAction( + actionName: string, + args: string[], +): Promise { // 1. Try local app action first (actions/ then scripts/ for backwards compat) const actionsPath = path.resolve( process.cwd(), diff --git a/templates/analytics/.gitignore b/templates/analytics/.gitignore index 1dbd12ac9..65277e1c6 100644 --- a/templates/analytics/.gitignore +++ b/templates/analytics/.gitignore @@ -46,4 +46,10 @@ build # Migration scratchpad (not for commit) .migration/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/calendar/.gitignore b/templates/calendar/.gitignore index 136aa5048..66aba7f03 100644 --- a/templates/calendar/.gitignore +++ b/templates/calendar/.gitignore @@ -37,10 +37,16 @@ data/.sessions.json data/google-auth.json data/google-accounts/ -# SQLite database files (generated at runtime) -data/app.db -data/app.db-wal -data/app.db-shm +# SQLite database files (generated at runtime) +data/app.db +data/app.db-wal +data/app.db-shm + +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db # Event data (generated at runtime) data/events/ diff --git a/templates/clips/.gitignore b/templates/clips/.gitignore index 295dcf032..446946399 100644 --- a/templates/clips/.gitignore +++ b/templates/clips/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/content/.gitignore b/templates/content/.gitignore index 81553c5a0..28ed7a729 100644 --- a/templates/content/.gitignore +++ b/templates/content/.gitignore @@ -37,6 +37,12 @@ data/app.db data/app.db-shm data/app.db-wal +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + # Ephemeral editor state .editor-selection.json diff --git a/templates/design/.gitignore b/templates/design/.gitignore index 295dcf032..446946399 100644 --- a/templates/design/.gitignore +++ b/templates/design/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/dispatch/.gitignore b/templates/dispatch/.gitignore index 295dcf032..446946399 100644 --- a/templates/dispatch/.gitignore +++ b/templates/dispatch/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/forms/.gitignore b/templates/forms/.gitignore index e9355c75a..cd5008e10 100644 --- a/templates/forms/.gitignore +++ b/templates/forms/.gitignore @@ -33,6 +33,12 @@ data/app.db data/app.db-wal data/app.db-shm +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + # Learnings learnings.md diff --git a/templates/issues/.gitignore b/templates/issues/.gitignore index 7577e7827..17fb2f57c 100644 --- a/templates/issues/.gitignore +++ b/templates/issues/.gitignore @@ -35,6 +35,12 @@ data/settings.json data/.sessions.json data/app.db +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + # Build build/ diff --git a/templates/macros/.gitignore b/templates/macros/.gitignore index 0775a23f1..038a925b9 100644 --- a/templates/macros/.gitignore +++ b/templates/macros/.gitignore @@ -30,6 +30,12 @@ build/ data/app.db .react-router/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .generated/ .vercel/ diff --git a/templates/mail/.gitignore b/templates/mail/.gitignore index bb5bf34f4..b41af1a4d 100644 --- a/templates/mail/.gitignore +++ b/templates/mail/.gitignore @@ -45,11 +45,17 @@ data/google-accounts/ # Uploaded media (images, attachments) data/uploads/ -# Ephemeral data -data/refresh-trigger.json -data/app.db -data/app.db-wal -data/app.db-shm +# Ephemeral data +data/refresh-trigger.json +data/app.db +data/app.db-wal +data/app.db-shm + +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db .claude/scheduled_tasks.lock # Learnings (personal preferences and memory — use learnings.defaults.md for tracked defaults) diff --git a/templates/recruiting/.gitignore b/templates/recruiting/.gitignore index 583b74dc8..fae913df7 100644 --- a/templates/recruiting/.gitignore +++ b/templates/recruiting/.gitignore @@ -13,6 +13,12 @@ data/app.db data/app.db-wal data/app.db-shm +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + build .generated/ diff --git a/templates/scheduling/.gitignore b/templates/scheduling/.gitignore index df1824e85..5c86f3be7 100644 --- a/templates/scheduling/.gitignore +++ b/templates/scheduling/.gitignore @@ -10,4 +10,10 @@ dist .env !.env.example +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/slides/.gitignore b/templates/slides/.gitignore index 5baab3dc6..b8c30d296 100644 --- a/templates/slides/.gitignore +++ b/templates/slides/.gitignore @@ -41,6 +41,16 @@ data/app.db data/app.db-wal data/app.db-shm +# Stray SQLite files at the template root. The real DB lives under data/; +# anything that creates app.db / local.db here is a misconfigured cwd or an +# explicit `--db ./app.db` override. +/app.db +/app.db-shm +/app.db-wal +/local.db +/local.db-shm +/local.db-wal + # Learnings (personal preferences and memory — use learnings.defaults.md for tracked defaults) learnings.md diff --git a/templates/starter/.gitignore b/templates/starter/.gitignore index 295dcf032..446946399 100644 --- a/templates/starter/.gitignore +++ b/templates/starter/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/videos/.gitignore b/templates/videos/.gitignore index 2e15b9538..d864746a2 100644 --- a/templates/videos/.gitignore +++ b/templates/videos/.gitignore @@ -29,10 +29,16 @@ dist-ssr .env .output/ -# SQLite database files -data/app.db -data/app.db-wal -data/app.db-shm +# SQLite database files +data/app.db +data/app.db-wal +data/app.db-shm + +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db # Learnings (personal preferences and memory — use learnings.defaults.md for tracked defaults) learnings.md