wp-durable · Server v2 handover (Change 3, 2026-10-10)

Status: ACCEPTED by piinchrome-1c on 2026-10-11 on deployment 18c9c20a (image of a69e0309 plus ANTHROPIC_API_KEY). See Acceptance.

Brief: docs/SERVER-V2-BRIEF.md (approved by Steven). Caller documentation: docs/API-GUIDE.md (also on https://wp-durable-docs.pages.dev). Earlier changes: STAGE1-HANDOVER.md (Stage 1 and Change 2).

Deployment

Item Value
Service wp-durable · d903f418-9cd4-48e6-b9f5-f5ffaf21803e, project wp-durable, environment production
URL https://wp-durable-production.up.railway.app
Volume /data (unchanged): agent.sqlite*, credentials.json, new work/<conversationId>/ and .spill/
Deployments M2 review ebaa09b9 (uid 1001, tools off by the self-test), M2 161ae662 (uid 61337, tools on), M3 a69e0309 (2026-10-11 02:29 UTC), final 18c9c20a (2026-10-11 02:45 UTC: redeploy of a69e0309 that activated ANTHROPIC_API_KEY, issued during the resume test)
Commits M1 8743b01, M2 eed07b7 and d5763de, M3 8111866
Deploy method git archive HEAD Dockerfile railway.json package.json package-lock.json tsconfig.json src test into a temporary folder, then railway up <folder> --path-as-root --service wp-durable --ci from the project root
Variables WP_DURABLE_TOKEN, PORT, WP_DURABLE_MODEL (unchanged), ANTHROPIC_API_KEY (set 2026-10-11 from .secrets/anthropic-api-key; ANTHROPIC_AUTH_TOKEN and ANTHROPIC_OAUTH_TOKEN unset)
Optional limit overrides WP_DURABLE_LIMIT_<NAME>; the full list is in the guide's Limits section. None set.
Test-only variables (never in production; refused without WP_DURABLE_FAUX=1) WP_DURABLE_FAUX, WP_DURABLE_FAUX_TOKENS_PER_SECOND, WP_DURABLE_FAUX_SCRIPT, WP_DURABLE_TEST_ALLOW_HOSTS, WP_DURABLE_TEST_UNISOLATED, WP_DURABLE_TEST_CALLBACK_DELAYS_MS
Agent user agent, uid/gid 61337 (ARG AGENT_UID in the Dockerfile, the one place to change it)

What changed

Area Source
Client API routes (brief §4) and views src/routes.ts, src/app.ts, src/submit.ts, src/env-routes.ts
Streams (events, watch, submission stream) src/streams.ts, src/http.ts (SSE), src/jsonpatch.ts
Server documents: wp.meta (title, createdAt), wp.request (per requestId: createdAt, mode, callbackUrl, settledAt, callbackTaskId), wp.callbacks (pending callbacks) src/meta.ts
Tools: http_fetch, bash, read, write, edit src/tools.ts
Sandbox and the startup self-test src/sandbox.ts
Address rules (http_fetch, callbacks) src/netguard.ts
Callbacks (wp.callback) src/callbacks.ts
Secrets out of process.env, limits, test switches src/config.ts, src/errors.ts
Faux test mode src/faux.ts
Image Dockerfile (base stage with the tool programs and the agent user; a test stage)

Image programs (apt, Debian bookworm, as built 2026-10-11)

bash 5.2.15-2+b13 · coreutils 9.1-1 · findutils 4.9.0-4 · grep 3.8-5 · sed 4.9-1+deb12u1 · gawk 1:5.2.1-2 · tar 1.34+dfsg-1.2+deb12u1 · gzip 1.12-1 · xz-utils 5.4.1-1+deb12u2 · unzip 6.0-28+deb12u1 · procps 2:4.0.2-3 · curl 7.88.1-10+deb12u15 · jq 1.6-2.1+deb12u2 · git 1:2.39.5-0+deb12u3 · ca-certificates 20250419~deb12u1 · python3 3.11.2-1+b1 · python3-venv 3.11.2-1+b1 · python3-pip 23.0.1+dfsg-1 · util-linux 2.38.1-5+deb12u3 (setpriv, prlimit). From the base image: node v22.23.3, npm 10.9.9. Versions move with Debian security updates at each build.

Isolation (brief §6)

  1. Process environment: WP_DURABLE_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_OAUTH_TOKEN are read and deleted from process.env before anything else runs; pi-ai gets them through createModels({ authContext }); redact() masks the captured values.
  2. Command environment: only HOME, TMPDIR, PATH, LANG, WP_CONVERSATION_ID (inheritEnv: false, enforced in the environment's exec).
  3. Privilege separation: every command runs as prlimit --nproc=256 --fsize=2147483648 --core=0 -- setpriv --reuid --regid --init-groups --inh-caps=-all --no-new-privs -- …. Volume modes set at every start: /data 0711 root, agent.sqlite* and credentials.json 0600 root, /data/work 0755 root, /data/work/<id> 0700 agent, /data/.spill 0700 root.
  4. File confinement: a canonical-path check gives the "outside the work directory" message; reads then check the opened descriptor (a regular file owned by agent); every write into a work directory runs as agent through a short helper command, so the kernel enforces it. Invariant: every file under /data/work is owned by agent; the Server never reads a file that is not owned by agent.

Startup self-test (fail closed: any failing check turns all five tools off; the report is in /v1/status): commands run as agent (uid and name); no secret names in the command env; no capabilities and NoNewPrivs; resource limits; the volume modes; work directories owned by agent; the wrapper and the descriptor check refuse root files; bash cannot read a root file or credentials.json or list /data; the agent cannot signal the Server; the agent helper cannot write a root file. A failing check with EAGAIN names the likely cause (a uid collision).

Why uid 61337 and not the brief's 1001: Railway runs containers without a user namespace (uid_map 0 0 4294967295), so a uid in the container is the host uid, and RLIMIT_NPROC counts that uid's processes on the whole host across tenants. With uid 1001 the host already had more than 256 such processes, so exec after setpriv failed with EAGAIN and the self-test turned the tools off on ebaa09b9. An unusual uid keeps the 256-process limit a real bound.

Tests

Suite Command Result at M3
Unit (vitest) npm run test:server 152/152
e2e, faux, laptop (unisolated) npm run test:e2e 51 passed, 1 container-only skipped
Mock suite npm --prefix mock test 96/96
Isolation, Linux container as root docker build --target test -t wp-durable-test <context> && docker run --rm wp-durable-test isolation 13/13
e2e in the same container (real setpriv, uid 61337) same run 52/52
Production image, fresh volume, no faux docker build default target, run with a token self-test ok, 5 tools; Change 2 smoke 35/36 (the strict check of agent now sees the additive tools field)
Production after the M3 deploy a69e0309 read-only check plus a replay of t0-1 15/15: healthz ok with 5 tools; openai-codex present; root 1 unchanged; isolation ok (setpriv, uid 61337, 20 checks); submission 8 readable and t0-1 replays to 8; no faux route; logs without token or sk-ant

npm test runs the unit, faux e2e, and mock suites. The e2e suites cover: every §4 route; streams (events, watch, a 1.5 KB answer streamed in many text events); images; busy handling, withdraw, abort, long poll; reset, compact, fork; exactly-once across a restart; tool rounds for all five tools; refusals (outside paths, symlinks, blocked targets, redirects to loopback); timeouts, spill files, abort of a running command; the work-directory routes; callbacks (delivery, retries, giving up, refusal, background); resume after a kill in the middle of a bash call (interrupted result with the output so far, an answer, one callback, orphaned processes killed) and of an http_fetch POST (not sent again).

Deviations from the brief

M1 (accepted)

  1. A conversation's usage stays the full pi.usage document (Stage 1, Change 2); the contract's {tokens, cost} is usageSummary.
  2. Error bodies keep { error: message }, plus code on 409s (busy, request_id_conflict, already_placed, settled) and provider on the credential 409.
  3. Times: the Engine stores none. Conversation createdAt is stamped by the conversationCreated hook (older conversations fall back to the oldest message time); entry createdAt is the model message timestamp (entries without a message have none); submission createdAt comes from wp.request, settledAt from a commit-listener follow-up commit. No placedAt.
  4. requestId stays required; type defaults to input on the conversation route; /v1/submit takes input only; unknown keys on submit bodies are ignored (Stage 1 behaviour, both submit routes).
  5. /v1/tasks/:id shows the Engine-stamped startedAt/endedAt; input and checkpoint are not exposed.
  6. Watch: a slow client gets one larger diff patch instead of a new snapshot frame.
  7. The submission stream forwards deltas from attach time on (see M3 item 7).
  8. Faux mode only: retries off, compaction keepRecentTokens 1.
  9. Limit overrides are named WP_DURABLE_LIMIT_<NAME>.

M2 (accepted)

  1. bash keeps the last 64 KB (the Engine keeps head or tail, not both); the full output is in the spill file.
  2. Spill files stay Server-private (/data/.spill, root 0700) while the command runs, then are copied into <workdir>/.tmp as agent.
  3. Directory listings and file info stay in the Server process (names and sizes only).
  4. Fail closed turns off all five tools, http_fetch included (its downloads use the agent helper).
  5. http_fetch refuses URLs with credentials, drops host and hop-by-hop headers, decompresses gzip/deflate/br under the size cap, and also blocks 100.64/10, 192.0.0/24, 198.18/15, 240/4, NAT64 with the embedded IPv4 checked, and the IPv6 documentation and discard ranges.
  6. read is replay safe; http_fetch is declared replay safe and marks non-GET/HEAD requests with a durable memo, so a rerun returns interrupted without sending again.
  7. The self-test uses /data/work/0 and /data/.isolation-probe and removes both.
  8. POST /v1/conversations/:id/submit stays lenient on unknown keys, like /v1/submit.
  9. Laptop tests use WP_DURABLE_TEST_UNISOLATED=1 (faux only).
  10. The Dockerfile has a test stage, so the deploy archive includes test/; the runtime image never contains it.
  11. Agent uid/gid 61337 instead of 1001 (cause above). Work directories of an earlier agent uid are handed to the current one at start (logged); root-owned entries under /data/work fail the self-test.

M3

  1. The callback's exactly-once key is the request record's callbackTaskId (wp.request), set in the commit that creates the task; the Engine has no request id for tasks.
  2. The task is created in the follow-up commit that stamps the settlement (a commit listener), not in the settling commit itself. A crash between the two is repaired at start from the wp.callbacks pending list (written with the request record before admission).
  3. Four attempts in all (one plus three retries); redirects are not followed; a restart in the middle of an attempt repeats that attempt with the same x-wp-durable-attempt (at-least-once per attempt).
  4. Callback URLs with credentials are refused; the stored URL is shown without path and query.
  5. The callback body is the submission view at delivery time (its callback field shows the delivery in progress).
  6. At start, the Server kills leftover processes of the agent user (pkill -u) before resuming, so an interrupted command cannot keep writing. On Railway the container is new after a restart anyway.
  7. Fix of an M1 defect in the submission stream: the opening content of each message (carried by message_start) and the partial already written at attach time were dropped, so a short answer arrived as one text event at the end. Now the concatenated text deltas always equal the answer.
  8. Test-only switches WP_DURABLE_FAUX_SCRIPT (responses scripted at start, for the kill-and-restart test) and WP_DURABLE_TEST_CALLBACK_DELAYS_MS.
  9. scripts/build-site.py (outside the brief's file list) publishes this handover on the docs site.
  10. The container runs the e2e files one after another (a starting Server kills the agent user's processes).

Acceptance

piinchrome-1c, production, real models, 2026-10-11:

Script Result Notes
v2 §11 (conversations 353, 371, fork 407) 26/26 27 text deltas for a 1.2 KB answer, concatenation exact; events snapshot → message_update/run_end; thinkingLevel: "high" on gpt-6-sol streamed a thinking delta; image read ("ZEBRA 4471"); callback to the mock sink delivered once with its state on the submission; a replayed requestId returned the same submission with still one delivery; a loopback callbackUrl → 400; a queued follow-up withdrawn; fork, reset with handoff, manual compaction; task abort; /v1/inspect; /v1/usage; docs site / and /v2-handover/.
Resume (§11.9, conversation 432, submission 441) 8/8 Redeploy during echo before-restart; sleep 240; echo after-restart: the run settled 36 s later with the bash result "before-restart" and "Tool bash was interrupted and may have partially run" (no "after-restart"), the run answered, the callback was delivered exactly once (one sink hit, attempt 1), earlier conversations intact, no live task left.
Change 2 Claude phase (conversation 24) 13/13 anthropic/claude-haiku-5-5 answered "pong" ($0.00026); PATCH to claude-sonnet-5-5 with thinkingLevel: "low" made real http_fetch and bash calls (bash as agent); an image read; usage.models shows both models; root conversation 1 still on gpt-6-sol; no secrets in entries.
M2 isolation and tools, rerun on the M3 image 21/22 The one miss is the script's expectation (ls of a 0711 directory exits 2, not 1); the listing was denied as required.
Stage 1 and Change 2, rerun 17/18 The one miss is the script's secret pattern matching the documentation text "sk-ant-…" on /handover/; no key is on the page.

Logs of a69e0309 and 18c9c20a (wpDurable scan, up to 5000 lines each): no token, no API key or key fragment, no sk-ant, no bearer header.

Known incidents

Known limitations

Open items

None. ANTHROPIC_API_KEY is set (2026-10-11); rotate it by replacing .secrets/anthropic-api-key and running the command of STAGE1-HANDOVER.md § Change 2, then a redeploy.