Skip to content

Latest commit

 

History

History
2633 lines (2088 loc) · 152 KB

File metadata and controls

2633 lines (2088 loc) · 152 KB

Handoff — 2026-07-28

State of the product as of this session. Facts and open questions only; no proposed fixes.

这一晚 — 2026-08-13(三个 agent,125 个文件,全部未提交)

早上好。下面是你需要决定的三件事这一晚改了什么。细节都在各自的 章节里,这一节只做索引。

需要你点头的三件事

  1. 部署。 计划写在 /tmp/commit-plan.md:8 组 commit、逐条 git add、 写好的 message、push 顺序、部署后验证的 curl。先读下面那节「部署前必读」 —— 生产此刻正在泄露私有对话,这批改动里有修复。
  2. CI 补口。 三个 workflow 的 diff 写在 /tmp/ci-gap.md,没有落地(工作区 规则:CI 改动要批准)。核心是:只改 scripts/ 的 PR 目前不跑任何检查, 而 33 个 check 脚本全住在那里。
  3. 两个生产动作:Railway 上设 FRONTEND_URL=https://codefox.sma1lboy.me (不设的话密码重置邮件里的链接指向 localhost);以及查一下存量脏数据 select id from chat where messages like '%"role":"Assistant"%' —— 见下面 role 那条,守卫只挡新写入,不修存量。

修了什么

类别 数量 最该知道的一条
安全 5 匿名任何人能从公开画廊读到每一条私有对话的全文 + 所有者邮箱。生产已实测复现。
竞态 / 数据完整性 9 一个大小写写错的 role永久锁死一个 chat(UI 显示成"项目打不开"),产品里无路可修。
查询效率 2 画廊每张卡都重查一次项目并连带取出它的全部对话,只为读 user 的三个字段:6 张卡 14 次查询 → 2 次。
移动端 3 390px 上 Sign Up 按钮在屏幕外 —— 手机访客注册不了;登录后项目类型和风格选择器同样够不着。
可访问性 2 按 Esc 关弹窗后焦点掉到 <body>,键盘用户位置全丢。修在共享组件,覆盖全部 30 个弹窗。
新能力 7 密码重置闭环、控制台搜索/分页/角色管理、项目笔记可见可编辑、配额、remix 署名、分享页 chrome。

验证到什么程度

  • pnpm check 37/37(其中 ~28 个是这一晚写的;runner 现在跑完全部并列出所有失败, 而不是停在第一个 —— 今晚真出现过"守卫完好但 check 的正则被格式化改坏"的永红)
  • 后端 npx jest 253 tests / 36 suites
  • E2E node scripts/e2e-nodes.mjs 40/40(隔离栈实跑,含 4 个新节点)
  • 双端 tsc 干净,npx turbo build --force 2/2

每一个新增的 check / E2E 节点都实测过"把修复还原后它会变红" —— 改源码、 重新 build、重启后端、跑一遍确认变红再还原。不会失败的检查不算检查。

遗留(按"会不会咬人"排)

  • 存量脏 role 数据:守卫只挡新写入。上面第 3 条那句 SQL 查一下。
  • ignoreBuildErrors: true(frontend/next.config.mjs,先于这一晚存在): 前端类型错误无法让 CI 失败。今晚每个前端改动都靠人手动 tsc 兜住。
  • CI 跑 Node 18,本地 Node 26。这个差异今晚真的抓到过一个可移植性 bug (Dirent.parentPath 是 Node 20.12+),所以我建议保留这个差异而不是对齐。
  • 未走查的面:软键盘遮挡输入框(headless 测不了)、share chrome 的键盘可用性 (它当时还在改)、真读屏软件(VoiceOver)验证。
  • .env 陷阱、schema.gql 启动覆盖、Chrome 三条链路 —— 都写在下面各自的小节。

其它 agent 的遗留清单在它们自己的章节里,这里不复制。

部署前必读 — 2026-08-13 生产冒烟走查(只读)

没有部署任何东西,没有碰生产数据或 env。以下全部是对 codefox.sma1lboy.me / backend-production-88a19.up.railway.app只读探测 结果,加上"如果现在 push 会发生什么"的逐项评估。

🔴 会咬人 — 一个正在流血的洞,现在就在生产上

匿名任何人都能读到生产上每一条私有对话的全文,以及所有者的邮箱。

这不是"部署以后会有的风险",是此刻线上的状态。实测(无 token,一条 GraphQL 查询):

fetchPublicProjects → user → chats → messages { content }

拿到的东西:541898146chen@gmail.com、8 个项目(其中 2 个是私有的, 带 projectPath —— 也就是文件路由用的目录名)、以及聊天标题和真实消息 正文。画廊的项目 id 是公开的,所以入口是敞开的。

原因就是 bug-agent 今晚修的那条:Project.user 挂在 @PublicfetchPublicProjects 上,而 field resolver 不经过任何 guard (fieldResolverEnhancers 没设,APP_GUARD 到不了)。

结论:今晚这批改动不是"可以部署",而是应该尽快部署 —— 修复已经在本地 写好并被 E2E 节点 40 守住(user{chats} / user{projects} / user{chats{messages}} 全部变成 schema 级拒绝)。生产上每多留一天,这个洞 就多敞开一天。

一个残留缺口,修复没有覆盖到:user { email } 仍然可查(chatsprojects 已经摘掉了,email 还在 User 的暴露字段里)。严重性比"读全部 私有对话"低一个量级,但仍然是 PII。已转给 bug-agent。

🟡 要注意 — 部署时会咬人的点

1. adminUsers / adminProjects 是 breaking change,而两端不是原子部署。

返回类型从 [AdminUser!]! 变成 AdminUserPage({ items, total })。 Vercel 前端和 Railway 后端各自独立部署,中间态一定存在。实测了旧前端的原查询 打新后端:

Cannot query field "id" on type "AdminUserPage".

影响面被限制住了:唯一的调用点是 /admin 的 console,只有 Admin 角色 的人会打开它。中间态期间,一个管理员看到的是用户/项目两张表报错,产品的 其他部分完全不受影响(普通用户、画廊、聊天、生成都不碰这两个 query)。

Railway 的部署延迟历来比 Vercel 长(HANDOFF 记录是 25–40 分钟,出现过 70 分钟),所以中间态大概率是新前端 + 旧后端:新前端发 adminUsers(search:…){total items{…}},旧后端不认识 search 参数,同样报错。 两个方向都只伤 /admin

不需要做什么,知道就行:部署后如果打开 console 看到两张表报错,等后端追上 再刷新,不是回归。

2. pnpm check 有 18 个脚本,CI 会卡。

今晚从 9 个涨到 18 个(三个 agent 各自加的)。这些是 CI 步骤,任何一个红了 push 就不算完成。本地当前 18/18 全绿。

3. schema.gql 会被本地后端启动时静默覆盖。

app.module.tsautoSchemaFile 指向 frontend/src/graphql/schema.gql —— 那是一个 checked-in 文件。任何人在 本地起一次后端,它就会被当时的源码状态重写。

这个坑今晚真的踩到了:做安全修复的"坏实现验证"时临时加回一个 @Field() 并重启后端,chats: [Chat!]! 就被写回了那个共享文件, check-public-traversal.mjs 当场变红 —— 而源码其实是对的。

每次跑完本地后端,git diff frontend/src/graphql/schema.gql 看一眼。 它的改动应该只来自你真正改过的 resolver。

🟢 无风险 — 逐项查过,不用管

结论
新 env 变量 一个都没有。整晚的 diff 里没有新增 process.env.* 读取。
新数据库列 / 新表 没有。四个 .model.ts 改动全是 declaration-only:三个是摘掉 @Field()(GraphQL 暴露面,不是数据库),一个是 RefreshToken.userIdnumber 改成 string —— 那一列 TypeORM 建的本来就是 varchar(类型取自上面的 relation),声明一直是错的。不需要 DB_SYNCHRONIZE=true 那套动作。
密码重置的 token 存储 无表。token 用账户当前密码哈希派生的 key 做 HMAC 签名 —— 改密码即失效,天然单次使用,没有要清理的行。
jwt_cache 内存 SQLite,重启即空,没有持久化 schema。确认无影响。
Postgres vs SQLite 生产是 Postgres,新增的 admin 搜索/分页只在 SQLite 上测过,所以专门查了生成的 SQL:搜索是 LOWER(col) LIKE ?(刻意避开了 Postgres-only 的 ILike,参数化,ANSI 标准,两边一致);分页是 TypeORM 的 getManyAndCount,它对带 join 的分页生成 SELECT DISTINCT "distinctAlias"."project_id", "distinctAlias"."project_createdAt" … ORDER BY "distinctAlias"."project_createdAt" —— 排序列被显式选进了 DISTINCT 列表,这正是 Postgres 的硬性要求(for SELECT DISTINCT, ORDER BY expressions must appear in select list)。可移植。大小写不敏感搜索实测通过(搜 DEMO 命中 demo@codefox.test)。
生产 introspection 已关闭(探测确认),所以上面的版本判断是靠行为探测做的,不是 __schema
注册开关 生产仍是 registrationOpen: false。今晚的改动没有碰它。

puppeteer 的 Chrome 从哪来(三条链路,别去后端代码里找)

封面截图和 PDF 导出都走 puppeteer.launch(),而后端代码里没有 executablePath —— 这是对的,不是漏了。PUPPETEER_EXECUTABLE_PATH 是 puppeteer 自己读的环境变量, 应用层不需要转发它。

  • 生产 Railwayrailway.jsonstartCommand 前缀: PUPPETEER_EXECUTABLE_PATH=$(command -v chromium || command -v chromium-browser)。 nixpacks 装的 chromium 不在 puppeteer 的默认缓存路径,这行动态解析后喂给它。
  • 本地 E2E / 后端~/.cache/puppeteer 里的 Chrome for Testing,默认路径,零配置。
  • 本地 visual-qa 脚本 — 显式 executablePath 指向系统 Chrome,CHROME_PATH 可覆盖。

在后端代码里搜 executablePath 搜不到而困惑时,答案是上面第一条: 问题在启动命令那一层解决了。

frontend/.env 会盖掉你传的后端地址

frontend/.env 里硬编码了 NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8080/graphql, 优先级高于命令行传进去的同名环境变量。所以把后端起在 8080 以外的端口做本地 调试时,前端仍然去打 8080 —— 如果那里恰好有一个旧进程在跑(这台机器上就有一个 2026-07-29 的 node dist/main),表现是"登录一直失败 / 数据对不上",而直接 curl 后端一切正常,非常难查。

绕法:临时写一个 frontend/.env.local(Next 里 .env.local 覆盖 .env),用完 删掉。注意这个文件是共享的 —— 多个 agent 同时调试时会互相覆盖。

生产落后程度(行为探测,introspection 关着)

探测 结果 说明
adminSetUserRole Cannot query field 第二轮的角色管理未上线
resetPassword Cannot query field 密码重置未上线
adminUsers{id}(旧数组写法) 通过校验,只报 401 生产仍然是旧的数组返回类型
user{chats{messages}} 返回真实数据 安全修复未上线(见上面的 🔴)

生产 = a419612 或更早。今晚三个 agent 的 ~57 个文件改动一行都没上, 且全部未 commit —— 所以一次 push 会同时触发两个平台的部署。

怎么部署(仍然需要你点头)

按 [[codefox-deploy-paths]]:push 到 main 就是部署,两个平台都是 git-connected。Vercel 认的是根目录的 codefox 项目(它持有域名); frontend/ 下那个 frontend 项目是个失效的陷阱,别去修它。

由于没有新列,不需要 DB_SYNCHRONIZE=true 那一轮开关操作。

Read this first — where the product stands (2026-07-29 03:00)

Two overnight sessions. Everything below is deployed (Railway + Vercel) and covered by the E2E suite (npm run e2e, host mode, 33 nodes).

A project is a page you can send someone. html is the default kind: single-file scaffold, srcdoc preview, no servers, no sandbox wait. A public page gets a real link — /share/<uniqueProjectId>, anonymous, served with a sandbox header because the HTML was written by a model following a stranger's prompt, and multi-page sites resolve their own relative links. Covers are screenshots of the page itself and follow the latest version; the gallery only shows projects that have one.

A turn is a version. Every agent turn commits, the Changes panel reads git, and a wrong turn can be restored — the restore is itself a version, so undo is undoable. The composer stays live mid-turn: messages typed while the agent works queue and steer the next turn.

The planner asks first. A vague prompt gets a question card (checkbox options, hidden composer) instead of a guessed build, and the answer picks a design system the agent then builds against.

Look. Painted hero (canvas sky, light beam, paper foxes), glass capsule topbar, brand favicon, flat agent replies, sections that flow rather than stack. Dark theme is the designed one.

Still waiting on Jackson

  • Vercel sandbox quota 402 — blocks prod Next-mode agent turns. Raise the cap in the Vercel console, then re-run the suite in vercel mode. html projects are unaffected (they live on the host by design).
  • frontend/src/components/sidebar.tsx is dead (no importers) — delete when convenient; deletion needs your say-so.
  • Visual QA in a real browser: question card, changes panel, version history, mobile switcher. The agent-browser daemon wedged both nights, so every screenshot here is puppeteer against a production build on :3001.
  • Before opening registration: html projects run the agent on the host (documented tradeoff) — seed-a-sandbox is the prerequisite.

Where things run

Piece Where Notes
Frontend Vercel, codefox.sma1lboy.me custom domain live, HTTPS issued
Backend Railway, backend-production-88a19.up.railway.app Nest, single container
Database Railway Postgres 1 real account + 1 demo
Model Azure OpenAI gpt-5.4-mini eastus2, DataZoneStandard, 1000K TPM
Project sandbox Vercel microVM switched this session; see "In flight"

The OpenRouter account it previously used is exhausted ($0.07 left). Its key is still valid and still configured nowhere — worth revoking.

The Azure credit is the Microsoft for Startups $1,000, expiring 2026-10-25.

What changed this session

Roughly 40 commits. Three themes.

Security. Seven separate holes, all closed and verified in production: forged tokens were accepted as valid; the file APIs had no authentication at all (anonymous read and write of any project); preview and screenshot were equally open; the screenshot endpoint was a working SSRF proxy; plaintext passwords and live bearer tokens were being written to logs; logging out did not end a session; an unauthenticated endpoint could delete any account by email. Uploads were also accepted on the strength of a claimed MIME type.

Features that were dead. Project covers (broken in three independent places, so no project ever had one), the public gallery (empty by construction), conversation memory (the agent saw only the current message — every follow-up had no referent), stop/abort (leaked an agent process per click), and reply persistence (a turn the user walked away from lost its answer while keeping its file changes).

Resource leaks. Deleting a project left its files, its chats and its dev server behind. Previews were never reclaimed. Every agent turn leaked a symlink; every avatar change leaked a file.

In flight

The sandbox switch is half-verified. Production is on SANDBOX_PROVIDER=vercel. File tree, file read and file write are confirmed working against real migrated projects. Starting a preview returns 500 after about two minutes and has not been diagnosed. Everything else — agent turns, screenshots, download, delete — is verified locally in this mode but not in production.

The four pre-existing projects were copied into sandboxes before the switch; their real generated code is intact (verified by file contents, not just file counts). Their old copies are still on the Railway volume, untouched.

Rolling back is one variable: SANDBOX_PROVIDER=host. The host path is unchanged and was regression-tested end to end.

Two other sessions are editing this same checkout — one on UI polish and streaming output, one on an admin dashboard. Expect churn in frontend/src/components, roles.guard.ts, user.resolver.ts and the build script.

Known open

  • Preview under the remote sandbox (above) — the one thing blocking a clean claim that the switch is done.
  • Registration is closed. It was closed because the agent ran with the backend's own privileges; the sandbox switch is what makes opening it defensible, so this decision should be revisited once the preview works.
  • The site has no favicon. The existing logo is a thin-line mark on transparency and is hard to read at tab size; a fork session is on it.
  • A project directory was once observed emptying itself with no clear cause. Never reproduced, never explained. A watcher is still armed.
  • .agent-runs accumulates session state that nothing reads back.
  • getHello and isValidateProject have no callers.
  • An unreachable "new project" modal exists in the frontend and would fail if reached — it still sends a field the API removed.

Things worth knowing before touching this

  • The Vercel sandbox SDK does not read VERCEL_TOKEN from the environment. Credentials are passed explicitly or come from OIDC.
  • The preview proxy has to be mounted before Nest's router, because Nest answers unmatched paths itself rather than passing them on. It therefore has to decline this server's own routes by hand.
  • Verifying with curl alone missed a whole class of bug this session. The preview cookie only exists in a browser, and its absence hid a failure that made the product unusable. Nothing in this session was checked in a real browser.
  • The shell on this machine exports a malformed NODE_ENV, which the backend rejects at boot. Local runs need it set explicitly.
  • Railway builds fail intermittently on a workspace binary that is not on PATH; a retry usually succeeds.

Credentials in play

VERCEL_TOKEN in Railway is the developer's personal CLI token, taken from the local Vercel CLI login. It carries full account access and is not scoped to this project. It works, but a scoped token generated in the dashboard would be the right thing to replace it with.

Overnight session — 2026-08-12/13 (borrows + failure paths)

27 commits. Two themes, one of which was found by the E2E suite rather than by reading code.

Six borrows from open-design. Every turn's page is linted for design slop and the findings land in the Changes panel. 155 design systems are importable and a page can change its mind — from the toolbar, or from a palette card the agent offers mid-conversation. The composer asks what you are making (landing, dashboard, deck, email, docs, app) rather than which framework; the answer is baked into the scaffolded page as a meta tag and read back on every turn. A page deploys to your own Vercel with your own token. And BYOK-lite: an API key and base URL ride along with a single turn, are never logged and never stored, and the key is deliberately never cached — the harness cache is keyed kind:model, so a cached credentialled harness would hand one user's key to the next. The base URL is SSRF-validated (backend/src/chat/external-url.ts).

The failure-path story, end to end. A turn can now die four ways and all four are bounded. Hard bridge death errors in ~30s (endSession races a 30s timeout — session.stop() goes through doSuspendTurn() to a process that is already gone, with no timeout of its own). A flapping reconnect trips at 90s. Any other silence trips at 5min, armed at stream start and reset by every part, because silence is what is fatal, not duration — a build turn runs 9-10 minutes but emits parts throughout. And the partial reply the user watched stream in survives all of it: splitTurn treats a turn ending on a tool call as "no answer yet", which is right for an abort and wrong for a runtime that died, so a died turn falls back to its whole streamed text.

The common shape underneath all of it: agent.stream() returns the harness's {result, done} and discards done — which is the only thing the adapter rejects when a bridge dies. generate() awaits it; stream() does not. Every hang this session traced back to that discarded promise.

One suspicion is recorded but unconfirmed, in the guard's own comment so whoever upgrades the harness re-tests it: the silent-close shape may be a stall in SandboxChannel's dispatch serialization rather than a lost rejection. enqueue chains onto a single promise (harness/dist/utils/index.js:263, dispatchChain.then(work)) and finalizeClose is enqueued onto it (:178), so a handleIncoming (:267) still awaiting its async parse would park finalizeClose behind it forever. Not chased further — the deadline covers us either way.

Also. Snapshots gate on git status --porcelain rather than changedFiles(). That was a real P0: changedFiles() diffs against the ROOT commit, so after any turn it stays non-empty while the tree is clean vs HEAD — the guard passed, git commit exited 1 with "nothing to commit", the catch returned null, and every caller read that as "nothing to snapshot". Restyle wrote anyway, leaving no version to go back to.

Logout actually ends the session, deleting a project deletes the project, a failed save keeps the edit. The admin console is reachable and its delete asks first. A phone can reach Code and Console.

Known-good check scripts

scripts/check-*.mjs, nine of them, all runnable with bare node and no framework. They exist because most of this session's logic is a branch that only fires when something is already broken, which no ordinary test run reaches. Each asserts against real source text plus a runtime case, and each was verified to fail when its fix is reverted — a check that cannot fail is not a check. Run them all with pnpm check, which is also a CI step on both workflows.

Script Asserts
check-stream-watchdog endSession bounds stop/destroy; one timer serving both deadlines; idle arm at stream start; per-part reset; 5min / 90s constants
check-rescue-save the rescue save survives every await in the finally block
check-partial-answer a died turn falls back to its streamed text; an abort still skips
check-changes-refresh Changes/History refresh gates hold across 6 states
check-preview-readiness readiness is the response, not a second probe; retry reachable
check-question-card the parser survives 11 model-written shapes
check-project-relative sandbox paths stripped from replies, ordinary prose untouched
check-roles-guarded role names served only by the guarded myRoles query

Traps

  • Native bindings are broken since the 2026-08-12 pnpm install. sqlite3 and bcrypt are compiled per Node version; the install left them mismatched, so a fresh backend boot fails with a module-version error that reads like a config problem and is not. The backend on :8080 keeps working only because it is a node dist/main started 2026-07-29 holding the old files by inode — restarting it is what triggers the failure. Rebuild the bindings before you restart anything, and do not assume a running :8080 means a fresh one would come up.
  • NEXT_PUBLIC_* is inlined at build time, not read at runtime (NEXT_PUBLIC_BACKEND_URL, NEXT_PUBLIC_GRAPHQL_URL). Changing one and restarting the server changes nothing — the old value is compiled into the bundle. Rebuild.
  • The frontend builds output: 'standalone' (frontend/next.config.mjs). A standalone build is served by node .next/standalone/server.js, not by next startnext start against a standalone build serves a stale or empty app and looks like a caching bug.
  • /api/* is a Next rewrite proxy to the backend, so a stalled turn has two hops to blame rather than one. Check the backend log first: if it has no [ChatController] line for the turn, the response was never ended and the client is right to be waiting.
  • The reconnect budget in the harness (SandboxChannel, 30s) restarts with every reconnectLoop, and a new loop starts on every socket drop — so a flapping socket resets it forever. Worth filing upstream; the guards above cover us either way.

Open decisions

  • Unauthenticated test-cleanup endpoints (backend/src/user/test-cleanup.controller.ts) — guard them or delete them. They exist for the E2E suite and are reachable in production.
  • Should Stop persist the partial reply? Today it does not: an abort means the user is going to ask again, so a half-answer in history is noise. But it is the same code path as a died turn, which now does persist. Whichever way this goes, the two should stop disagreeing.

Overnight session — 2026-07-28 (feature-death audit)

Every user-facing feature was walked in a real browser against the local stack (vercel-sandbox mode, Azure gpt-5.4-mini). Working and verified: console tab, copy-project-id, public/private toggle, download (zip verified), code edit + save (persists to the sandbox), preview + all its controls, cover screenshots (end-to-end into the gallery card), gallery, chat rename, clear history, settings, theme toggle, Enhance, sidebar nav, new chat/new project.

Fixed this session:

  • First turn from the home page never ran — the prompt was saved server-side and nothing triggered the agent. The chat page now shows a thinking indicator immediately, starts the turn once the project binds, and silently retries (10×6s) while the sandbox is still provisioning — creation races the sandbox and the first attempt is often rejected.
  • Preview pane said "Error initializing preview" / toasted "failed" while the dev server was still booting; now says not-ready and retries every 5s.
  • Delete chat did nothing — the confirm dialog was closed by the closing dropdown in the same tick (Radix race). Opens via setTimeout(0) now; delete verified end-to-end.
  • Dead message buttons (copy/delete/regenerate/thumbs had no onClick, edit only rewrote the local array): copy is real now, the rest were removed.
  • The model picked at creation now sticks: stored on the chat (Chat.model, new nullable column), returned by getChatDetails, and used for the auto-started first turn and later sends. Before, every chat silently ran models[0]. There is still no in-chat model picker (setSelectedModel plumbing exists, no UI).
  • Empty file listings are now errors: a workspace that cannot list (missing host dir, failed sandbox find) throws instead of answering "no files" with a 200. This was the likely mechanism behind "project directory emptied itself": the tree replaced itself with nothing whenever the backend was in the wrong sandbox mode. The sandboxes themselves were intact all along.
  • Download temp zip now unlinks on stream close (covers mid-download disconnects); the orphan duplicate download.controller.ts stays deleted.
  • Removed dead code: getHello (AppResolver), isValidateProject (+ DTO, guard branch), the unreachable new-project modal (+ createNewProject in the context). schema.gql / type.tsx regenerated.
  • Dev-only auth toggle moved out of the bottom-right corner (it sat exactly on the SaveChangesBar's Save button) and its aria-label no longer collides with the Preview tab.

Known open, discovered tonight:

  • The local cli-proxy (port 8317) fails every codex-harness turn: its /v1/responses streaming disconnects ~5s in, both models, while /v1/chat/completions works. The proxy is not part of this repo; the local backend was switched back to the Azure env (values recoverable via railway variables --json; see the session memory).
  • bindProjectAndChat calls connection.synchronize() on every bind — a schema-sync per project creation, on SQLite a full table-recreate dance. Untouched tonight; worth removing deliberately.
  • The models dropdown caches in sessionStorage for an hour, so backend model changes don't show until it expires or storage is cleared.
  • .agent-runs still accumulates unread state (unchanged).

Overnight rounds 2–3 — 2026-07-28 早

Security: the GraphQL chat API had no ownership checks. Any signed-in user could read, rename, clear, delete or inject messages into anyone's chat by id (getChatHistory, getChatDetails, deleteChat, clearChatHistory, updateChatTitle, saveMessage all ran on @JWTAuth() alone). The existing ChatGuard was GraphQL-aware the whole time — it was just never attached. All six now run @UseGuards(JWTAuthGuard, ChatGuard); verified cross-user denial + own-chat access by hand. ChatGuard also no longer 500s on anonymous operations (info.operation.name?.value).

Admin console (user-requested):

  • /admin no longer renders the personal chat sidebar (root-layout path check).
  • New role-gated adminSetProjectPublic + make public/private buttons per project row — the owner-gated updateProjectPublicStatus was why admins "had no permission" to edit visibility. Verified against another user's project, DB confirmed both directions.
  • onDisk no longer brands every sandbox-mode project "no files".

Features added and verified:

  • In-chat model picker (bottombar, next to attach; hidden when only one model is configured). Switching persists via new updateChatModel (guarded, validated against AVAILABLE_MODELS) — reload keeps the choice, the next turn uses it.
  • Regenerate on the trailing assistant reply: new guarded dropLastAssistantReply soft-deletes the stored answer, the turn re-runs via the same path as the auto first turn. Verified: old answer soft-deleted in the chat JSON, replacement saved, UI swaps live.
  • Image attachments verified working end-to-end (agent read a staged image's color correctly); removed the stale "agent cannot read them yet" banner.
  • "Have feedback?" was a dead span; now links to the GitHub issues page.
  • Harness model fallback: a chat carrying a model the endpoint no longer serves falls back to the default instead of 404ing every turn forever (hit this live: only gpt-5.4-mini is actually deployed on the Azure resource — gpt-5.4 appears in /v1/models but 404s as a deployment).

Still open: .agent-runs is written by @ai-sdk/harness itself (924KB/22 dirs, already excluded from listings/zips) — clearing it is a delete-policy call. Prod deploy note: Chat.model column needs one DB_SYNCHRONIZE=true boot or a manual ALTER; picker stays hidden in prod until LLM_MODELS lists >1 real deployment.

Deployed — 2026-07-28 00:31 PT

All of tonight's work (9 commits, d552df8..46e647d) is live in production.

  • Backend: Railway auto-deployed from the push; chat.model column created via a one-boot DB_SYNCHRONIZE=true (verified in Postgres: information_schema shows model | YES), then the flag was set back to false and the service redeployed clean.
  • Frontend: the codefox Vercel project is git-connected and deployed both pushes automatically (aliased to codefox.sma1lboy.me). The separate frontend Vercel project is a stale manual one — its deploys fail on npm install (the repo is a pnpm workspace) and nothing is aliased to it; candidates for deletion. frontend/vercel.json now pins npx pnpm@10 install for anyone deploying that directory standalone.
  • Prod smoke: landing 200, graphql alive, public gallery returns data, anonymous getUserChats denied ("Authorization header is missing" — the default-deny guard is live), models query public, /admin route serves.

GraphQL 与 REST 的取舍:按 Jackson 的判断保留 GraphQL,but the surface is now default-deny (@Public() allowlist: login/register/refresh/checkToken/ confirmEmail/resendConfirmation/fetchPublicProjects/getAvailableModelTags). A forgotten guard now fails closed. Full REST migration remains an option; estimated as a dedicated multi-day effort across ~35 operations + Apollo removal.

Incident + hotfix — 2026-07-28 ~01:10

The default-deny deploy broke the anonymous landing page: an infinite reload loop (black screen). Signed-in users were unaffected, which is why the smoke test missed it — the smoke curled APIs but never rendered the page as a tokenless browser.

Chain: anonymous load → registrationOpen query (not marked @Public — it is not an async method and the annotation sweep only matched async resolvers) → UNAUTHENTICATED → Apollo's tokenRefreshLink treats every auth error as "a session to refresh" → refresh fails (no session ever existed) → clears storage and window.location.href = '/' → reload → repeat.

Fix (e17467b): registrationOpen is @Public; the refresh link only engages when a refreshToken actually exists, and never redirects when already on /. A missed @Public now degrades to a console error on one query instead of taking down the landing. Full resolver audit table checked — everything else is either @Public deliberately or guarded (admin ops are class-level guarded).

Lesson recorded: prod smoke must include "anonymous browser renders the landing" — curl cannot see a client-side reload loop.

Resolution verified 01:20: the anonymous landing renders on codefox.sma1lboy.me (fresh browser profile, hero + Sign In + gallery). The lingering "still black" readings after the fix were an agent-browser artifact — a freshly launched headless session's first navigation can land on about:blank; the second navigation renders. The real loop was confirmed gone from the network log (single document load, graphql 200s) before that.

Round 5 — 2026-07-28 ~01:40

  • Forks were silently empty in sandbox mode (b198775, deployed): the copy step read the host disk, found nothing, and the scaffold fallback handed every fork a fresh template. Forks now zip the source workspace (the download machinery) and unpack it into the fork's sandbox. Verified via API: a fork of the fox-emoji project contains the fox-emoji edit.
  • Sign-up flow works end to end locally; with MAIL_ENABLED=false the backend sensibly lets unverified accounts log in, but the UI still claims "Verification Email Sent" — cosmetic lie, unfixed.
  • Open: after a successful in-browser fork the UI stayed on the workbench with an empty RECENT instead of navigating to the forked chat — mutation, refetch and rows all succeed server-side; needs a fresh browser session to chase (both agent-browser and the Chrome extension degraded tonight, agent-browser first-nav-after-launch lands on about:blank).
  • Throwaway local account auditor@codefox.test / AuditPass!2026 (owns two test forks) — safe to delete.

Round 6 — 2026-07-28 ~01:50

  • fb7c5ca (deployed): the sign-up success screen now asks the backend (emailVerificationRequired, public) whether a verification mail actually goes out. Mail-disabled deploys say "Account created — you can sign in now" instead of pointing the user at an inbox that will stay empty; the resend button and the blue verification box hide too.
  • Browser tooling died for the night (agent-browser daemon wedges + about:blank first-nav; Chrome extension "different extension" errors), so the remaining UI verifications are queued for a fresh session: post-fork navigation, avatar upload, stop button mid-turn, Google OAuth button audit (backend GoogleStrategy exists, frontend shows a Continue-with-Google button — untested end to end).

Round 7 — 2026-07-28 ~02:00

  • 76f562d (deployed): "Continue with Google" hides in both auth modals unless the backend actually holds GOOGLE_CLIENT_ID + GOOGLE_SECRET (new public googleAuthAvailable). Neither local nor prod has them, so the button was a guaranteed dead end (the strategy boots on placeholder credentials). The OAuth plumbing itself is kept — configure the env and the button returns.
  • UI verification for rounds 6–7 is by type-check + pattern parity only: both browser tools were still broken for interaction (agent-browser CDP timeouts; the Chrome extension throws "Cannot access a chrome-extension:// URL of different extension" on click/js — possibly a conflicting extension like Jam injecting frames). Re-verify visually in a fresh session: sign-up copy branch, hidden Google buttons, fork navigation, avatar upload, stop button.

Round 8 — 2026-07-28 ~02:15

  • 0bf2b2b (deployed): removed the SETTING / NEW_PROJECT window-event plumbing. NEW_PROJECT had no dispatcher; SETTING's only listener set chatId to the literal string 'setting' (polling a bogus id) on a page that was being navigated away from anyway.
  • The post-fork "RECENT: Nothing yet" screenshot is explained and benign: the workbench lists CHATS via cache-first, and the login-time cache was empty — fork itself succeeded. The only remaining question is why router.push('/chat?id=…') didn't land; needs a live browser.
  • agent-browser stayed dead even after killing 18 orphaned headless Chrome processes (daemon CDP timeouts persist). Machine likely wants an agent-browser upgrade or a reboot of the tool in daylight.

Round 9 — sandbox cost leak — 2026-07-28 ~02:20

Jackson flagged the Vercel sandbox bill. Findings and actions:

  • Four sandboxes had been RUNNING for hours (2x CPU · 4 GiB each) with future expiry times measured in HOURS despite timeout: 10 min. Root cause: extendTimeout(ms) is cumulative — "extends BY", not "extends TO" — and sandboxHandle() called it on every touch. A busy session (agent turns, file reads, polls) banked +10 min per call; tonight's testing bought one sandbox 4.6 extra hours of paid runtime.
  • Immediate: all four running sandboxes stopped via the SDK (persistent snapshots keep their files; next use resumes them).
  • Fix (5467f56, deployed): the deadline is only topped back up to IDLE_MS when remaining life drops below half — extension is now at most one call per 5 minutes per sandbox, and the ceiling is always ~10 minutes of remaining life. Sandboxes now die ~10 min after the last real touch, which the stopped rows in the dashboard show Vercel honors.
  • Storage note: ~20 stopped sandboxes/snapshots accumulate (mostly tonight's test projects). Deleting them = deleting those projects' files — needs an explicit decision; the admin "delete project" path already reclaims the sandbox when used.

Loop round 1 (cron c24f105f, every 30m until 10:00) — ~02:30

  • Branded the auth-gate LoadingPage (pulsing FoxMark + wordmark, dead theme ternaries removed) — it used to be a generic spinner that read as a broken page whenever auth validation lingered.
  • Chat empty-state copy now describes the product ("edits this project's real files, preview updates beside you") instead of generic chatbot talk.
  • Browser tooling still dead (probe timed out); username editing on settings queued as a next-round direction.

Planner / ask-user-question flow — 2026-07-28 ~03:05 (a81fa2b, deployed)

Jackson's ask: the agent should plan first — confirm what the user wants via structured choices (checkboxes etc.) before actually building, and the composer should step aside while the question is open.

Design: no new API. The system prompt tells the agent that on a project's first message, when real product choices are open, it replies with ONLY a fenced codefox-questions JSON block (intro + 2-4 questions, each 2-5 options, multi flags, user's language). The frontend:

  • extractQuestions() parses the block out of assistant messages (question-card.tsx); prose renders as markdown, the block as a QuestionCard (single = pick one, multi = toggles, optional free-text note, and a "Skip — let the agent decide" escape so a broken card can't dead-end).
  • The card is interactive only as the trailing message; in history it renders read-only. TurnTrail text steps get the block stripped.
  • While an unanswered card is trailing, the ChatBottombar hides behind "Answer the questions above to start building" (inputHidden).
  • Submitting composes "My choices: - label: options…" and sends it through useChatStream.sendMessage → normal turn.

Verified at the API level end to end: "帮我做个网站" → pure question block (4 Chinese questions, one multi); posting "My choices: …" → zero re-asks, 28 tool calls, a matching 高端质感 portfolio built, agent ran npm run build itself. Frontend rendering is tsc/lint-clean but needs a visual pass when a browser is available again (both tools still wedged).

Sidebar removal + planner deploy note — 2026-07-28 ~03:30

Jackson's direction: no sidebar concept at all. Home = project list; a project = chat (left) + preview (right); back arrow top-left. Done in 1d99c05:

  • root-layout renders children bare (SidebarWrapper unmounted everywhere).
  • The floating nav wordmark is always shown and links home; the account menu (UserSettingsBar: settings/logout) moved into the nav's right side.
  • ChatTopbar: back arrow replaces the redundant "+" (both went home).
  • CodeEngine defaults to the Preview tab — the product's promise is the running app, not a file tree. Displaced by the removal, still to re-home: chat rename/clear/delete (lived in sidebar-item; the mutations all work — likely as a ⋯ menu on workbench cards or the chat topbar); sidebar*.tsx files left in place meanwhile.

Deploy gotcha found: the two prior Vercel builds (359ff99 polish, a81fa2b planner UI) FAILED on a react/no-unescaped-entities lint error in the new empty-state copy — prod frontend had silently stayed on 5467f56-era build. Fixed in 1d99c05; lesson: next build locally before push, tsc alone does not gate Next's lint errors.

Next for the right panel (per Jackson, see github.com/nexu-io/open-design): drop the heavy file explorer for a lightweight "changed files" view — the sandbox template is a git clone, so git status --porcelain in the workspace is the cheap honest source; needs a small backend endpoint plus a compact list UI, full tree behind a toggle.

打通夜 (rounds A–C) — 2026-07-28 ~04:00

Jackson: 全部今晚打通。Status:

  • A (bdcb53a, deployed): Code panel leads with a git-backed "Changes" list (new /api/project/changes, changedFiles() on both workspaces, porcelain parser shared in workspace.ts). Full tree behind an "All files" toggle; auto-falls back when a project has no git baseline. Verified: the fox-badge project reports exactly M src/app/page.tsx.
  • B (bdcb53a): chat rename / clear history / delete moved into the chat topbar's ⋯ menu (the sidebar's mutations, re-homed; same Radix setTimeout(0) dialog fix). Pane layout per Jackson: chat rail 18% default (15–45), bubbles got real padding and prose rhythm (70dac9e).
  • C: scripts/e2e-nodes.mjs — the "Dify-style" segmented test: 23 nodes covering register→login→create→planner asks→choices build→changes→file r/w→preview→download→rename→drop-reply→ownership-denied→publish→fork carries files→clear→delete→admin. Zero deps, node 18+, exit 0 = all green. Two nodes run real agent turns (token cost). First full run in progress.

打通 — 23/23 — 2026-07-28 ~05:05

The segmented E2E (scripts/e2e-nodes.mjs) is fully green in host mode: register → login → models → create → bind → model-persist → planner asks → choices build (42 tool calls) → changed-files → file r/w → preview → download → rename → drop-reply → ownership-denied → publish+fork carries files → clear → delete → admin. Run it any time: ADMIN_PASSWORD= node scripts/e2e-nodes.mjs

Fixes the suite forced along the way: turns without a model now land on LLM_DEFAULT_MODEL instead of the CLI's baked-in default (was 404ing); host scaffolds seed a git baseline commit so "changes" answers in both modes; the suite itself had two silent-failure bugs (Role enum case, ChatInputType name) now loud.

OPEN / needs Jackson: Vercel sandbox quota is exhausted — every sandbox API call returns 402, which kills agent turns / preview / files in SANDBOX_PROVIDER=vercel, INCLUDING PRODUCTION. Local dev is switched to host mode meanwhile. Raise the cap or wait for the cycle, then re-run the suite against vercel mode. The extendTimeout cost fix (5467f56) is what stops this recurring at the old rate.

Round: visual QA attempt — 2026-07-28 ~05:20

Both browser tools remain unusable on this machine tonight: agent-browser's daemon answers session list but hangs every page operation; the Chrome extension can render old tab 142 but every interaction throws "Cannot access a chrome-extension:// URL of different extension", and NEW tabs snap back to chrome://newtab after a claimed navigation — something else in that Chrome (Jam?) is reverting automated navigations. Visual QA of tonight's UI (question card, changes panel, sidebar-less layout, 18% rail, prose rhythm, signup copy, hidden Google) is queued for daylight: disable the conflicting extension or agent-browser upgrade, then walk home → create → planner card → build → changes list → preview with screenshots. npm run e2e covers the behavior side already (23/23 in host mode).

Loop round (03:14–03:25)

  • Killed a duplicate cron: the recurring "今晚上你继续补充需要的功能…" message was itself a scheduled job (1fbbbc6e) overlapping the UI-iteration loop (c24f105f, kept). One loop remains, until 10:00.
  • 5876520: project cards on the workbench get a hover ⋯ menu — rename and delete with confirm, the last piece of chat management re-homed from the removed sidebar. Full next-build gated.
  • Observed: vercel ls from repo root now shows a codefox-repo/codefox project building on push — the GitHub org repo appears to have its own Vercel integration besides sma1lboys-projects/codefox (which holds the domain). Worth consolidating in daylight; two builds per push is waste.
  • Browser tools: unchanged (wedged); no visual pass this round.

Message layout restack — 2026-07-28 ~03:35 (Jackson's screenshot feedback)

The 18% rail made the side-by-side avatar column expensive: ~40px of every line went to the avatar gutter. Messages now stack — a small avatar + sender row (You / CodeFox) on top, full-width content underneath; the waiting dots match. List gap widened to compensate for the lost horizontal rhythm.

Loop round (~03:30)

  • 0633412: phones get a stacked chat/App layout with a slim pane switcher (both panes stay mounted so the preview iframe survives switching); the planner's half-streamed codefox-questions JSON no longer flashes as a raw code block — the partial fence is hidden until it closes, in both the plain and TurnTrail renderers.
  • Earlier this hour on Jackson's live feedback: message layout restacked (sender row above full-width content, 3b39d28), workbench card ⋯ rename/delete (5876520), duplicate cron killed.

The light kind — html projects (d18ba1e) — 2026-07-28 ~04:30

Jackson's direction via open-design: most generated sites are a page, not a toolchain — the artifact IS the deliverable. Implemented as a per-project kind, template-as-plugin:

  • Project.template: 'html' (NEW DEFAULT for new projects) | 'next' (the old starter, still selectable). Composer got a Kind select ("Page — self-contained HTML, instant preview" / "Next.js app").
  • html scaffold: one index.html (Tailwind CDN) + git baseline. No npm, no node_modules, no dev server. Scaffolds host-side in every mode.
  • Workspace routing: html projects resolve to HostWorkspace even under SANDBOX_PROVIDER=vercel (their files live on the backend's disk).
  • Agent: html projects run with dedicated instructions (self-contained HTML/Tailwind-CDN, no build tools; planner section shared) and forceHost sandbox. ⚠ that trades microVM isolation away for html projects — fine while registration is closed; seed-a-sandbox is the follow-up before opening it.
  • Preview: srcdoc iframe rendering index.html straight from /api/file — zero servers, instant, quota-immune. 5s light poll + refresh + open-in-tab (blob url). Next projects keep the old dev-server path.
  • Fork inherits the kind. Download zip / changes list / editor all work unchanged (host paths).

Verified end-to-end on host: create(template html) → scaffold 527B starter → concrete prompt built directly (no questions, 10 tools) → index.html 10.8KB with a working 2027 countdown, zero framework references → changes = exactly "M index.html". The srcdoc preview renders that file as-is.

Deploy note: project.template is a new column — DB_SYNCHRONIZE=true was set before the push (same dance as chat.model); flip it back to false after the deploy verifies.

04:10 — E2E 收口:26/26 全绿

The suite gained three html nodes (24 scaffold instantly / 25 turn edits the page and nothing else / 26 changes are exactly index.html). First 26-node run failed 13/15/16 — not a regression: node 07 created its project without a template, and html is now the default, so the "Next-path" assertions (read src/app/page.tsx, dev-server preview, big zip) ran against an html scaffold. Fixed by pinning node 07 to template:'next'; both paths now covered. Full host-mode run: 26/26 green (b47631e pushed).

04:20 — 首页卡片标注项目类型

Home cards now read "2h ago · Page" / "· Next.js" — getUserChats carries project { id template } (user.service loads chats.project alongside; verified live with a throwaway user holding one of each kind). Legacy null templates read as Next.js, matching the preview branch. Also left a ceiling note on the srcdoc preview: relative style.css/script.js would 404; the agent is instructed and E2E-checked to keep pages self-contained, raw-file route is the upgrade path.

04:25 — 第 N 轮:守护 + 文案还账

  • E2E node 09 now also asserts getUserChats carries project.template (guards the relations list in user.service). Suite re-running in background.
  • Mid-turn failure toast was 'Failed to get chat response' + err — raw error concatenated, no space. Now a readable sentence, aborts excluded (the user's own stop is not an error), details to the console log.
  • Landing page copy caught up with reality: "nothing selects between templates yet" was stale — the Kind picker exists. Facts row and limits card now describe both starters truthfully.

05:05 — 第 N+1 轮:落地页 fork 反馈、favicon、tab 标题

  • Anonymous fork click was a silent no-op (router.push('/') while already there) — now a toast says signing in is what unlocks forking.
  • The fox favicon (app/icon.png + apple-icon.png) had been generated but never committed — it ships now, app-router serves it automatically.
  • Browser tabs now read " — CodeFox" (topbar effect, restores on unmount); several open projects were previously identical tabs.
  • Question cards checked: old cards already render non-interactive, no fix needed. agent-browser daemon still wedges on cold start (2min timeout) — same root cause as all night, not retried further.

05:35 — 第 N+2 轮:头像、prod 冒烟

  • Chat messages showed initials even for users with an uploaded avatar — AvatarImage pointed at src="/" (guaranteed 404). Now mediaUrl(avatarUrl) like the settings bar, fallback kept for the avatar-less.
  • Prod smoke: fox favicon serving (200, 9.2KB), Railway latest deploy SUCCESS 04:59 (one FAILED at 04:15 superseded), prod GraphQL alive with registration closed + introspection disabled — both intended.

06:10 — 第 N+3 轮:表单一致性、html zip 实测

  • Home composer hid nothing when only one model exists — the chat composer already hides a one-option picker; the home form now follows the same rule.
  • html project download verified live: real zip (PK magic), named after the project, contains just the page. Kind picker copy reviewed — already explains both starters, left alone.

06:35 — 第 N+4 轮:死链接的呈现

  • /chat?id= used to render an empty composer polling a denied query — now a clear "could not be opened" state with the way home (guarded by !isReady && no messages so a live chat can never trip it).
  • app/not-found.tsx added — unknown routes got Next's unstyled default 404.
  • Settings page confirmed present (settings/page.tsx → UserSetting).

07:00 — 第 N+5 轮:第十个项目的入口

  • The home grid capped at 9 recent projects with no way to reach the rest — the "N total" counter becomes "Show all N" once there are more than nine, toggling the full grid. Admin public/private toggle and the settings page were both reviewed this round and came up clean (wired and honest respectively), left alone.

07:40 — 第 N+6 轮:html 项目的封面

  • Covers only came from the dev-server preview path, which html projects (the default kind) never touch — the gallery would have filled with gray "No preview yet" tiles. Backend: when internalPreviewUrl() is null and the project is html, puppeteer shoots file:///index.html directly (html projects live on the host in every mode). Frontend: HtmlPreview fires the capture once per mount after the page first loads.
  • Verified live end-to-end: fresh html project → /api/screenshot → 200, PNG, 13KB of the starter page.

08:00 — 第 N+7 轮:全面体检轮

  • Magic enhance verified live (regenerateDescription returns a fleshed-out brief for a one-line prompt). Fork chain reviewed — returns the new chat id and lands the user in their copy; the old "fork-navigation mystery" is resolved as working. Railway deploys lag pushes by ~25 minutes; the html- cover deploy should land ~08:05, confirm next round.
  • Root-level QA PNGs are now gitignored (files untouched); git status is down to HANDOFF.md.

08:35 — 第 N+8 轮:首页列表的时效性

  • Creating a project then coming back home showed a stale grid: the list's network-only invalidation flag was only ever set by the (removed) sidebar, leaving cache-first with no invalidator. Now cache-and-network — cache paints instantly, network refreshes behind it — and the skeleton only shows when there is nothing cached, so no flash on revisits.
  • Railway deploy of the html-cover fix confirmed live (07:55 SUCCESS).
  • sidebar.tsx is confirmed dead (no importers) — deletion left for Jackson per the no-unconsented-deletion rule.

09:15 — 第 N+9 轮:瞬态重连不再杀 turn,套件到 27 节点

  • A 25/27 run exposed a real robustness hole: Azure dropped the stream mid-build, the codex harness said "Reconnecting... 1/5" — and the chat controller forwarded that as a fatal error frame, killing a turn that was about to recover (node 18 then had no trailing reply to drop). Reconnect notices are now logged and swallowed; a reconnect that truly fails still ends the stream and hits the existing catch.
  • Node 27 guards the html cover path (screenshot returns a real PNG); the html chat cleanup moved into it. Full rerun after the fix: 27/27.
  • Composer placeholder now says what the box does ("Describe a change — the agent edits the real files").

09:30 — 第 N+10 轮(收尾轮)

  • HANDOFF gained the "Read this first" overnight summary at the top — shipped list, still-waiting-on-Jackson list.
  • Final health check: Railway 09:14 SUCCESS, codefox.sma1lboy.me 200, prod GraphQL alive. The 09:15 transient-fix deploy lands on the usual ~25min lag; it is tsc- and suite-verified locally.

10:25 — loop 结束

Cron deleted per instruction. One loose thread: the 09:15 push (6d3a6ec, reconnect fix + node 27) had not shown a Railway deployment by 10:24 — earlier pushes deployed on a 25-40min lag, this one is 70min late. The change is additive, tsc-clean, and 27/27 locally; if Railway simply missed the webhook, an empty push or dashboard redeploy will pick it up.

10:45 — steering:agent 工作时输入不再锁死

Jackson 起床第一条反馈:streaming 时 composer 被 disabled,用户只能干等。 The harness has no mid-turn injection API, so this is queue-based steering: the textarea stays live while the agent works ("Keep typing — sends when the agent finishes this turn"), Enter/send queues the message, the status row shows "N queued for the next turn" with a drop button, and the moment loadingSubmit falls the queue goes out through sendMessage as the next turn (multiple queued messages join into one). Stop keeps its button; a queue-send button appears beside it when there is text. Image attach stays disabled mid-turn — queued turns are text-only for now.

10:55 — agent 回复去掉气泡

Feedback: the tinted assistant bubble double-framed the question card and read as clutter. Assistant messages now render flat like user ones — the avatar + sender row is the frame. The question card keeps its border and moves to bg-card so it is the one framed element in a reply.

13:20 — 落地页背景画

Reference: coforce.sma1lboy.me's painted hero (oil clouds on linen, paper planes in a light beam). CodeFox gets the same visual language in its own tokens: warm charcoal canvas sky, a soft beam from the upper right, cumulus with ember-lit crowns bottom-right, and a skulk of paper-fox darts climbing the light. Composed as SVG (turbulence-displaced circle clusters, linen weave pattern, grain) and rendered to hero-canvas.jpg (288KB) via the backend's puppeteer — no image model involved, fully reproducible from tmp/hero-canvas.html. Dark theme only by design; masked to fade before the facts row. Local view: production build serving on :3001 (the :3000 dev server wedged overnight and was left untouched per the port rule).

13:40 — 玻璃胶囊导航 + section 过渡

  • Topbar is now a floating liquid-glass capsule: sticky, centered at max-w-1040, rounded-2xl, bg-background/55 + backdrop-blur-xl + hairline border — the painted sky reads through it.
  • Section seams smoothed per feedback: the hero painting's tail now extends under the facts row (mask fades over 1200px), the 3px rule above the numbers is gone, and the "one real turn" band eases in/out via a transparent→secondary→transparent gradient instead of starting as a slab.

13:55 — favicon 换成真 logo

The tab icon was a different cute fox than the brand's FoxMark — replaced with the real mark (angular head, chevron ears, terracotta eyes) on a warm dark rounded tile so it reads on both light and dark tab bars. Rendered from the same SVG geometry as the topbar mark via puppeteer (tmp/favicon.html); apple-icon gets the opaque 180px variant.

14:20 — 玻璃胶囊二轮

The first pass looked like a black slab because the painting started below the nav — the glass had nothing to refract and the seam where paint met body background read as a cut. Fixed by raising the sky 128px above the hero (h-1330, -top-32) so it runs behind the capsule.

The capsule itself: max-w-880, rounded-full, bg-background/30 with backdrop-blur-2xl + saturate-150, and inset shadows for glass thickness (bright refraction line on the top edge, dark one beneath). Inner controls lost their borders — a bordered button inside a bordered capsule was the same double-frame problem as the chat bubble; they are now text/icons with a hover wash, and the single solid fill marks Sign Up as the one primary.

08:50 (7-29) — 你亲手改的代码,被记成了 agent 的功劳(而且会被一起回滚)

编辑器里手改文件不提交任何东西 —— 只有 turn 会提交。于是下一次 turn 的 snapshot 把用户手打的东西一起扫进 agent 那个 commit 里,label 用的是 agent 的 prompt。两个后果:

  1. 回滚到"这一轮之前"会连用户自己写的东西一起扔掉;
  2. 历史把这份工作记在 agent 名下。

实测确认:先手写一个文件,再让 agent 跑一轮 —— 版本历史里只有两条 (baseline + agent 那轮),手写的内容藏在 agent 的 commit 里。

turn 开始前先 snapshot('Your edits')。工作区本来就干净时是 no-op(常见 情况),有待提交内容时就把它变成用户自己的一条版本。实测:手写文件 → 让 agent「重写 index.html 并删掉其他 html 文件」→ 历史变成三条,回滚到 "Your edits" 后那个文件逐字回来了

新节点 36 守这条路:先断言"编辑本身不会自己提交",再断言 turn 之后手写内容 是独立的一条版本、agent 那轮也还有自己的一条,最后回滚验证内容逐字一致。

08:20 (7-29) — 沙箱配额用尽,却告诉用户"模型账户没钱了"

这轮查 turn 失败时用户到底看到什么。explain() 按状态码分类,但沙箱错误 也带 provider 形状的状态码,于是被模型那几条规则抢走了 —— 实测:一个 沙箱配额 402(正是 HANDOFF 里记着的、当前挡住 prod Next-mode turn 的那个 故障)被报成"模型提供方账户没钱了"。指向了错误的厂商,也指向了错误的 待办。另外三种沙箱故障(未配置凭证 / 启动超时 / 连不上)则全部落到通用 的"agent 出错停止了",用户无从判断该不该重试。

沙箱规则放在最前面,因为它们更具体;之后按原因分开:

  • 配额用尽 → "沙箱配额用完了"
  • 缺 VERCEL_PROJECT_ID / TOKEN / OIDC → "这个部署没配置沙箱"
  • 超时 → "沙箱没能及时启动,再试一次"(明确告诉用户值得重试)
  • 其他 → "连不上沙箱"(仍然比通用句子有指向)

模型侧的四条分类一字未动,单测里专门有一条守住"真正的模型 402 仍然报模型"。

验证:10 种错误形状逐一实测分类正确;单测从 4 条加到 9 条,并验证过新增 的 4 条在还原 fix 后会失败。后端单测 84 全绿。

07:50 (7-29) — 问题卡片:模型漏了 id,回答一题就等于回答了全部

planner 的问题卡片是产品的第一印象,但从没验证过。先实测三种 prompt (中/英/中),agent 三次都产出了合法、本地化正确的卡片(各 4 题), 这条链路本身是好的。

问题在解析器:它不要求 id 字段,但组件里所有答案都存在 choices[q.id] 下面,key={q.id} 也用它。模型漏写 id 时两题的 id 都是 undefined —— 选了第一题的选项,第二题也跟着被填上,React key 还会撞。这份 JSON 是 模型写的,id 是建议而不是契约。

解析时改成:模型给的 id 只在"是字符串、非空、且没重复过"时才采用,否则用 位置 q${index} —— 位置才是真正唯一的东西。实测五种形状(无 id / 重复 id / 空字符串 id / 数字 id / 正常 id)现在都是唯一且互相隔离的。

自检脚本见上面的「Known-good check scripts」表(pnpm check)。

07:20 (7-29) — 页面可以导出成 PDF 了

并发那条线挖干净了,这轮换新需求。open-design 主打 "real files, HTML/PDF export",CodeFox 只有 zip —— 而一份 HTML 的 zip 不是任何人会拿去交付的东西。

这不是新增机器,是复用:拍封面的 puppeteer 已经在跑,连"这个项目唯一 合法的渲染地址"的推导、浏览器复用、target closed 后重启浏览器的恢复逻辑 全都现成。抽出 renderTarget()withPage() 两个私有方法后,PDF 就是 同一条管线换最后一步 page.pdf()

  • GET /api/pdf?projectPath=…,读权限(截图是写权限——截图会覆盖封面, 打印什么都不改,所以公开项目谁都能打印)。
  • printBackground: true 是关键:不加的话打印样式表会丢掉所有背景,一个 深色设计打出来就是白纸。实测渲染出来的 PDF 保留了 luxury 的黑金和 Didot 衬线。
  • 只对 html 项目开放:Next 应用打印的是它 dev server 当时恰好在服务的东西, 那不是任何人要的交付物。
  • preview proxy 的 OURS 正则加了 pdf —— 它跑在 Nest 之前,不加就会 在带 preview cookie 时被吞掉(这个坑这轮是第二次踩到了,share 那次一样)。

验证:owner 200 拿到 12.5KB 真 PDF(%PDF- 魔数、2 页、有 content stream)、 陌生人 403、匿名 401、项目转公开后陌生人 200;把 PDF 渲染成图确认不是白纸。 新节点 35 守卫这条路。

06:50 (7-29) — 同时到达的消息会互相覆盖(丢的是用户的对话本身)

沿着上一轮的线索继续查"两个人同时做同一件事":一个 chat 的 messages 是 一个 JSON 列,追加一条消息 = 读出整个数组、push、写回整个数组。

实测:并发保存 6 条消息,存下 2 条,另外 4 条消失 —— 而且每一次调用都 返回了 true。丢的不是计数器,是用户真实的对话内容。

JSON 列没有原子追加,所以写必须排队。ChatService.serialise(chatId, …) 把这些全部串起来:

  • saveMessage —— 读改写,是主犯。
  • dropLastAssistantReply —— 同一列的读改写。
  • clearChatHistory —— 它自己不读旧数组,但一个先开始的追加会在它之后 把旧数组写回去,把"清空"撤销掉。
  • updateChatModel / updateChatTitle —— 这两个看起来无关,但它们 findOne 拿的是整行(包含 messages)再整行 save,所以 turn 进行中改 模型或改标题,会把这次读之后到达的所有消息写没。

存进 map 的 promise 永不 reject(一个失败的写不会卡死这个 chat),队列排空 后条目删除。

验证:同一个探针 —— 修复前 6 发 2 存,修复后 6 发 6 存、无重复 id

06:20 (7-29) — 同时 fork 的人越多,fork 数记得越少

forkProjectsourceProject.subNumber += 1 然后 save —— 这是对内存里 那行的读改写。四个人同时 fork 同一个项目:四次都成功,计数记录为 1。 丢了三个。

这不只是个数字不好看:上一轮刚把 gallery 的 trending 改成按 subNumber 排序,所以一个被少算的项目正是那面墙永远不会展示的项目 —— 越受欢迎、 同时 fork 的人越多、少算得越厉害。

改成 repository.increment(),让数据库做原子自增。实测:修复前 4 次 fork → 计数 1;修复后 4 → 4。

顺手 grep 了仓库里别的 += 1 / -= 1,只剩 admin 里一个本地循环计数器, 不是数据库字段,不受影响。

节点 20 现在顺带断言"一次 fork 记一次"。

05:50 (7-29) — 首页那九个"最近"项目其实是最旧的九个

getUserChats 没有 ORDER BY,而首页把返回的前九个当作 "recent" 展示 (第十个之后收进 "Show all N")。实测确认返回顺序是最旧在前 —— 也就是 说一旦你有第十个项目,新建的那个反而看不见了,而每张卡片还写着 "2h ago"。改成 createdAt DESC

同一个查询还把已删除的 chat 从数据库捞出来再用 JS 过滤掉。删过一百个 项目的用户,此后每次打开首页都要把这一百行重新加载一遍。改成在 where 里 过滤。

这个改动有个陷阱值得记:一旦对关联加了过滤条件,一个所有 chat 都被删掉的 用户就不匹配任何行,findOne 会返回 null —— 而它以前返回的是"用户 + 空列表"。所以补了一条不带关联过滤的回查。实测三种情况:新用户 []、 有一个 chat 时返回它、删掉之后仍然是 [](而不是 null)。

本轮另外两条线是死胡同,记下来免得后面重复走:

  • 下载 zip 里"少了 notes.md" —— 是我解析 unzip -l 时把最后一行切掉了, 实际文件都在。archiver 的 glob 和 ignore 列表都正确。
  • archive()finalize() 在挂 close 监听器之前 await —— 看着像竞态, 实测 close 总在 finalize 之后才触发,监听器接得住。

05:20 (7-29) — 同一个项目上并发两个 turn,会静默吞掉一整轮工作

同一个 chat 上同时发两条消息,两个 agent 会同时在同一个工作目录里跑。 没有任何东西串行化它们。

实测(两条"把整个页面重写成…"同时发出):两个 turn 都回 201、都向用户报告 成功,但版本历史里只有一个 —— 后完成的那个提交的是一棵已经被覆盖过的 树,另一整轮的工作凭空消失,没有任何错误、任何日志、任何提示。用户被告知 两次都成功了。

  • pipeAgent 现在按 projectPath 排队:新 turn 挂在该项目队列的尾部。存进 map 的那个 promise 永不 reject,所以一个失败的 turn 不会把整个项目的队列 卡死;队列排空后条目会被删掉,不会每个项目永远留一个已 settle 的 promise。
  • 排队时会先给客户端写一行"正在等这个项目当前的 turn 结束",而不是让用户 盯着一个安静的流。runTurn 里的 setHeader 因此加了 headersSent 判断。
  • 排队期间客户端挂断的话不再启动 agent。

验证:同一个探针,修复前 = 2 个 turn / 1 个版本(丢了一轮);修复后 = 第二个 turn 报告 queued: true,3 个版本全在(baseline + 两轮)。

04:50 (7-29) — 聊天附件走的是另一条路,而那条路没有任何检查

头像上传会限制 5MB 并嗅探magic bytes(客户端唯一无法撒谎的东西)。 聊天里贴的图片走的是完全不同的一条路——base64 data URL 而不是 multipart ——那条路两样都没有:扩展名直接取自客户端声称的 mime,字节从来没人看过。

实测:发 6MB 的 AAAA… 并声称 image/png,HTTP 201,文件以 .png 落盘, 6144KB,然后 agent 被告知"读这张图片"。数量上限是 4 张,单张没有上限, 25MB 的 body 上限装得下四张 6MB。

  • sniff 从 file_check 导出(聊天附件走另一条路,但需要同一个答案), 补上 GIF ——聊天允许 gif,嗅探器得能叫出它的名字。
  • decodeImage 现在:先按 base64 长度估算大小再解码(超限的附件不必 先在内存里materialise出来),然后嗅探,文件名按实际类型取扩展名。
  • 顺带把导出 sniff 带来的口子堵上:头像路径原本只要"能认出来"就放行, 加了 GIF 之后一个改名成 .png 的 gif 就会通过——现在改成对照白名单校验。

验证:4 个 sniff 单测(含 6MB 单字节 blob、<script>、PDF 头、太短的 buffer、RIFF/WAVE 不能冒充 WebP);真实 API 四发四态——6MB blob 和伪装成 png 的脚本都没有落盘,真 png 落盘,声称是 png 的 gif 以 .gif 落盘。

04:20 (7-29) — 停用账号此前只是一句建议

admin 控制台有个 "set user active" 开关。登录会检查 isActive,但别的 地方都不检查 —— 实测确认(直接改数据库然后走真实 API):

  1. 新登录 → Invalid credentials
  2. 停用前签发的 token → 照常工作
  3. refresh → 高高兴兴发一个新 access token

也就是说封禁一个已登录的用户在最长 7 天(refresh token 的寿命)内完全 没有效果。这不是理论问题:admin 面板上那个按钮看起来生效了,实际什么 都没做。

  • JWTAuthGuard 现在除了验签名和查 token 没被登出之外,还按主键读一次 User 行,账号关闭就拒。这是在这个 guard 已有的一次 DB 读旁边再加一次主键 查询——值得:不加的话"停用"是建议而不是控制。
  • refresh 路径同样检查,并且删掉那个 refresh token ——它才是那个会继续 working 的东西。
  • guard 的依赖放进 JwtCacheModule(它本来就被每个挂这个 guard 的模块 import)。在每个模块各自注册 User repo 太脆:新模块能编译通过然后在启动 时炸——这个坑我在这轮就踩到了(PromptToolModule)。

验证:同一个探针脚本,修复后三项全部变成 "This account is no longer active";E2E 全绿说明正常用户不受影响。

03:50 (7-29) — trending 一直只返回一个;删除项目会永久漏一张封面

gallery 的 trending 策略在生产上是坏的。 它取 ceil(总数 * 0.01) 再和调用方要求的 size 取小 —— 生产上 48 个公开项目, ceil(0.48) = 1,所以要 6 个只回 1 个。实测生产:latest 回 5、 trending 回 1。这个公式要到 600 个项目以上才不再退化,也就是说它在产品 存在过的每一个规模上都是坏的。前端因此从来没用过 trending(grep 无结果) ——一个从来不返回有用结果的策略,自然没人接。改成按 fork 数、再按时间 排序,size 就是唯一上限。

删除项目永久泄漏它的封面。 删除会回收文件(~1GB 的依赖树)和聊天, 但从不 unlink 那张截图 PNG,于是每次删除都在卷上留下一个再也没人指向的 文件。复用换封面时那条规则(staleMediaPath):fork 会继承 photoUrl, 所以只在没有别的行指着它时才删。注意这里的算术 —— 项目行此时已经被标记 删除,所以 count 数的是其他行,而 staleMediaPath 把调用者也算进去, 因此传 others + 1

验证:新节点 34 守卫 trending(和 latest 返回同样数量、按 fork 数降序、 不超过 size);删除路径的算术直接实测(无人共享 → 删,一个 fork 共享 → 留,两个 → 留,没有封面 → 不动,traversal → 拒绝)。

03:20 (7-29) — Console 标签页对默认项目类型不再说谎

Console 标签页显示的是 dev server 的输出,但 html 项目没有 dev server 也永远不会有。于是对产品的默认类型,这个标签页只会说"打开 Preview 标签 页来启动 dev server"——一个不存在的服务器。实测:/api/preview/logs 对 html 项目回 200 但永远是空的。

与此同时 agent 会写内联 <script>,那些脚本抛错时用户完全看不到。

  • lib/page-console.ts:预览 iframe 是 same-origin,所以页面自己的 console 可以从父窗口读到。包裹 frame 的 console 方法而不是只听 error 事件——未捕获错误有 onerror,但 console.log 没有任何事件,而"页面 打印了什么"正是打开这个标签页的主要理由。同时接 errorunhandledrejection。原始 console 照常收到输出:devtools 仍是更全的视图, 在这里吞掉会让这个标签页变成降级。
  • 存在模块级 store 而不是 React state:写入方(预览面板)和读取方 (Console 标签页)从来不在同一棵子树里,而且值得看的错误通常发生在有人 打开那个标签页之前。上限 300 行——渲染死循环的页面不能把它撑爆。
  • Console 标签页按项目类型分支:html 显示 "Page console" 并带 Clear; 空态文案说的是实话("这个页面打印的东西和它的脚本抛的错会出现在这里")。

验证:puppeteer 实测同构场景(父窗口 + same-origin srcdoc iframe), console.log / console.error / setTimeout 里抛出的未捕获 TypeError 三者全部捕获到。这类行为 curl 看不见——HANDOFF 记过这个教训。

02:50 (7-29) — 多页站点:agent 被要求建的链接,产品终于能打开了

agent 的 instructions 明确写着"页面需要更多结构就加一个 .html 文件并链 过去",但分享路由和预览面板都写死只读 index.html —— 于是 agent 照做 写出的每一个 <a href="about.html"> 都是死链。三个面(share / 预览 / 封面截图)全都只认 index.html。实测确认:写一个 about.html, /share/<id>/about.html 回 404。

  • 分享路由改成 @Get([':id', ':id/*'])(Nest 10 + Express 4 下 *path 形式静默匹配不到任何东西,所以是两条路由而不是可选段)。
  • shared-page-path.ts 把公开 url 解析成要读的文件。这是把匿名的、 攻击者可控的 url 变成一次磁盘读取,所以刻意收窄: 只服务 .html —— 这条路由以 text/html 回应且不需要会话,项目里 别的文件(agent 写的 .env、owner 留的笔记)不因为项目公开就一起公开; 最多一层目录;normalize 后越界直接拒绝而不是钳制;反斜杠、NUL、 双斜杠、./..、畸形百分号编码全拒。8 个单测。
  • 预览面板现在跟随站内链接:iframe 是 sandbox 且没有 allow-top-navigation, 所以点相对链接什么都不会发生——以前看起来就是个死链。现在拦截点击 换页,顶栏显示当前页名并给出返回首页的箭头。绝对 url 和锚点不拦。 封面只在首页时拍(一张 "About" 的封面是错的)。

验证:节点 33 扩展(写 about.html + notes.md → 链接页 200 且内容正确 → notes.md / ../../etc/passwd / ..%2f..%2f 全 404);另测嵌套页 pages/deep.html 200、同项目里的 secret.md 404。

02:20 (7-29) — 用户名可以改了(设置页自己承认的半成品)

设置页上写着 "Username — Not editable yet",代码注释也直说 API 还没有这个 mutation。这是产品里唯一一个自己承认没做完的地方,也是 HANDOFF 早前记过的 待办方向。

  • updateUsername(guarded)。用户名会出现在 gallery 的公开卡片上,所以 校验是认真做的:折叠空白后 3–32 字符、拒绝 `< > / \ @ ' " `` 和控制字符、拒绝已被别人占用的名字。
  • 列上没有 unique 约束,而且从来没有过,所以这里不假装能保证唯一性 ——加约束的 migration 会在现存重复数据上直接失败。它保证的是:一次改名 绝不会新造一个与他人重名的冲突,这才是真会撞到的情况。
  • 改自己已有的名字不走占用检查(否则"保存我现在的名字"会报"已被占用")。
  • UI:设置页那个 read-only chip 换成可编辑输入框,失焦或回车即存,Esc 还原 ——一个字段配一个 Save 按钮比这次改动本身还重。错误直接显示服务端说的话, 不在前端复制一份规则(那注定会漂移)。

验证:9 个单测 + 真实 API 全流程(改名生效并持久化、另一用户抢同名被拒、 改回自己的名字通过、过短/markup/纯空白被拒、首尾空白被折叠、匿名 401)。

顺手把长期红着的 3 个单测修好了,后端套件首次全绿 67/67(此前只有跑 jest src/project/ 才是绿的,跑全量一直是 3 红):

  • user.service.spec.ts 的测试模块缺 UploadService——头像上传搬进 upload service 时服务多了这个依赖,测试模块没跟着改,于是它编译失败、 这个套件一直红。
  • register-user.input.spec.ts 的四个用例都写在 confirmPassword 加进 DTO 之前:不设这个字段,它永远第一个报错,所以"合法输入"用例从来就不 合法,而 email 用例断言的 errors[0] 实际是 confirmPassword 的错误。 顺便把 errors[0] 改成按 property 查找——字段校验顺序不是 DTO 的契约。

01:50 (7-29) — 分享链接在生产验证 + 有了链接预览

先确认上一轮的分享功能在生产上是活的:Jackson 的真实页面 codefox.sma1lboy.me/share/2fc8ed27-… 匿名 200、3296 字节真页面,CSP sandbox 穿过 Vercel rewrite 完好,一个 Next 项目的 id 正确回 404。

然后补上它缺的一半:链接贴进 Slack / iMessage / 推特是一条光秃秃的 url ——生成的页面只有 agent 写的 <title>,没有名字、没有描述、没有图。而封面 截图本来就存在、本来就公开可取(/api/media/... 实测 200),纯粹是没接。

social-card.ts:服务时往 <head> 顶部注入 og / twitter 标签。

  • 不覆盖页面已经声明的:agent 可能自己写了 og:tag,那是作者的意图。
  • og: 和 twitter: 视为同一件事的两种拼写——写这个测试时抓到一个真 bug: 页面只声明了 og:title 时,我们仍然注入了 twitter:title,于是同一个 链接在 Slack 显示作者的标题、在推特显示项目行名。现在互为孪生的标签 任一被声明,两个都跳过。
  • 图片地址用 x-forwarded-host(访客真正访问的域名),不是后端自己的 hostname——后者在 rewrite 之后是 Railway 子域。这个 header 会决定一个进 公开页面的 url,所以只接受 host 形状的值。
  • 项目名是用户输入且进 HTML 属性,转义("><script> 有专门的测试)。
  • 没有 <head> 的页面原样返回,不围绕假设去重建它。

验证:11 个单测 + 真实路由实测(标签只注入一次、页面 body 和 luxury token 完好);节点 33 扩展为同时守卫预览卡片和"卡片图片必须指向产品域名"。

01:25 (7-29) — 生成的页面终于可以给别人看了

之前一个 public 项目只能被 fork,不能被:gallery 上是一张截图, 预览是 srcdoc iframe,连"在新标签打开"都是 blob url——关掉就没了。没有 任何一个链接能发给别人。 而"把做出来的东西发出去"基本上就是人做它的理由。

  • 新的 GET /share/:id(share.controller.ts),@Public(),匿名可读。
  • id 用 uniqueProjectId 而不是 projectPath:目录名是所有需要鉴权的 文件路由的 key,把它印在公开 url 里等于邀请别人去试那些路由。这个 uuid 本来就在行上,而且对文件 API 毫无意义。顺手把 gallery 查询里的 projectPath 换掉——它一直被取出来发给匿名访客,却没有任何地方用它。
  • 页面是这个 origin 上的不可信 HTML(模型按陌生人的 prompt 写的),响应 带 Content-Security-Policy: sandbox allow-scripts allow-forms——脚本能跑 (那是产品本身),但拿不到打开它的人的 cookie 和 storage。
  • 只服务 html 项目:Next 应用没有单个可服务的文件,给它一个 share 链接只能 404。不存在 / 非 public / 非 html 全部回同一句"This page is not shared." ——能区分它们的错误页就是一个探测私有项目是否存在的接口。
  • next.config/share 也 rewrite 到后端,所以链接穿产品自己的域名, 而不是 Railway 的子域。preview proxy 的 OURS 正则也补上了 share ——它跑在 Nest 之前,不加就会在带 preview cookie 时被吞掉(HANDOFF 记过 的老坑)。
  • UI:gallery 的封面变成指向真实页面的链接(Next 项目保持普通 tile,不给 死链);工具栏在项目 public 且是 html 时出现 Share 按钮,复制完整 url。

验证(节点 33 + 手工全覆盖):私有 404 → 发布后匿名 200 且是真页面 → neon token 还在 → CSP sandbox 就位 → 拿 projectPath 当 id 404 → 乱码 404 → 未知 uuid 404 → public 的 Next 项目 404。

01:05 (7-29) — E2E 补到 32 节点,并因此抓到一个竞态

给套件补上新功能的守卫节点(28 设计系统、29 版本、30 回滚、31 回滚拒绝 非法输入),第一次跑就抓到一个真 bug:

  • snapshot 发生在 res.end() 之后,所以客户端在流关闭那一刻去查历史 ——而这正是 UI 刷新的时机——会看不到刚结束的那一轮。节点 29 读到 1 个 版本,而我事后手动探测同一个项目是 2 个,时间戳证实 commit 写在节点 29/30 查询之后。现在 snapshot 排在 endSession 之后、res.end() 之前。
  • 节点 28 的价值:它验证的是风格能活过 agent 的重写——节点 25 的真实 turn 把页面整个改掉之后,neon 的 #070711 仍在页面里。
  • 第二次跑又抓到一个我自己引入的回归:每轮 turn 提交之后,Changes 面板 空了——"改了什么"一直是拿工作区和 HEAD 比,而现在 HEAD 跟着 agent 走, 于是刚填满面板的那一轮把它自己清空了。改成跟第一个 commit(starter) 比:git diff --name-status <root> HEAD 得到已提交的偏离,再并上 porcelain 的未提交部分(parseNameStatus + mergeChanges,9 个单测)。 一个文件如果是 agent 新建的、后来又改过,相对 starter 仍然算 added。
  • 节点 30 也顺势加强了:原本断言存在 "Before restore" 这个 label——这是实现 细节,而且 turn 会提交之后它通常是 no-op。现在改成验证往返:回滚后 再滚回去,文件内容必须逐字回到回滚前。
  • 顺手修掉套件自己的环境依赖:节点 19 原本借用部署的 admin 账号来当"另一 个用户",于是任何没有 seed 该账号的数据库上,19/20/23 三个节点全挂。 19 现在自己注册第二个账号;23 是唯一真正需要 admin 的,它先验证非 admin 被拒(这本来就是它该测的一半),admin 那一半在没有该账号时明确 跳过而不是让整轮失败。

媒体文件删除统一到一处(cover.tsmedia-file.ts):头像换新时的 unlink 既没有共享检查(两个用户指向同一 url 时会删掉对方的),也没有目录 约束——url 反推成路径,而"删掉这一列里的路径"离"删掉磁盘上任何东西"只差 一个坏掉的列。staleMediaPath 现在同时负责两件事:别的行还指着就不删, 解析结果必须落在 media 目录内(5 个单测覆盖 ../ 穿越)。

00:45 (7-29) — 版本历史与回滚

agent 走错一步之前没有任何退路:每个项目只有一个 commit(它的 baseline), 之后所有轮次的改动全堆在同一个未提交的 diff 里。而项目本来就是 git 仓库 (changedFiles() 一直在用 porcelain)——回滚是这套机器的自然延伸,不需要 新的存储。

  • workspace 接口加三个方法:snapshot(label) / versions() / restore(id), host 和 vercel 两种实现都有(sandbox 侧用一次 shell round trip)。
  • 每轮 agent turn 结束后自动 snapshot,label 就是用户自己的 prompt (截断 72 字)。放在 session 结束之后,所以 agent 最后的写入已经落盘; 失败的 turn 也 snapshot——那些改动是真的,而且正是最想撤销的东西。 没有改动就不提交(返回 null)。
  • restore 自身可撤销:回滚前先把当前状态存成一个版本("Before restore"), 然后 checkout <sha> -- . —— 移动文件但不动 HEAD,历史保持线性, detached HEAD 会让之后所有 snapshot 变成孤儿。
  • 新接口 GET /api/project/versionsPOST /api/project/restore (写权限:公开项目的只读访问者不能改别人的文件)。versionId 只接受 sha 正则——这个字符串会进命令行。
  • Code 面板第三个视图 History:每个版本一行(prompt + 相对时间), hover 出 Restore。回滚后清空编辑器选中(文件内容已经不是它了)、刷新 changes 和历史,成功/失败都有 toast。

验证:versions.spec.ts 6 个单测(含 label 里含分隔符、HEAD 带换行、 畸形行不会凭空造版本);直接对 HostWorkspace 跑完整流程(两次 snapshot → restore → 文件确实回到第一版 → 历史多出 Restored 条目);再通过真实 API 走 一遍(建 neon 项目 → 写文件 → restore 到 baseline → 文件回到 starter 且 neon token 还在 → 历史 = Restored/Before restore/starter baseline); 注入 ; rm -rf / 得 400,缺 versionId 得 400,匿名得 401。 backend tsc 干净、DI 启动干净、frontend build 干净、16 个测试全绿。

00:15 (7-29) — 页面有了设计系统(借 open-design 的 token 契约)

生成的页面此前全部落在同一套默认深色 Tailwind 上——agent 没有任何风格锚点, 于是每个项目自己发明一套外观,没有一套显得是有意为之的。

nexu-io/open-design (Apache-2.0, 82k stars) 有 153 套 design system,全部绑定 同一组 CSS 变量——这个 token 契约就是可以直接借来不用白做的东西。取其中 风格跨度最大的 8 套(Editorial / Product / Minimal / Brutalist / Luxury / Neon / Glass / Retro),值是它们的。

  • backend/src/project/design-systems.ts(新):一张表 = 一套系统。加一套就是 加一行。
  • scaffold 把 tokens 直接烤进 index.html 的 :root风格活在生成的文件里, 不存 DB —— 所以没有新列、没有 migration、不用再跳一次 DB_SYNCHRONIZE 的 上线舞蹈。
  • agent instructions:html 项目被告知 :root 是页面的风格契约,要用 var(--accent) / var(--text-3xl) 而不是自己挑颜色;新加的页面沿用同一个 :root 块;换风格要改 :root 的值(一处重设全站),绝不在标签里写死 hex。
  • 新 public query designSystems 只回 id/name/blurb + 三个色板值——tokens 留在 服务端,加系统不会给浏览器多塞一份。composer 的 Style 选择器用色点展示, 只在 Page 类型下出现(Next starter 自带风格)。

验证:8 套 × 12 个契约 token 的单测 + 未知 id 回落(design-systems.spec.ts, 4 绿);真实 scaffold 四种风格并 puppeteer 截图,neon 深紫、brutalist 米底黑 Arial Black、luxury 黑金,bogus-id 回落 Editorial——确实各不相同。backend tsc 干净,frontend tsc 干净,schema.gql + type.tsx 已重新生成。

00:15 (7-29) — 同轮的 bug 修复

上一条提交 (793e812) 让 prod 前端从 23:05 起一直构建失败。 gallery 空态 文案带了个 prettier/prettier 错误,next build 直接 Failed to compile —— 正是 HANDOFF 里记过的那个事故模式(tsc 干净 ≠ build 干净,Vercel 静默停在旧 版本)。已修,build 恢复绿。

PreviewService 三处泄漏(preview.service.ts + 新 preview.service.spec.ts, 3 绿):

  • 并发 start 各自 boot 一个 dev server——map 在 spawn 返回前是空的,两个同时 到达的请求都漏过了已存在检查,第二个覆盖第一个,孤儿 Next 进程再没人能停 (idle sweep / delete / module destroy 都找不到它)。现在 in-flight 的 start 记在 starting map 里,后来者复用同一个 promise。
  • exit handler 无条件 delete:重启会在旧进程咽气前就换掉 entry,于是新起的 dev server 被从 map 里删掉,同样变成没人管的进程。现在只在 map 仍指向这个 child 时才删。
  • boot 失败的 preview 留在 map 里,ready 永久 rejected,之后每次 start 都 短路在它上面——预览一直坏到进程重启为止,哪怕 dev server 其实几秒后就起来了。 现在失败即清理 + SIGTERM,下次请求重新起一个。

html 项目的 fork 在 vercel 模式下是空的(project.service.ts):fork 只按 sandbox mode 分支,但 html 的文件在两种模式下都只住在 host —— 这正是 WorkspaceService 的判断。于是 html fork 被解包进一个没人会读的 microVM,而它 自己的 host 目录压根没建,打开就是没有文件。现在 fork 的分支条件跟 WorkspaceService 对齐。

fork 一个空的 html 项目会拿到 Next 脚手架(scaffold.ts):copyProject 在源目录没有可拷贝内容时回落到 scaffoldProject——那是 Next 的脚手架, 而 fork 继承了源的 template: 'html'。于是 srcdoc 预览去找一个 Next 脚手架里 根本没有的 index.html,永远白屏。实测确认(修前 fork 得到 21 个 Next 文件、 无 index.html;修后就是一个 index.html)。copyProject 现在收 template 参数, 回落到对应的脚手架。

fork 和源项目共享一张封面文件,谁先重拍谁就删掉对方的 (project.service.ts + 新 cover.ts):fork 直接继承 photoUrl,而换封面时 会 unlink 被替换的那个文件。于是重拍其中一个,另一个的卡片 404——而 gallery 要求有封面,那个项目就从墙上掉下去了。现在只在没有别的项目指向该文件时才删 (staleCoverPath,3 个单测)。

14:40 — 灰带删除 + gallery 的封面门槛

  • The "one real turn" band is gone entirely. --secondary in dark is a near-neutral grey (45 4.9% 16.1%); under a warm painted page it reads as dirt. Third attempt at that seam and the winner is no band at all — the terracotta label, the display heading and the bordered cards separate it.
  • Gallery now requires a cover (photoUrl: Not(IsNull())). The filter was dropped earlier this session for a good reason — capture was broken everywhere and requiring it emptied the wall — but capture works now, so a wall of "no preview yet" tiles is just an honest way to show nothing. Prod check: 48 public projects, exactly 1 has a cover (the rest are E2E leftovers nobody opened). Covers fill in as projects get opened.
  • Covers follow the latest version: HtmlPreview re-shoots whenever the page content changes (1.5s debounce so mid-turn rewrites do not each get a screenshot), instead of once per mount.

03:00 (7/29) — 部署与分享链路复核

  • Vercel Ready, Railway SUCCESS (285f07b3) — everything through 6098f38 is live. /share/<bogus> on prod returns the branded "not shared" 404, so the route is deployed rather than just built.
  • Share pipeline walked end to end locally: public html project → anonymous /share/<uniqueProjectId> → 200 with the real page, Content-Security- Policy: sandbox allow-scripts allow-forms and X-Content-Type-Options: nosniff. og:title/description present; og:image only when a cover exists (correct — no cover, no image tag).
  • Screenshot endpoint refuses a stranger's project (403), so covers cannot be farmed for projects you do not own.
  • Leftover test data on the local DB: one public page project (d92ca790, "Warm Roast Landing Page") with no cover, invisible in the gallery by the cover rule. Harmless; delete whenever.

Full suite re-run against HEAD (6098f38 + this doc commit): 32/32 green, including the newest nodes — versions/restore/undo-the-undo, restore refusing a non-version, the anonymous share link (7603B served with no token), and the html cover shooting a 137KB png.

手改同时作为 context 喂给 agent —— 但只进 prompt,不进消息:提交那条 快照之前先读出改了哪些文件,把清单(路径 + 新增/修改/删除)拼进 prompt, 附一句"先读它们,除非这条消息要求撤销,否则保留用户做的"。不存库、不流式 输出,所以聊天记录里看不见。给清单而不是 diff:agent 手上有读文件工具、 文件就在那儿,点名足够让它去看,而一次大规模手改也挤不掉真正的请求;超过 20 个文件折叠成 "…and N more"。

节点 37 守这条,在自己的项目和自己的对话里跑:节点 36 那个项目里有个 文件就叫 by-hand.html,而那段历史还在聊天里——"我刚手改了哪个文件"于是有 一个非常可信的错误答案摆在眼前,模型两次都答了它。换成干净项目 + 一个本身 不含任何提示的文件名后,断言才真的在测 wire 而不是测模型的联想能力。

设计 linter 从单向变成闭环 —— 它一直在评判,但没人听得见

open-design 的两个明显方向都已经做完了(158 套设计系统、zip/PDF/share/ Vercel deploy),所以这轮挖的是已经移植进来、但只走了一半的东西

lint-artifact.ts(976 行,从 open-design 逐行搬来的反 slop 规则)在每轮 turn 结束时跑,findings 推到 Changes 面板。它自己的文件头就写着这件事: "nothing feeds them back to the agent, so it does not self-correct"。于是:

  1. 写出 slop 的那个 agent 永远看不到判决 —— 它在 turn 结束后才跑。 同一个页面下一轮回来还是那个紫色渐变,因为没有任何东西把结论带回去。
  2. 用户读到"把紫色渐变换成纯色"之后,只能自己把这句话重新打一遍 到输入框里。面板是只读的死胡同。

两半都补上,都是复用而不是新机器:

  • lint-note.ts(新):findings → prompt 段落。每轮开头重新算,不存 —— restyle / restore / 手改都会让页面变样,记住的列表描述的是一个不存在 的页面。复用的是已经存在的 lintPage(),只是也在 turn 开头调一次。
  • 只带 P0/P1(P2 是建议,不值得每轮的 prompt 预算),上限 8 条 —— 一个满 盘皆输的页面不能把用户真正的请求挤掉。干净页面一个字都不加:空的 "没问题"前言是花预算说没新闻,还会训练模型跳过这个块。
  • 措辞上明确写了 findings 是 advisory:linter 自己承认是 greppy 的, 不加这句,一个刻意的设计选择会被每轮"修"掉一次。
  • 面板加 "Fix these":走 sendMessage,和手打的一模一样 —— 所以它是历史 里的一条 turn、有 snapshot、能回滚。turn 进行中禁用(否则只是排队), 两种布局(手机 / 桌面)都接了线。

验证:scripts/check-lint-feedback.mjs(新,已并入 pnpm check,11 个 全绿),逐一验证过三处 wiring 各自还原后这个 check 会失败 —— 不会失败 的 check 不是 check。另外拿真页面实测走了一遍真链路:一个含渐变/emoji 图标/ sans display/lorem 的页面 → 真 linter 出 4 条 P0 → 真的变成 1455 字节的 prompt 段落;一个干净页面 → 空字符串。后端 164 测试全绿,前后端 tsc 干净, next build 绿(第一次 lint 时我自己写的那行正好踩中 prettier 报错 —— 就是 HANDOFF 记过两次的那个"tsc 干净但 build 挂"的事故模式,已修)。

已知边界:findings 只喂给 agent,不强制它修 —— 一个 turn 结束后页面仍 然可能带着同样的 P0(模型判断该保留),这是有意的,linter 是 greppy 的。 Next 项目不参与两边(没有单一 artifact 可判),和原本的行为一致。

项目的设计合约一直存在,只是没人看得见 —— NOTES.md 可见可编辑了

open-design 的核心概念之一是"每个项目一份 DESIGN.md,所有渲染都遵循它"。 侦察下来 codefox 早就有这个东西了,而且比想象中更像合约:NOTES.md 由 agent 维护(instructions 里明确要求),记的是"受众是手机上评估的独立 开发者"、"accent 保持 #c96a3a,用户拒绝过蓝色两次"、"没有真实数字前不要 定价区",并且每一轮 turn 开头都会被读回 prompt(notesNote())。 deploy 还专门把它排除在发布之外(SKIP_FILE = /^NOTES\.md$/),理由是 "这正是没人想从自己域名上服务出去的东西"——说明它被当真了。

唯一的问题是它对用户完全不可见。 grep -rn "NOTES" frontend/src 零 结果。也就是说:一个文件在左右每一轮构建,用户既看不到 agent 相信了什么, 也无法纠正写错的一条——只能用散文再说一遍然后祈祷。

所以这轮做的不是加一个新概念,是把已有的合约露出来:

  • frontend/src/components/chat/code-engine/notes-dialog.tsx(新):工具栏 Notes 按钮打开,读/写 NOTES.md。没有任何新后端——/api/file 的 GET/POST 早就在了,带 JWT + ownership 校验,写入时自动建文件。这个 dialog 就是把那个端点指向一个约定路径。
  • 404 = 空合约,不是错误:新项目 agent 还没写过 notes,readFile 回 null、controller 抛 404。当成错误的话,这个功能就只在"agent 恰好写过"的 项目上能用——而用户最想立规矩的时刻恰恰是项目刚建的时候。现在给一个 # Notes\n- 的起点。
  • 读失败绝不能变成空保存(真正会毁数据的那条):加载失败时不显示空 textarea,failed 态 + Save 禁用。否则"加载挂了 → 框是空的 → 用户点保存 → agent 记的东西全没了"。保存失败则保持 dialog 打开并明说"你的文字还 在",关掉就等于把用户刚写的扔了。
  • 手改归属复用已有机器:保存只写工作区不提交,turn 前的 snapshotPendingEdits() 会把它变成用户自己的一条 "Your edits" 版本,并 告诉 agent 用户动了 NOTES.md。不需要新代码。
  • 桌面和 compact 两套布局都接了线——这仓库出过 API key 只在 ≤450px 溢出菜单 里、桌面够不着的事故。不按 isPage 分支:两种项目都有 notes (instructionsFor 在 Next 分支也拼了 NOTES_SECTION)。

验证:scripts/check-notes-contract.mjs(新,已并入 pnpm check,13 个 全绿),逐一验证过四处还原后 check 会失败(写错文件名 / 不处理 404 / 读失败后 Save 仍可点 / 少接一套布局)。另外拿真 HostWorkspace 走了一遍真 往返:文件不存在回 null → 写入后逐字读回 → 中文和 " <script> & 原样 存活 → 超长文件被 clip 并且说了自己被 clip → 空白 notes 对 prompt 一个 字都不加。后端 168/168 全绿,前后端 tsc 干净。

踩到一个值得记的坑(不是我的代码):next build/_not-found/ 预渲染失败(Cannot read properties of undefined (reading 'call'))。先怀疑是自己,于是把改动挪开重建 —— 干净;再放回来 —— 还是挂。真正原因是 .next 增量缓存陈旧:rm -rf .next 后同样的代码 连续两次构建全绿。这个报错完全不指向缓存,长得像模块解析错误,下次别 再顺着 import 查。

已知边界:合约只是 prompt 里的一段,不强制 agent 遵守;它和 158 套设计 系统各管一边(系统管 token,notes 管产品决策),这轮没有把选过的设计系统 自动沉淀进 notes——那需要在 restyle 路径上写文件,是另一个改动。

换风格这个决策,以前没有任何人告诉 agent

第三轮:把设计决策沉淀进 NOTES.md。结论是只有一条路径需要改,另一条不该 碰 —— 这个取舍本身是这轮的主要产出。

planner 问答不写。 question card 的答案是通过 sendMessage 走一轮真 正的 turn 的("My choices: - 风格: …"),而 agent 的 instructions 早就要求 它把决策写进 NOTES.md。后端在这条路上再写一遍就是双写:同一个决策两条措辞 不同的记录,谁也不知道该信哪条,而且 agent 下一轮"纠正"其中一条时另一条还 在。已经有人负责的事情不要再插一手。

restyle 必须写。 它是唯一一个从不跑 turn 的设计决策 —— restyleProject 是个 GraphQL mutation,换掉 :root 的 token 块就返回了。 于是没有任何东西告诉过 agent 外观变了,下一轮它仍然按旧风格在建

  • backend/src/project/style-note.ts(新,43 行):一个纯字符串函数。
  • 写在已有的 queueForProject 块内部,和 index.html 的写在一起 —— 它们是同一个决策,一个并发的 turn 不能插在中间。没有新队列、新表、新端点。
  • 替换自己那一行,不追加:NOTES.md 每轮都被 clip 进 prompt,一个 "用户试过的每一种风格"的流水账会把真正重要的决策挤出去。实测连试五种风格 → 一行
  • 大小写不敏感匹配:agent 自己也维护这个文件,它可能把这行改成自己的措辞; 严格匹配的话下次就会多出第二行。
  • best-effort:页面此时已经换好了,让一条脚注的失败去报"restyle 失败" 是在对用户撒谎。catch 住只记 warn。

验证:style-note.spec.ts 5 条(含"连换五次只剩一行"、"agent 记的别的决策 一条不少且顺序不变"、"reworded 的大小写变体不会造出第二行"); scripts/check-style-noted.mjs 守 wiring(写的是不是 NOTES.md、有没有跑出 队列、失败会不会拖垮 restyle),逐一验证过三处还原后会失败。另外拿真 HostWorkspace 走了一遍:agent 先记了"- No pricing section yet",再连换 Neon → Luxury,最终 prompt 里是 - No pricing section yet + 一条 - Design system: Luxury,Neon 不见了。

pnpm check 14/14,后端 173/173,双端 tsc 干净,next build 绿 (先 rm -rf .next——上一轮那个假信号)。改动是纯加法:已有文件只多了 20 行。

未做:把 planner 的结论也落进去(理由见上),以及"合约"里除风格外的东西 (受众、语气)——那些 agent 自己在写。

忘记密码这件事,以前无解 —— 重置闭环补上了

第四轮两个候选,选了密码重置。画廊那条没做,理由写在下面。

sendPasswordResetEmailpasswordReset.hbs 一直都在,MailService 里 连链接格式都写好了(/reset-password?token=)—— 但零调用、零 UI、零路由。 也就是说一个忘记密码的用户完全没有找回途径,只能去求管理员改库。这不是 "体验不好",是一条死路。

画廊那条评估后放弃:它已经有 strategy(latest/trending)+ size,而生产上 有封面的公开项目只有个位数。给一面几乎空的墙加搜索和分页是给不存在的 问题写代码;等项目数真的起来了,那时才知道该按什么搜。

单次使用,但没有新表

关键设计:token 用账户当前的 password hash 参与签名(HMAC 的 key 是 secret:passwordHash)。重置改掉 hash → key 变了 → 所有旧链接同时失效。 单次使用、旧链接全部作废,两件事一起免费拿到,不需要 reset_tokens 表、不需要 过期清理 job。hash 本身不出服务器,发出去的只是摘要。

  • backend/src/auth/reset-token.ts(新):<userId>.<expiresAt>.<HMAC>, 30 分钟有效,timingSafeEqual 比对,长度不等直接 false(它会抛)。 刻意要求正好三段 —— userId 里如果能塞点号,一个 token 就能被解释成另一个 用户的。
  • 不做账号探测:未注册地址 / Google 无密码账号 / 真实账号,三条路径返回 完全相同的一句话,唯一的区别是有没有真的发信。
  • 复用注册那条路的 lastEmailSendTime 做 1 分钟冷却 —— 否则这就是一个 不需要登录、能往任意邮箱轰炸的发信接口。
  • 重置会删掉该用户所有 refresh token:会去重置密码的人,很可能正是因为 别人在他账号里。不删的话入侵者还有最多 7 天。
  • 重置成功顺带 isEmailConfirmed = true —— 能收到这封信本身就证明了邮箱。
  • SMTP 在所有环境都关着,所以 mail disabled 时重置链接写进后端日志 (Logger.warn),不进 response —— 链接就是全部的秘密。

前端:登录弹窗里加 "Forgot your password?" 内联切换(地址已经在上面那个框里 了,不值得一个新路由),以及 /reset-password 页面 —— 这个路径不是我选的, MailService 一直指向它,只是页面从来不存在。useSearchParams 必须包 <Suspense>,否则整条路由退出静态渲染。

验证:reset-token.spec.ts 10 条(单次使用、所有旧链接一起死、过期、 换 secret、手工改过期时间、改成别人的 userId、畸形输入不抛异常、无密码账号、 不泄露 hash);scripts/check-password-reset.mjs 守那些用起来看不出对错 的性质(是不是账号探测器、重置有没有终结会话、@Public 有没有掉、两端 URL 是否一致),逐一验证过四处还原后会失败。另外用真 bcrypt 走了一遍 重放:同一个链接在重置后再用,verify = false。

pnpm check 16/16,后端 196/196,双端 tsc 干净,next build 绿 (/reset-password 出现在路由表里)。

踩到一个真类型错误:RefreshToken.userId 声明是 number,实际存的是 uuid 字符串 —— logout 那处同样的 delete 早就挂着 as any。跟随现有写法,没有 顺手改 model:那是一次 migration,不是这个功能的脚注。

已知边界:重置不撤销当前 access token(JWT 缓存按 token 字符串索引, 不按用户),所以入侵者手上那张最多还能活到它自己过期;refresh 已经断了, 所以最长一小时而不是七天。要彻底断,得给 JwtCacheService 加一个按用户失效 的接口 —— 独立改动。

停用账号、重置密码,现在真的把人踢下线了

第四轮留的边界:重置密码只删 refresh token,access token 还能活到自己 过期。这轮补掉,并且发现同一个洞在 admin 那边更大 —— adminSetUserActive(false) 两样都没删:已登录的会话照常工作(7-29 那轮 修的是"每个请求查一次 isActive",但那只挡新请求走 guard 的路径,refresh 仍然能换新 token)。

取舍:加一列,不是加一张黑名单表

lead 给了两个方案。选了在 jwt_cache 表上加 user_id:

  • 那张表本来就每个 token 一行,加一列是同一次查询、没有第二份状态要维护。
  • 黑名单方案(userId → 失效时间戳)要求每次请求都多做一次比较,而且需要 token 里带签发时间、还要额外的清理逻辑。撤销从一个 DELETE 变成一个常驻判断。
  • 代价:老的行 user_id 是 NULL,撤销匹配不到它们 —— 但这张表是内存 sqlite,进程重启就空了,所以"老行"只存在于一次部署之内。单测里专门有 一条守住 NULL 行不会被误删。

一个函数,两条路径

AuthService.endAllSessions(userId) 同时干两件事,返回踢掉的数量:

  • 删 refresh token(否则最长 7 天能换出新会话);
  • jwtCache.removeTokensForUser() 删活着的 access token(否则最长 30 分钟)。

两条路径都走它,而不是各自手写一半 —— 这两件事当初就是这么漂开的。 admin 那边通过已经 import 的 AuthModule 注入 AuthService(AdminModule 早就 import 了它,零模块改动),没有碰 gap-agent 的自锁守卫,并且只在 !isActive 时踢人:重新启用一个人不该把他踢下线。

第三条路径(用户主动改密码)不存在:grep changePassword 全仓库零结果, 没有硬造。

顺手修掉那个类型谎言

RefreshToken.userId 声明 number、实存 uuid 字符串,两处 as any实测过才动的:起一个内存 sqlite 让 TypeORM synchronize, PRAGMA table_info 显示这一列本来就是 varchar —— 类型是从上面那个 @ManyToOne 关系推出来的,不是从这行声明。所以这是纯声明修复,不需要 migration。改完删掉两处 as any(logout 那处保留它自己的语义:只踢当前 这一个 token,不是所有设备)。

验证:revoke-user.spec.ts 4 条(踢掉一个用户的全部会话、别人不受影响、 撤销后重新登录正常、没有 user_id 的老行不被误删); scripts/check-session-revocation.mjs 守 wiring,逐一验证过四处还原后会 失败(只踢一半 / admin 不踢 / 签发时不记 userId / 回调写成箭头函数—— 那样 this.changes 就不是行数,日志会打印 "Ended undefined sessions")。 另外走了一遍 guard 的真实谓词(isTokenStored,jwt-auth.guard.ts:49): 撤销后立刻为 false,同时另一个用户仍然 true。

pnpm check 17/17,后端 200/200(含 gap-agent 的 admin 套件,DI 改动 没弄坏它),双端 tsc 干净,next build 绿。

已知边界:撤销只对这个进程有效 —— jwt_cache 是进程内内存 sqlite,多实例 部署时 A 实例的撤销不影响 B 实例已缓存的判断。目前是单容器,真要水平扩展时 这张表得换成共享存储(Redis / Postgres),接口不用变。

设置页可以改密码了(以前只能改用户名和头像)

第五轮确认过"改密码入口不存在",这轮补上。侦察结果:设置页有头像、用户名、 邮箱(诚实标着 read-only)、主题 —— 没有密码。另外查了"UI 在但后端断了" 的死路:grep deleteAccount/deleteUser 只有 E2E 用的 test-cleanup controller, 前端没有删账号入口,后端也没有对应 mutation,不是断头路,是两边都没有, 按 YAGNI 不造。改邮箱同理 —— 那一行明确写着 read-only,是诚实的,不是坏的。

为什么不是"resetPassword 加个 guard"

已登录不等于有权改密码。 捡到一台没锁屏的笔记本的人,不能因此把账号 锁走。所以 changePassword 除了 guard 之外再验一次当前密码(bcrypt compare),这是它和 resetPassword 唯一但关键的区别 —— 后者的授权来自邮箱, 前者的授权必须来自密码本身。userId 取自 token 而不是参数(参数就等于允许 调用方指名别人的账号)。

改完之后本机不掉线

改密码会 endAllSessions(复用第五轮那个函数)—— 但接着给本机重新签一对 token 返回,前端 login() 收下。理由:改密码是良好卫生习惯,把正在操作的 那个标签页踢下线是在惩罚它。顺序也重要:先撤销再签发,反过来会把刚发的 token 一起杀掉(check 里专门守了这个顺序)。

Google 账号不给必然失败的表单

新增 guarded query hasPassword是 query 不是 User 上的 field —— field resolver 不过 guard(bug-agent 这轮刚证明过这条),而 User 从公开 gallery 可达。UI 在答案回来之前整行不渲染(先渲染表单再换掉 = 看起来像 bug),无密码账号显示 "Google sign-in" 而不是一个只能报错的框。

频率限制:没加。需要一个有效会话 + 正确的当前密码,一次 bcrypt compare, 没有比登录路径更值得猜的东西。

验证:change-password.spec.ts 5 条(错密码被拒且不保存不撤销、成功 后真的 bcrypt 存且旧会话死且本机拿到新 token 且新 token 带 userId 可再撤销、 新密码太短被拒、无密码账号明确报 Google、hasPassword 两态); scripts/check-change-password.mjs 逐一验证过四处还原会失败(不验当前 密码 / 不撤销 / 掉 guard / 前端不收新 token —— 最后这条最阴险:改密码成功, 然后用户下一个请求 401,因为自己的 token 刚被自己撤销了)。

pnpm check 18/18,后端 205/205,双端 tsc 干净,next build 绿。

写测试时 stub 假设错了一次:createRefreshTokenrandomUUID 自己生成 token,不是我 stub 的 save 返回值 —— 断言改成校验它是个真 uuid。

真浏览器视觉 QA —— 一晚的新 UI 第一次被真的看过

HANDOFF 里"Visual QA in a real browser"这条老欠账还上了。本地生产构建 (frontend standalone :3001 + backend :8081 SQLite,没碰 8080 那个长跑 进程)+ puppeteer 走查,桌面 1440 和 compact 430 两个视口各过一遍。 截图在 qa/(已加 .gitignore),脚本 qa/walk.mjs / walk2.mjs 可重跑。

puppeteer 没有下载过 Chrome(~/.cache/puppeteer 是空的),用系统 /Applications/Google Chrome.app 的 executablePath 就跑起来了——不需要为 一张截图下 170MB。

修掉的三个真 bug

  1. 重置邮件里的链接是 undefined/reset-password?token=…(最严重)。 FRONTEND_URL 既不在 .env 也不在 .env.example,configService 直接返回 undefined 拼进链接。也就是说未设该变量的部署上,密码重置和 邮箱确认两条链路发出去的链接全是死的,而唯一的症状是"用户回不来"。 加了 ?? 'http://localhost:3000' 回落 + 写进两个 env 文件。这个只能靠 真跑一遍看日志发现,curl 打 mutation 返回的是成功。
  2. html 项目每次轮询都去启一个 dev server:project-context 的后台 getWebUrl 对所有项目无差别调用,而 html 项目按设计没有 package.json、 永远没有 dev server → 后端 No project at … 抛 500,每轮一次,而 html 是产品的默认类型。加了 template !== 'html' 判断。
  3. 登录弹窗切到"忘记密码"后,标题还写着 "Welcome back / Sign in to your account" —— 描述的是另一个屏幕。标题跟着表单走。

记录但没修

  • 桌面 18% 聊天栏里,composer 的 textarea 只有 34px 宽(一行一个字)。 实测量过:整行 225px,左侧那组(附件按钮 + 模型选择器)占 147px,发送区 28px,textarea 只剩 34。compact 视口下是好的,所以只影响窄栏桌面。 试过给 wrapper 和 trigger 加 min-w-0 / shrink / max-w,都没生效 (shadcn SelectTrigger 自带 w-full,量下来仍是 115px),不想在收尾轮 里瞎改 CSS,已全部还原。真正的修法可能是模型名截断或把选择器移出这 一行。旁证:同一处代码的注释记着 "Have feedback?" 曾造成一模一样的挤压。
  • 聊天记录里 codefox-questions 的原始 JSON 以代码块形式露出来了(compact 截图 11 可见)——planner 卡片本该把它渲染成卡片。
  • compact 下 Notes 在溢出菜单里(代码和 check 都断言它在),但脚本点两次把 菜单关掉了,没截到图;不是产品问题,是我的走查脚本问题。

走查结论

结果
登录弹窗 + "Forgot your password?" 两个视口都在;Google 分隔线在重置态正确隐藏
/reset-password(有 token / 无 token / 两次密码不一致) 都对,错误文案深色下可读
设置页密码行 + 展开表单 两个视口都对,融入现有设计语言
Notes dialog(空合约) 桌面正确显示 # Notes 起始模板,无报错
Changes 面板 正确显示"还是脚手架";设计 token 已烤进页面
admin console(总览/搜索/项目表/角色) 两个视口都渲染真实数据,自我保护禁用态在

又踩到的坑(都记过,又踩了)

  • NEXT_PUBLIC_* 是构建期内联的:我 env VAR=... next build 前缀传了 却没进 bundle,browser 一直在打 8080 那个老后端,于是满屏 "Cannot query field surface/myRoles" ——看起来像 schema 坏了,其实是 前端在跟另一个后端说话。写成 .env.local 才生效(用完已删)。
  • next start 不能服务 standalone 构建(next.config 里 output: 'standalone'),会 500 且只在启动日志里警告一句。
  • 一个 // 注释写进了 JSX 属性区 → 整个 workbench 白屏 React #423。 JSX 里属性之间只能用 {/* */}

composer 挤压 + question JSON 露出 —— 两个 QA 记录的问题都修了

composer:34px → 153px(4.5 倍)

上一轮量到的:整行 225px,左侧组(附件 + 模型选择器)占 147px,发送区 28px, textarea 只剩 34 —— 一行一个字。上轮我试了 min-w-0 / shrink / max-w 三种改法全部无效(shadcn SelectTrigger 自带 w-full,量下来仍 115px), 当时全部还原了。

这轮不再跟 flex-basis 较劲,照抄同一处注释里记着的历史解法: "Have feedback?" 当年就是被移出输入行才解决的。模型选择器现在有自己 一行,在输入框上方、同一张卡片内,带一条细分隔线。

实测(1440 宽 + 真实 18% 聊天栏比例):textarea 34px → 153px,左侧组 147px → 28px(只剩附件按钮)。compact 视口确认没被弄坏(截图 10-workbench- compact)。选择器仍然只在 models > 1 时出现,aria-label 保留。

question JSON:root cause 是"只在流式时才清理"

不是历史重放丢了卡片分支 —— 卡片分支一直在(chat-list.tsx 对每条 assistant 消息都跑 extractQuestions,完整 fence 会渲染成卡片,历史里是 只读的)。真正的问题在另一半:

stripPartialQuestionFence() 早就存在,专门处理"fence 还没闭合"的半截 JSON —— 但它只在 isStreaming(index) 为真时才被调用。而会留下未闭合 fence 的,恰恰是那些死掉/被 Stop 掉的 turn:流结束了,isStreaming 永远 是 false,于是那条消息每次刷新都把半截 JSON 当代码块渲染,永远

改成两条渲染路径(prose 和 TurnTrail)都无条件调用它。完整 fence 不受影响 (函数第一行就是 if (FENCE.test(content)) return content),所以卡片照常。

走查脚本的去留

qa/walk.mjs / walk2.mjs 两轮里抓到 4 个真 bug(undefined 重置链接、 html 项目每轮 500、composer 34px、死 turn 露 JSON),值得长期保留, 移进 scripts/:

  • scripts/visual-qa.mjs —— 登录弹窗 / 忘记密码 / reset-password / 设置页 密码行,两个视口。node scripts/visual-qa.mjs [--token <reset-token>]
  • scripts/visual-qa-workbench.mjs —— 工具栏 Notes / Changes 面板 / admin console,两个视口。node scripts/visual-qa-workbench.mjs --chat <chatId>

刻意不进 pnpm check:它们需要一套跑着的栈(backend 8081 + frontend 生产构建 3001),而 check 脚本的价值恰恰在于不需要。截图仍写到 qa/ (gitignored)。measure.mjs / compact-notes.mjs 是一次性探针,留在 qa/。

新 check:scripts/check-composer-room.mjs,守两件事——选择器不许回到输入 行、partial fence 的清理不许再被 isStreaming 门住。两处还原都验证过会 失败pnpm check 20/20,后端 210/210,双端 tsc 干净,干净重建 next build exit 0。

Remix:侦察发现 90% 已经存在,只补了署名

第九轮的任务是"公开项目的 remix/fork",侦察结论是这件事基本已经做完了, 所以这轮的产出主要是"没写的代码"。

后端 forkProject 早就在(project.service.ts:459),而且 lead 列的 每一条要求它都已经满足:guarded mutation ✅、源项目必须 isPublic 否则拒 ✅、 复制文件而不是共享目录 ✅、新 uniqueProjectId ✅、isPublic = false 起步 ✅、 forkedFromId 记录来源 ✅、fork 计数用 increment() 原子自增 ✅。还额外 处理了 host/sandbox/html 三种模式的文件复制差异,以及"不能 fork 自己的项目"。

画廊入口也早就在(public-projects.tsx):每张卡片一个 Fork 按钮, 匿名点击有引导登录的 toast,自己的项目显示 "Yours" 而不是一个注定失败的按钮。

git 历史不用管:copyProject 走的是 download 那套 zip/解包机器,本来 就不带 .git(IGNORED_ENTRIES 里有),所以"别把源项目的完整 git 历史带 过去"这条已经天然满足,不需要新代码。

NOTES.md 带过去:同理,copy 是整目录复制,NOTES.md 自然跟着走。这也是 对的——设计决策正是 remix 价值的一部分,而且 deploy 已经单独把 NOTES.md 排 除在发布之外,所以它不会泄露到 fork 出去的公开页面上。

配额:没有开新口子,因为根本不存在配额。 grep 过整个 project.service, 项目创建没有任何数量上限逻辑。fork 和 create 走同一条(不存在的)限制,符合 "别开新口子"的要求。要加限流是独立需求。

真正缺的一块:署名

forkedFromId 存的是源项目的 uniqueProjectId —— 正好就是 /share/:id 吃的那个 key。所以署名是一个纯前端改动:工具栏上一个 "Remixed" 小链接,指向 源项目的分享页。不需要新 query、新字段、新 resolver——GET_PROJECT 早就 把 forkedFromId 取回来了。

优雅降级是免费的:源项目被删或转私有时,/share/:id 自己会回那个统一的 "This page is not shared." 404 页,项目页什么都不会报错。

向后兼容:forkedFromId 本来就是 @Field({ nullable: true }),老客户端不 选它就完全不受影响,不需要像 adminUsers 那轮一样处理。

没做的

  • 分享页上的 Remix 按钮:分享页的 HTML 带 Content-Security-Policy: sandbox allow-scripts allow-forms,没有 allow-top-navigation——注入一个链接进去,点了什么都不会发生。要让它工作 得放宽这个模型写的、跑在自己 origin 上的不可信页面的 CSP,这是个安全取舍, 不值得为一个入口做(画廊已经有入口了)。真要做的话正确姿势是把按钮做成 share 路由自己渲染的外层 chrome,而不是注进页面里——但那要改 social-card.ts,bug-agent 这轮正在动那个文件。
  • E2E 节点:fork 路径 E2E 节点 20 已经覆盖("publish+fork carries files"), 而且已经断言"一次 fork 记一次"。没有再加。

验证:scripts/check-remix-attribution.mjs(新),守署名链接指向 share id 而不是行 id(存错就是每个署名都 404)、fork 的四条既有保证不许烂掉、 forkedFromId 保持 nullable。三处还原都验证过会失败pnpm check 23/23,后端 210/210,双端 tsc 干净,干净重建 exit 0。

配额:项目数上限 + 每用户并发 turn 上限

上一轮侦察暴露的缺口:create 和 fork 都没有任何上限,而且 turn 队列是 按项目排的 —— 所以一个账号有 N 个项目就能同时开 N 个 agent turn,每个 都是一次真实的模型会话。注册关着是这两件事至今没出事的唯一原因,而"开放 注册前"是 HANDOFF 里的老主题。

项目数:一个函数,两个调用点

backend/src/project/quota.ts(新,54 行):assertCanCreateProject()create 和 fork 调的是同一个函数 —— 写两遍就是 fork 悄悄变成绕过上限的 官方路径,check 脚本专门守这条。

  • 默认 20。生产是 127 用户 / 170 项目,20 远高于任何真实用户,又能把 一个滥用账号的破坏面兜住。MAX_PROJECTS_PER_USER 可覆盖,已写进 .env.env.example(FRONTEND_URL 那轮的教训:不写进 env 文件的 变量,"未设置"就是默认情况,不是边缘情况)。
  • 坏值 = 默认值,绝不等于"无限制":'' / 'lots' / '0' / '-5' 全 部回落到 20。单测有一条专门守这个。
  • 计数只数 isDeleted: false,所以删掉一个立刻空出一个位子,不需要 额外的回收逻辑。
  • 存量超限用户:已有项目一个不动,只挡下一个(单测里有 99 个项目的 用例)。生产上没人接近 20,所以这条实际是空操作。
  • admin 不受限:运维要给所有人收拾残局,把他自己关在门外是本末倒置。
  • 检查放在 generateText 生成标题之前 —— 已经到上限的用户不该先被 扣一次模型调用。

并发 turn:复用已有的 map,不建表

project-queue.ts 里加了一个 per-user 计数器(默认 3,MAX_TURNS_PER_USER 可覆盖)。没有新状态表,和已有的 per-project 队列住在同一个模块、同一 种进程内 map,同样的多实例上限(要水平扩展两者都得换真锁)。

关键是 finally:槽位无论 turn 怎么结束都要还回来。漏一个槽比没有限制 更糟 —— 用户会被锁在自己账号外面直到进程重启,而且没有任何错误解释。单测 专门测了"turn 抛异常后槽位归还"。

超限回 429 而不是 500,消息说明"你已经有 N 个 turn 在跑"。

前端

create 和 fork 两条路径的 catch 原本把一切都压成 "Failed to ..."。配额那句 话本身就带着上限、当前数量和解决办法,压掉它等于把唯一可行动的信息丢了。 两处都改成透传那句话(跟着现有"已知消息映射成可读文案"的模式走)。

接近上限时不提示 —— 20 个项目的用户离上限还很远,为一个没人碰得到的 阈值加一条常驻提示是给不存在的问题写代码。真有人抱怨再加。

验证:quota.spec.ts 8 条 + turn-limit.spec.ts 4 条 + 新 scripts/check-quota-wired.mjs(守 fork 不许绕过、turn 不许无限、坏 env 不许关掉上限),三处还原都验证过会失败。后端 222/222(含 DI 启动, 注入 AuthService 没弄坏模块图),pnpm check 25/25,双端 tsc 干净, 干净重建 next build exit 0。

同轮追加:邮箱大小写让"忘记密码"对部分账号永久失效

bug-agent 发现,在我的 auth 地盘,我修的。六处邮箱查询里只有 requestPasswordReset 做了 toLowerCase() —— 而它恰恰是那个"无论账号 存不存在都回同一句话"的端点。于是 Foo@x.com 注册的用户点忘记密码:查 foo@x.com → 查不到 → 返回和成功一模一样的话术 → 永远收不到邮件,没有 任何报错、任何日志。同一个洞还让 Foo@foo@ 能注册成两个账号。

root cause 修法是一个函数,不是五处各补一个 toLowerCase() (find-by-email.ts,39 行)。六处 findOne({ where: { email } }) 全部 改成 findUserByEmail()

两边都 LOWER,而不是只归一化写入 —— 这是存量安全的关键:生产上已经有 大小写混合的行,只改写入侧会让那些老账号从此登录不上(查询用小写,行里 是大写)。LOWER(user.email) = :email 让老行照常可查,新重复也建不出来, 不需要 migration。写入侧也归一化了,但那只影响新行。

ponytail: 注释记了天花板:LOWER() 用不上索引,127 用户无所谓;真进慢 查询日志再加个 generated lowercase 列,改这一个函数即可。

验证:find-by-email.spec.ts 4 条,跑真 SQLite(整个修复就是一句 SQL 比较,mock 掉 repository 等于什么都没测):写一行 Foo@Example.com 的 "存量行",然后用原样 / 全小写 / 全大写 / 带首尾空格四种问法都能查到;空 输入不匹配任何行;DUP@ 能撞上 dup@(这就是重复注册被挡住的机制)。 新 scripts/check-email-case.mjs 守"不许有裸 where: { email } 回来"、 "列和输入两边都要 LOWER"、"注册写入要归一化",三处还原都验证过会失败 ——其中第一条第一次写漏了 shorthand 形式(email }),实测还原时没报错才 发现,已修正正则。

后端 226/226,双端 tsc 干净,next build exit 0。

过程中撞到别人的半成品:某轮 project.model.ts:65 引用了未定义的 Byline,整个后端 tsc 挂掉、所有 jest 套件跑不起来。不是我的文件,按避让 规矩没动,报给了 lead,几十秒后对方补完就恢复了。教训:多 agent 同仓时, "我的测试跑不起来"第一步先看是不是别人正在写。

第十一轮:邮箱归一化的三块补完(重复对 / 探测 / 全流程测试)

修复本体在上一节(第十轮追加)。正式派发后补了 lead 额外点名的三件事:

重复对的行为是确定的。 生产上可能已经并存 Foo@x.comfoo@x.com, 两行都能被小写比较命中 —— getOne() 此时返回哪一行取决于查询计划器, 也就是"你登进哪个账号"是随机的。加了排序:精确匹配优先,其次最早创建。 不合并、不删除任何账号 —— 那是账号主人的决定,不是一个查询函数的。

只读探测脚本:scripts/probe-duplicate-emails.mjs。一条 GROUP BY, 不写任何东西、不加载 Nest。本地库跑过:零重复对。 生产没跑成:Railway 的 DATABASE_URL 指向 postgres.railway.internal, 只在 Railway 网络内解析,而 railway run 是拿着 prod 环境在本地跑, 所以照样 ENOTFOUND(试过,别再试第二遍)。要在生产上跑得用 Postgres 服务 的 public proxy URL(DATABASE_PUBLIC_URL)或容器内 shell —— 脚本头部记了。

测试补到 6 条:原有 4 条之外加了"重复对确定性"(精确匹配两个方向各中 各的,再用两边都不匹配的大小写连查 5 次确认结果稳定)和"全流程"(混合大小写 注册 → 原样登录 → 大小写变体登录 → 忘记密码命中,后者正是原 bug 的路径)。

check 也加了两条断言守住排序,还原后验证过会失败

后端 228/228,pnpm check 26/26,双端 tsc 干净,next build exit 0 (又踩了一次 .next 半损的 _app.js.nft.json 假报错,清缓存重建即绿 —— 第四次了,谁再看到直接清缓存,别查代码)。

分享页有了外层 chrome —— Remix 入口终于能点了

第九轮我记过:分享页的 Remix 按钮被 CSP sandbox 正确挡住,注进去也点不动, 正解是"让 share 路由渲染外层 chrome 而不是往沙箱页面里注东西"。这轮做了。

结构:包起来,不是注进去

  • /share/:id我们自己的页面:一条细顶栏(项目名 + 作者 byline + Remix 按钮 + Built with CodeFox),下面一个 iframe 撑满剩余高度。
  • iframe 的 src 是 /share/:id?raw=1原来那个响应,一字未改
  • 子页面(/share/:id/about.html)保持裸的 —— 它们是从 frame 内部的 链接跳过去的,套 chrome 会叠出第二条顶栏。规则简单:只有正门有 chrome

share-chrome.ts(新,~110 行)。ponytail: 字符串模板,不是模板引擎 —— 一个页面三个链接,整个文件比配一个渲染器还短。

安全边界没退(实测,不是推断)

写了一个真的敌意页面塞进项目里(试 window.parent.locationtop.location= 跳转、document.cookie),用 puppeteer 打开分享链接:

parent BLOCKED: SecurityError
topnav BLOCKED: SecurityError
cookie BLOCKED
top url unchanged: true
parent chrome still present: true

iframe 的 sandbox="allow-scripts allow-forms allow-popups" —— 刻意没有 allow-same-origin:它和 allow-scripts 并存时,页面能反过来摘掉自己的 sandbox。也没有 allow-top-navigation。两条都写进了 E2E 断言。

外层响应仍然带 sandbox CSP header —— 实测过它不阻止我们自己那几个链接 跳转(浏览器只拦 iframe 内的 top navigation),所以既没退化也没多花代价, 节点 33 原有的 CSP 断言照常绿。

og/twitter meta 移到外层(爬虫读的是外层)

socialTagsFor() 复用 withSocialCard,喂它一个空 <head> 再取回它加 的东西 —— 不复制那套 twin-spelling 规则、forwarded-host 逻辑,尤其不复制 bug-agent 刚修的 $ 注入修复(String.replace 把项目名里的 $` 当替换 模式)。他那 16 个单测照常全绿,我另有一条断言守住 $ 仍然逐字存活。

验证

share-chrome.spec.ts 7 条(sandbox 不含逃逸开关 / byline + Remix / 项目名 和用户名都转义 / 无作者时不渲染 byline / meta 在外层 / $ 不被展开); 节点 33 扩展(Remix 存在、iframe 有 sandbox 且不含两个逃逸开关、 指向 raw、raw 页 200 且没有 chrome)——原有断言一条没动,全部仍然成立 (子页面还是裸的、traversal 还是 404、CSP 还在)。

后端 235/235,pnpm check 26/26,双端 tsc 干净,next build exit 0。

已知边界:Remix 按钮跳到 /?remix=<id>(画廊,那里 fork 已经能用、未登录会 提示登录),没有在分享页里再做一套 auth 流程 —— 前端拿这个参数自动滚到 对应卡片是下一步,不做也不坏。

Remix 落地闭环 —— 三件事里两件已经做完了

第十三轮。侦察先行,结果三条要求里两条早就在:

  • fork 计数展示:画廊卡片一直在显示 {p.subNumber ? · N forks : ''} —— 0 不显示的逻辑也已经有了。
  • 防重复点击:disabled={forking === p.id} 一直在。

真正缺的只有 /?remix=<id> 的落地处理,以及分享页 chrome 上的计数。

落地:复用 handleFork,不是再写一遍

?remix=<uniqueProjectId> → 在画廊里按 share id 找到那张卡 → 调用卡片 按钮用的同一个 handleFork。auth 提示、配额可读错误、in-flight 禁用 全部跟着来,不用重建。

两个顺序问题是这轮的全部含金量:

  1. 先清 URL 再 fork,否则 fork 途中刷新会再 fork 一次。claimed ref 守同一个 tick(这个 effect 会因为墙加载完、以及登录后 isAuthorized 翻转 而重跑)。
  2. 未登录时不清 URL。清了的话,"登录后继续 fork"就断了 —— 参数留着, 登录使 effect 重跑,remix 意图自己活过这趟往返。

实测(真浏览器 + 真后端)

[signed out] url still has param: true      ← 意图活过登录
[signed out] prompt shown: true
[signed in]  final url: /chat?id=dc65ad9b…  ← 落进自己的新项目
[signed in]  param cleared: true            ← 刷新不会再 fork
[signed in]  fork count 0 -> 1              ← 恰好一次

分享页 chrome:by qauser · 1 remix(单数正确,0 时整段不出现)。

一个诊断值得记:第一次实测 signed-in 没 fork 成 —— 因为脚本挑的第一个 公开项目正是 QA 用户自己的,而 API 正确拒绝了自己 fork 自己。不是 bug, 是测试数据挑错了。换成另一个 owner 的项目就对了。另外一个 fork 出来的项目 /share/<id> 回 404 也是对的:它 template 是 Next,share 只服务 html。

验证:share-chrome.spec.ts 8 条(新增计数的 0/1/多数分支),新 scripts/check-remix-landing.mjs(守清 URL 的顺序、未登录不清、 in-flight 禁用、0 不显示),两处还原都验证过会失败。 后端 240/240,pnpm check 27/27,双端 tsc 干净,lint 干净, next build exit 0。

项目生命周期侦察:五条里三条已存在,补了 duplicate + 可见性

第十四轮先侦察后动手。结果:

能力 状态
重命名 已有 — workbench ⋯ 菜单 + Rename 对话框
删除 已有 — 确认框写明"项目 / 聊天记录 / 生成的文件都会没,不可撤销"
可见性 toggle 已有(工具栏),但卡片上看不出来 ← 补了
项目列表信息 有标题 / 相对时间 / 类型(Page/Next.js);缺公开状态 ← 补了
复制自己的项目 完全没有 ← 这轮主要产出

duplicate = forkProject 加一个参数

forkProject 早就干完了全部的活:复制文件(host / sandbox / html 三种模式都 处理了)、新 uniqueProjectId、isPublic=false 起步、绑 chat、走配额。它拒绝 自己的项目只因为一条 guard。所以 duplicate 不是新函数,是 forkProject(userId, projectId, mine=true)

两条 guard 现在双向:!mine 时不能 fork 自己(否则墙上的按钮是个必然失败), mine 时不能"复制"别人的(否则它成了绕过 fork 规则的后门)。

自己复制自己不计 fork 数 —— subNumber 是 trending 墙的排序依据,计了的话 "按 Duplicate 十次"就是刷榜。实测确认源项目仍是 forks=1

实测(真后端)

fork 自己    → "Cannot fork your own project"   ✅ 仍然拒绝
duplicate    → "Copy of QA Page"                 ✅
源 forks     → 1(没被自增)                      ✅
副本         → public=False,自己的 share id      ✅
副本文件     → index.html 读得到                  ✅

一个测试写不成,改成 check

duplicate.spec.ts 写完跑不起来:import project.service 会拉进 ESM-only 依赖,jest require 不了(instructions.ts 单独成模块是同一个原因)。 没有为了可测性去拆服务 —— 改成 scripts/check-duplicate-project.mjs 守同样 的性质(委派而非复制、两条 guard、自复制不计数、继承配额、guarded 且 userId 取自 token、UI 防重复点击、错误不被压平)。三处还原都验证过会失败。

pnpm check 28/28,双端 tsc 干净,lint 干净,next build exit 0。 后端 240 里 1 红是 bug-agent 的 restore.spec.ts(它的未跟踪新文件 + 它在改的 host-workspace),我的 6 个套件 41 条全绿,已报给 lead。

新用户空态:从死路变成四个起点

工作台空态原本只有一句 "Nothing yet. Describe a project above to start one." —— 对不知道该写什么 prompt 的人这是死路,而那正是新注册用户。

  • 四个示例 prompt,点一下填进 composer 并滚回顶部(手机上 composer 在屏外, 不滚的话点了像没反应)。
  • 一条 "see what others made" 链到画廊 —— 配合 remix 闭环,fork 一个改比 从零 prompt 门槛低得多

示例必须具体,这是这轮唯一有含金量的约束:"做个网站"会触发 planner 的 question card —— 那对模糊 brief 是正确行为,但作为第一印象是错的,用户 想看的是产品会建东西。所以每条都点名 kind + 观感 + 一个具体区块: "A SaaS landing page, dark, with a pricing section"。四条覆盖 landing / personal site / dashboard / email 四种 scenario。

复用而非新建:Workbench 早就拿着 promptFormRef(为了读 prompt), 只给那个 ref 加了个 setMessage,没有把 composer 的 state 抬到父组件。

实测(真浏览器):桌面 1440 与 390px 两个宽度,四个示例都在、画廊链接 都在、点击都真的填进了 composer;390 下整齐折行不溢出。

check 写完第一版是坏的:正则要求 20+ 字符才算一条 starter,于是把 'A website' 换进去时它反而匹配不到、检查通过了 —— 正是它要防的东西。 改成先切出 STARTERS 数组再逐条判词数。三处还原现在都验证过会失败。

pnpm check 29/29,双端 tsc 干净,lint 干净,next build exit 0。

全局 code review(整晚 3405 行改动,52 文件 + ~50 个新文件)

戴 reviewer 帽子过了一遍全量 diff,包括审自己写的那大半

修了(都是小的,当场改)

  1. 同一个动作两个名字 —— 画廊说 Fork,分享页说 Remix,而同一个 数字在两处分别是 "3 forks" 和 "3 remixes"。用户看到的是同一件事。统一 成 Remix(lead 一直用的词、也是 open-design 的概念):按钮、 "remix any of them"、"Sign in to remix"、计数全部对齐,并补上画廊缺的 单复数("1 remix" 而不是 "1 remixs")。
  2. 一个 check 守着刚被改名的东西 —— 改完 #1 我自己的 check-remix-landing.mjs 立刻红了(它断言 forks 字样)。这正是 review 清单第 3 条要找的那类问题,已更新断言。check 会红是好事,它证明那条 断言不是摆设。
  3. 注释债(我的):lint-artifact.ts 头部仍写着 "nothing feeds them back to the agent, so it does not self-correct" —— 第一轮就把这条闭环了,留着 会让下一个读者以为还是死路。已改成指向 lint-note.ts
  4. YAGNI(我的):findUserByEmail(users, email, relations = [])relations 参数六个调用点没有一个用过。纯投机参数,删掉。
  5. 无用的 as any:delete({ token: refreshToken } as any) —— token 本来就是 string,这个 cast 从来不需要。删掉后 tsc 仍然干净, auth.service 现在 as any。不必要的 cast 会吞掉将来的真类型错误。

遗留清单(按严重度,不是我该动的)

P1 — 会被误提交

  • 仓库根目录三个未跟踪、未 gitignore 的临时脚本:r12focus.tmp.mjs / r12kb.tmp.mjs / r12seed.tmp.mjs(gap-agent 的 a11y 走查脚本,打 3200 端口)。不在 .gitignore 里,下一次 git add -A 会进仓库。 作者删或 加 ignore,我没动别人的文件。

P2 — 一致性

  • subNumber 这个字段名在 UI 上现在叫 "remix",在数据库/GraphQL 里还叫 subNumber(既不是 fork 也不是 remix)。改名要动 schema + 前端 + E2E, 不值得现在做,但记一笔:第三个名字

P3 — 已知且有意

  • project-queue.ts 的 per-user turn 计数和 jwt-cache 一样是进程内的, 多实例部署会各算各的。两处都有 ponytail: 注释写明了升级路径。
  • share chrome 的 Remix 按钮跳画廊而不是就地 fork(CSP sandbox 的取舍, 已在对应轮次记过)。

没发现的(查过,是干净的)

  • 未接线的导出:新增的 7 个模块逐个扫过,零个只导出不被调用的符号, 且每个都有真实生产消费者(不是只有测试在用)。
  • 错误处理风格:三个 agent 写的 toast 文案是一致的("Could not X", 服务端消息可行动时透传),没有互相打架。
  • 单实现接口 / 工厂 / 冗余配置:没有。

验证:pnpm check 31/31,后端 240/240(bug-agent 的 restore 也回绿 了),双端 tsc 干净,lint 干净,next build exit 0。

light theme 侦察:是设计过的,只有一处欠债 —— 我自己写的

侦察结论先行:light 不是"没坏",是真的设计过globals.css:root 是一整套 paper 变量(#FAF9F5 纸、#B0532F 为纸面压暗过的 terracotta、#6B655C muted),.dark 是另一套,不是反色。

实测:五个面,零对比度失败

用 puppeteer 在 light 下跑了落地页 / 工作台 / chat / 设置 / admin,对每个 文本节点算它和自己实际背景的 WCAG 对比度(沿 DOM 往上找第一个非透明背景), 按字号区分 4.5:1 / 3:1 门槛:

[landing] light=true low-contrast=0
[workbench] light=true low-contrast=0
[chat] light=true low-contrast=0
[settings] light=true low-contrast=0
[admin] light=true low-contrast=0

这一晚新增的 UI(Notes dialog、密码行、空态起点、Public 徽章、remix 计数) 全部走 token,没有一个硬编码 hex,所以它们在 light 下自动是对的。

唯一的债:分享页 chrome(第十二轮我写的)

它由后端渲染,够不到 globals.css,所以颜色只能内联 —— 我当时全写死成 dark。后果:一个用 light 系统的陌生人打开分享链接,看到的是浏览器里一块 深色板。而分享页恰恰是唯一给陌生人看的页面

改成 color-scheme: dark light + 一组 CSS 变量 + 一条 @media (prefers-color-scheme: light),dark 仍是默认(它是被设计的那个), light 用的是 app 自己的值(#faf9f5 / #b0532f),不是我现编的反色。

实测两种系统偏好:dark → rgb(20,17,15),light → rgb(250,249,245)

check 当场抓到一个我漏的:iframe { background: #fff } —— 页面加载前 那一瞬会在深色页上闪一下白。改成 var(--page)。这是 check 写完立刻回报的, 不是我自己看出来的。

scripts/check-share-chrome-theme.mjs:守 light 分支存在、color-scheme 两值、style 块内零硬编码颜色、light 值必须还在 globals.css 里(防止 chrome 抄了个过期值)。两处还原都验证过会失败。

pnpm check 33/33,后端 240/240,双端 tsc 干净,next build exit 0。

没做的:hero 画布(hero-canvas.jpg)按你说的豁免 —— 它是烤好的艺术 资产,dark-only 是有意的。存量面没发现问题,所以没有"存量清单"要交。

文档对齐:三处改,两处"查完确认不用改"

第十八轮。每条声明都对着代码/配置核实过,不确定的没写。

改了

  1. DEPLOY.md 补三个 env(表格形式,和已有风格一致)。最重要的是 FRONTEND_URL —— 密码重置和邮箱确认的链接全部由它拼出来, 生产上不设就指向 localhost,唯一症状是"用户回不来"(第七轮实测踩过 这个,当时链接是 undefined/reset-password?token=…)。另外两个是 MAX_PROJECTS_PER_USER / MAX_TURNS_PER_USER
  2. README 的模型配置说反了主次:它让用户设 OPENROUTER_API_KEY,而 代码是 env('LLM_API_KEY') ?? env('OPENROUTER_API_KEY') —— OpenRouter 那个是回落.env.example 里也一直只有 LLM_API_KEY。改成先讲 LLM_API_KEY + 任意 OpenAI 兼容端点,OpenRouter 作为零配置选项和回落。
  3. README 新增 Checks 段:此前完全没提 pnpm check 和后端测试 ——33 个 check 已经是 CI 的一步,贡献者却在 README 里找不到。耗时是实测 的:pnpm check 13s、后端 jest 8s(不是估的),并写明 visual-qa*.mjs 故意不在 check 里(它们要一套跑着的栈)。

查完确认不用改

  • README quick start 准确:pnpm dev = dev-init.mjs && turbo dev, 确实会生成两个 .env;端口、SQLite 路径、DATABASE_URL 切 Postgres 的 说法都对;列的两个模型和 ai.constants.ts 的默认值逐字一致。
  • .env.example 完整:35 个条目,fail-fast 那三个(JWT_SECRET / JWT_REFRESH / SALT_ROUNDS,在 env.validation.ts 里是非 optional) 都在,且校验器不拒绝未声明的变量,所以我加的两个 cap 不会挡启动 (第十/十三轮已实测启动过)。
  • pnpm check 13s,远低于"超过 1-2 分钟就写说明"的线,不需要额外文档。

记录:config.schema.json 是死的

它描述的是 chat models(不是 env),而且:仓库里没有 config.json, getConfigPath() 零调用者,config.schema.json 本身没有任何代码引用。 所以"新 env 要不要补进去"的答案是不要 —— 往一个没人读的 schema 里加东西 只会让下一个人以为它是活的。已于 2026-08-14 经用户批准后删除

open-design 系统对照 + 一个真 bug

对照表(WebFetch 读的上游 README)

上游能力 codefox 现状
151 套设计系统 已有等价物 — 155 套(design-systems.ts)
反 slop lint / self-critique gate 已有且更进一步 — 我们的 findings 回喂 agent(第一轮),上游只是 gate
DESIGN.md 品牌合约 已有等价物 — NOTES.md,且可见可编辑(第二轮)
导出 HTML / PDF / ZIP 已有
模板/形态 (36 templates) 部分 — 6 个 scenario(landing/dashboard/deck/email/docs/app)
remix / use-as-template 已有(第九、十三轮)
导出 PPTX / MP4 / 图片 / 音频 有意不做 — 每个都是独立管线(HyperFrames、TTS),不是一晚的量级
Electron 桌面版 有意不做 — 产品形态是 web
MCP server / 26 种 agent 集成 有意不做 — codefox 自己就是 agent 宿主,不是被集成方
277 plugins / 插件市场 有意不做 — 需要注册表+分发,是平台级投入
BYOK + SSRF 防护 已有(external-url.ts)

结论:值得借鉴的都借完了。 剩下的要么是平台级投入(插件市场、桌面端、 MCP),要么是独立管线(PPTX/视频/音频),没有"今晚可完成且高价值"的项目。 按 lead 的指示转为打磨既有交付。

打磨:分享页的 Remix 对大多数项目是死的

挑这个是因为它是唯一给陌生人看的页面上的主 CTA,而它有一个我第十三轮 自己写下的、当时没意识到严重性的缺陷。

链接原本带 uniqueProjectId(share id),而落地页要 fork 需要 row id, 所以它得先在画廊墙上按 share id 找到那个项目 —— 而墙只取 6 个 (fetchPublicProjects size:6,且要求有封面)。于是:

任何不在最新 6 个里的公开项目,它的分享页 Remix 按钮点了什么都不发生。 if (!match) return; —— 静默返回,无 toast、无报错、无日志。

修法是把链接直接带 row id(chrome 手上本来就有整行),forkProject 收的 就是这个 id,连查找都不需要了。删掉了那次墙扫描和那条静默 return。

实测(真后端):故意造一个公开但不在墙上的项目(无封面 → 被墙的封面门槛 过滤掉),对它调落地页现在发的那个 mutation → Fork of Filler 1 成功; chrome 输出的链接也确认是 row id。

两个守卫在我改完的瞬间都红了(check 的 handleFork(match.id) 断言、 chrome 单测的 remix=abc-123),这正是它们该做的;更新后新增一条 assert.doesNotMatch(/if \(!match\) return;/) 守住"不许再退回墙扫描", 还原验证过会失败

后端 240/240,pnpm check 33/33,双端 tsc 干净,lint 干净。 next build 当前被 gap-agent 的 admin-console.tsx 挡住 (Failed to collect page data for /admin)—— 已隔离验证:stash 掉它的改动 后 exit 0,带着它 exit 1,与我无关,已告知 lead。

公开页 SEO:robots / sitemap / meta / canonical

侦察结论:四样全都没有。没有 robots.txt、没有 sitemap、app 自己没有任何 og 卡片,而 title 和 description 是同一句占位符("The best dev project generator" 写了两遍)。

做了

  • robots.ts / sitemap.ts(Next 原生 route,不引库)。//share/ 放行; /chat /settings /admin /auth/ /reset-password 全部 disallow —— 一个重置链接出现在搜索结果里就是公开的凭证
  • layout.tsx 换成真的 title / description / og / twitter, metadataBase 让相对的 og:image 变绝对(否则 Next 只会警告并发一个爬虫 取不到的相对 url)。
  • 分享页 <link rel="canonical"> 指向干净 url —— 转发链接会带 ?utm_source=…&fbclid=…,不加的话每个变体都是一个和自己竞争的页面。 实测带这两个参数访问,canonical 输出的是干净地址。
  • 工作台卡片时间包了 <time dateTime>(相对文案不变,机器能读到真时间戳)。

两个只有实测才看得见的坑

  1. (main)/layout.tsx 里有第二份 metadata,是同一对占位符 —— 嵌套的 赢,所以我在 root 写的真标题根本没到过页面。浏览器取到的 title 仍是 Codefox - The best dev project generator — CodeFox(还被 template 套了 一层)才暴露。删掉嵌套那份。
  2. sitemap 原本是构建期预渲染的(revalidate 单独用不改变这一点)。 而构建发生在没有后端可达的地方 —— 我把后端停掉重建,产物里 sitemap.xml 只有首页一条,分享页一条都没有,而且是烤死的。 改成 export const dynamic = 'force-dynamic',路由表从 ƒ, 请求时才查。实测:后端起着 → 2 条(首页 + 真实分享页);后端停着 → 构建仍 exit 0、请求仍返回首页那条(catch 兜底)。

没做(按 lead 的"别过度"):不做 SSR 改造、不加 JSON-LD 全家桶。og + canonical + sitemap 已经覆盖分享和索引这两件真事。

scripts/check-public-seo.mjs:守"只有一处 metadata"、私有路由必须在 disallow 里、canonical 存在、sitemap 必须是 force-dynamic、后端挂掉要 降级。两处还原验证过会失败。

后端 248/248,我的 15 个 check 全绿,双端 tsc 干净, 干净 .next 重建 exit 0(robots.txt 静态 sitemap.xml 动态 ƒ)。

收尾:pnpm check 以前会瞒着你

第二十一轮自选。挑的不是新功能 —— 挑的是那个所有人都在依赖、而今晚一直 是瞎的门禁

pnpm check 原本是:

for f in scripts/check-*.mjs; do node "$f" || exit 1; done

遇到第一个失败就停。 今晚 check-admin-guarded.mjs 一直红着(见下), 于是排在它后面的 33 个 check 谁也没跑过 —— 输出里一个 ok 都没有,而我 们整晚都在拿这个命令当"全绿"的证据。

换成 scripts/run-checks.mjs:跑完全部,失败的逐条列出(带断言原文,截断到 一行),末尾一句 35/36 checks passed,退出码不变(有失败仍然 exit 1, CI 行为一致)。

实测:故意再打断一个(把 sitemap 的 force-dynamic 删掉)→ 34/36 checks passed / failing: check-admin-guarded.mjs, check-public-seo.mjs —— 两条都点名了。老 runner 下第二条根本不会被发现。

那条红灯本身是误报

check-admin-guarded.mjs 断言的自锁守卫好好地在 admin.service.ts:262, 只是 prettier 把 if (!granted && userId === actingUserId && ...) 折成了四 行,而 check 的正则写死了单行形式。守卫在,check 却永远红 —— 这种状态比 没有 check 更糟:下次真出问题没人会信它。已把定位和修法转给 gap(它的文件, 我没动)。

冻结建议

ponytail: 单进程顺序跑,全套 ~13s。要并行再说,现在省的是秒、花的是一个 调度器。

改动面建议到此冻结。 122 个文件、4131 行插入、三个 agent 并发,而今晚 最后两轮抓到的两个问题(嵌套 metadata 遮住 root、runner 遮住 33 个 check) 都是"看起来在工作、实际被遮住"那一类 —— 这类问题的密度随改动面上升, 再加新东西不如让现有的先被真正看一遍。

最终确认:后端 253/25335/36 check(唯一那条是上述误报)、双端 tsc 干净、干净 .next 重建 exit 0(robots.txt 静态、sitemap.xml 动态)。