feat(chat): fork openwork as the CommonPlace chat register (OW1, OW3-OW6) - #158
Conversation
SPEC-COMMONPLACE-OPENWORK-FORK-1.0, plan:c8307a874c5981f5. Lands OW1,
OW6, the conformance gate, the Verify First audits, and the spec anchor.
Vendored from different-ai/openwork @ 2f2dde6 (dev), MIT subset only:
apps/app -> apps/chat, apps/server -> apps/chat-server, and
packages/{ui,types,paths} -> packages/openwork-*. /ee never crosses;
handsfree declined (macOS Swift AX runtime), openwork-ui-mcp quarried
(local-desktop discovery transport), install-config dropped (its sole
consumer was the Den join dialog).
Severed: PostHog telemetry including its baked-in project key, Den
telemetry ingest, the hosted model catalog default, the Den base URL and
sign-in flag (now false literals so Vite eliminates the branches), the
Den control-plane MCP quick-connect and its openworklabs fallback, an
`npx -y openwork-ui-mcp` entry that fetched at runtime, the
capabilities-knowledge plugin that taught Den endpoints as truth, and
donor support/docs/feedback destinations.
Evidence: both packages typecheck clean, the web build succeeds, and a
scan of the built bundle finds zero openworklabs.com and zero PostHog.
scripts/audit-openwork-fork.sh enforces the eight named choices and the
anti-scope lines mechanically; it passes except the OW3 token check,
which fails on raw hex colors as it should until OW3 lands.
Four Verify First findings contradict the spec's Frame and are recorded
as amendments A1-A8: ee/LICENSE is FSL-1.1-MIT by name; there are six
plugin factories, not five; no sessions/artifacts schema exists, so
artifact truth is opencode's filesystem storage; and @ai-sdk/react is
unused with `ai` imported type-only, so there is one transport seam, not
two. OW6 decides on that evidence: stay on the opencode head, because
the seam is two client-construction sites and the real divergence is
artifact identity, which replacing the transport would not fix.
…session OW3. The fork's Tailwind theme is emitted from the console registers rather than restated. scripts/generate-console-register.mjs materializes the resolved --ij-, --cp-, and --gy- values into src/styles/console-register.css, with a drift gate and a hard error on any token that would resolve to nothing. Both semantic layers (--dls-* and the shadcn slots) rebind to it, the radius law follows the console's five-step ladder instead of one base times seven coefficients, and fonts resolve to the three faces the tokens declare. Geist shipped as a font file that no rule ever named; it is gone. Raw hex leaves component source: the workspace identity ramp reads console hue families, the boring-avatars palette table moves to the register lane beside the console's own precedent, and the surfaces that genuinely need a literal (WebGL uniforms, xterm's ITheme) read the register at runtime and re-read it on a scheme change. Shader law: one mount per window. The activity orbs each held a WebGL context, one per in-flight message, so the count was unbounded; they are now compositor gradients. check-shader-mounts.mjs holds the line at one. OW4. Den sign-in is replaced by the console session. chat-server verifies the console's signed cp_active_workspace cookie, so a console user reaches this register with no second sign-in and the browser holds no token. Only the verify half lives here; the wire contract is pinned against the console's own encoder, which is the drift alarm across the two apps. The Den view surfaces are deleted, with them the per-organization brand accent that overwrote --dls-accent at runtime, and the orphaned join-organization state. Bundle scan: the three DEN flags are eliminated, no donor host survives. 25/25 conformance checks pass.
…space container OW2. The head's graph door is configuration, not surgery: theorem-mcp.ts emits a remote MCP entry and merges it over the operator-managed runtime map, so a settings write cannot silently disconnect the head from the graph. Absent when THEOREM_MCP_URL is unset, with no default endpoint, for the same reason OW1 removed the hosted model catalog. The prompt was the real bug. OW1 severed every Den endpoint but left the agent instructed to reach Den's meta-MCP for memory, naming search_capabilities and postMemory as the way to remember and recall. Severing a transport does not sever the instructions describing it, and no path or bundle audit catches a string. Replaced with graph doctrine; a test asserts the Den tool names are gone from the shipped prompt. Live proof is partial and amendment A12 says exactly how far it goes: opencode 1.17.11 starts against the config this fork generates and accepts it. The MCP handshake, the permission round-trip, and todos/events are not proven, because every app-scoped route on this engine build hangs and a local stand-in for the Theorem MCP received no request. Recorded as unresolved, not as passing. OW5. One image, two doors, one checkout at WORKSPACE_DIR. The registers see the same filesystem because it is the same filesystem, so there is no sync step and no reconciler that could be wrong. Both doors authenticate against one WORKSPACE_TOKEN, and the entrypoint refuses to start without it rather than exposing an unauthenticated IDE. check-two-doors.mjs makes both claims falsifiable on a machine with no Docker daemon, which is where the drift that matters (a second path, a second secret) actually gets introduced. Amendments A9-A12: the route-versus-zone decision is a route on the console origin, because the session cookie is what the choice is really about. Conformance: 33 checks, all green. The audit now also holds the token generator, the shader count, the no-second-design-authority rule, the verify-only console session, and the two-door invariants.
…sion A12 said the remaining OW2 proof needed a model-provider credential. That was wrong. The blocker is a deadlock in opencode 1.17.11's app-scoped routes, and it happens on GET /config, before any provider is selected. Five causes ruled out by direct experiment, with stock opencode carrying no OPENCODE_CONFIG as the decisive control: it deadlocks identically, so the Theorem MCP binding is not implicated. Also ruled out: the models.dev catalog (local mirror changed nothing), a missing git repository, and disk pressure, which was a real confound on the first runs at 116 MiB free and reproduced unchanged at 7 GiB. A13 records why Theorem ACP is not the instrument for this. It would change the deliverable rather than verify it, since an ACP session does not exercise the opencode head that OW2 names, and OW6's stay-on-opencode decision still holds because its reversal condition is a contradicted permission assumption, which a deadlock that never reached the permission model is not. Where ACP does belong is named so it is not lost: OW7, and stage two if that condition fires. DeepSeek is settled in the same amendment: already in the fork's provider tables, reachable through the provider block the runtime config passes through, so it is a configuration line whenever a live session is wanted.
The OW3 font change edited apps/chat/package.json (Geist out, JetBrains Mono and Manrope in) and I committed it without pnpm-lock.yaml. That is not cosmetic: packaging/workspace/Dockerfile installs with --frozen-lockfile, so the OW5 image would have failed at that layer with ERR_PNPM_OUTDATED_LOCKFILE. Verified both directions: the committed lockfile fails the frozen install, and this one passes it under the same filter the Dockerfile uses. Docker never came up in this environment, so the image build could not have caught it, and every other gate stayed green while the deploy was broken. The audit now runs the frozen-lockfile check itself, which is the only thing standing between a dependency edit and a failed deploy on a machine that cannot build the image. Found by the repo-hygiene check flagging an uncommitted pnpm-lock.yaml.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 35660538 | Triggered | Generic High Entropy Secret | 5514313 | apps/chat-server/src/agent-context-cloud-probe.test.ts | View secret |
| 35660539 | Triggered | Generic High Entropy Secret | 5514313 | apps/chat-server/src/agent-context-diagnostics.schema.test.ts | View secret |
| 35660540 | Triggered | JSON Web Token | 5514313 | apps/chat-server/src/cloud-mcp-health.test.ts | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Important Review skippedToo many files! This PR contains 874 files, which is 774 over the limit of 100. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (206)
📒 Files selected for processing (874)
You can disable this status message by setting the |
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Investigated all three. They are false positives, and the reason is specific: every one is a canary belonging to the diagnostic sanitizer's own test suite, so the scanner is flagging the test data of a secret-redaction test.
Nothing to revoke or rotate. None of the three can be removed or altered. Each is the input to a No code change was made. This check is the GitGuardian GitHub App rather than a The verification is recorded in 🤖 Addressed by Claude Code |
|
Both automated reviewers skipped this PR on file count, and that is a fair objection rather than a quirk to wave through: 865 of the 1071 files are a vendored snapshot of I have pushed the vendoring commit as a base ref so the imported code can be subtracted:
Diffing @coderabbitai review --base openwork-vendor-base Copilot cannot be scoped the same way, since it reviews the PR as a whole and the PR is still 1071 files against its 300-file cap. The One caveat on the 64-file range, so it is not mistaken for the whole story: it excludes OW1 itself. The severing work (telemetry removed, Den control plane cut, hosted model catalog dropped) lives inside the vendoring commit, because vendoring and severing were one operation. 🤖 Addressed by Claude Code |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
git show --no-patch --format='%H%n%s' openwork-vendor-base
git merge-base --is-ancestor openwork-vendor-base HEAD
git diff --stat openwork-vendor-base..HEAD
git diff --name-status openwork-vendor-base..HEAD
fd -a '^MODIFICATIONS\.md$' apps/chat -x sh -c 'printf "\n--- %s ---\n" "$1"; sed -n "1,260p" "$1"' sh {}Length of output: 854 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- remotes ---'
git remote -v
printf '%s\n' '--- remote openwork-vendor-base ---'
git ls-remote --heads origin openwork-vendor-base || true
printf '%s\n' '--- local supplied commit ---'
if git cat-file -e '5514313e^{commit}' 2>/dev/null; then
git show --no-patch --format='%H%n%s' 5514313e
else
echo 'Commit 5514313e is not present in this checkout.'
fi
printf '%s\n' '--- matching local refs ---'
git show-ref | grep -E 'openwork-vendor-base|5514313e' || trueLength of output: 1086 🏁 Script executed: #!/bin/bash
set -euo pipefail
base='5514313e05d147e326f8e0a4383a072987d32385'
printf '%s\n' '--- authored range summary ---'
git diff --stat "$base"..HEAD
printf '%s\n' '--- authored files ---'
git diff --name-status "$base"..HEAD
printf '%s\n' '--- OW1 divergence record ---'
sed -n '1,360p' apps/chat/MODIFICATIONS.md
printf '%s\n' '--- focused file outlines ---'
for file in \
apps/chat/scripts/*console-register* \
apps/chat-server/src/console-session.ts \
apps/chat-server/src/theorem-mcp.ts \
packaging/workspace/check-two-doors.mjs \
scripts/audit-openwork-fork.sh
do
if [ -f "$file" ]; then
printf '\n--- %s ---\n' "$file"
ast-grep outline "$file" --view expanded || true
fi
doneLength of output: 27800 🏁 Script executed: #!/bin/bash
set -euo pipefail
for file in \
apps/chat-server/src/console-session.ts \
apps/chat-server/src/console-session.test.ts \
apps/chat-server/src/server.ts \
apps/chat-server/src/routes/core.ts \
apps/chat-server/src/theorem-mcp.ts \
apps/chat-server/src/theorem-mcp.test.ts \
apps/chat-server/src/openwork-runtime-config.ts \
apps/chat/scripts/console-register-lib.mjs \
apps/chat/scripts/check-console-register.mjs \
apps/chat/src/app/index.css \
packaging/workspace/Dockerfile \
packaging/workspace/entrypoint.sh \
packaging/workspace/check-two-doors.mjs \
scripts/audit-openwork-fork.sh
do
printf '\n--- %s ---\n' "$file"
cat -n "$file"
doneLength of output: 111938 🏁 Script executed: #!/bin/bash
set -euo pipefail
base='5514313e05d147e326f8e0a4383a072987d32385'
printf '%s\n' '--- console session verifier ---'
cat -n apps/chat-server/src/console-session.ts
printf '%s\n' '--- console session contract test ---'
cat -n apps/chat-server/src/console-session.test.ts
printf '%s\n' '--- server console-session references ---'
rg -n -C 14 'readConsoleSession|resolveConsoleSessionSecret|console session|consoleSession' \
apps/chat-server/src/server.ts apps/chat-server/src/routes/core.ts
printf '%s\n' '--- authored CSS changes near RGB token use ---'
git diff --unified=30 "$base"..HEAD -- apps/chat/src/app/index.css \
| grep -n -E -C 12 'dls-(accent|secondary)-rgb|ow-dot-ticker|dls-accent'
printf '%s\n' '--- RGB-token definitions and uses ---'
rg -n -C 2 -- '--dls-(accent|secondary)-rgb' apps/chat/src apps/console/srcLength of output: 51257 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- console actor and workspace-claim use ---'
rg -n -C 5 --glob '*.{ts,tsx}' \
'workspaceSlug|workspaceId|type:\s*"console"|actor\.(workspaceSlug|tenant)|actor\?\.workspaceSlug|actor\?\.tenant' \
apps/chat-server/src
printf '%s\n' '--- route authorization and workspace resolution ---'
rg -n -C 12 --glob '*.ts' \
'requireClient\(|requireHost\(|resolveWorkspace\(|ctx\.actor|auth ===|route\.auth|type Route' \
apps/chat-server/src/server.ts apps/chat-server/src/routes apps/chat-server/src/types.ts
printf '%s\n' '--- console-session client gate ---'
cat -n apps/chat/src/react-app/shell/console-session-gate.tsx
printf '%s\n' '--- authored package and audit wiring ---'
git diff --unified=12 5514313e05d147e326f8e0a4383a072987d32385..HEAD -- \
apps/chat/package.json apps/chat-server/package.json pnpm-lock.yamlLength of output: 201401
I found two issues.
I did not run tests or build the Docker image in the sandbox. ✅ Action performedReview finished.
|
…ositives All three are in vendored chat-server test files and all three are canaries for the diagnostic sanitizer, so the scanner is flagging the test data of a secret-redaction test suite. Each is the input to a not.toContain assertion, which is why none can be removed or altered: doing so would delete the security property the test holds. Nothing to revoke or rotate. The values are described rather than quoted. Quoting them is what a first pass did, and the scanner immediately flagged this file as a fourth occurrence: the documentation reproduced the thing it documented. The citation plus a description is enough for a reader, and adds no new occurrence. The check is the GitGuardian GitHub App rather than a ggshield CI step, so it reads no configuration from this repository and a .gitguardian.yaml would be inert; remediation is dashboard-side. Recorded because these will re-trip on every upstream cherry-pick touching these files.
7a72aba to
ac56ea9
Compare
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d73edf16a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| # The built chat register, served by the daemon's static handler. Built on the | ||
| # host or in a prior stage: this image does not carry the web app's toolchain. | ||
| COPY apps/chat/dist /srv/openwork/public |
There was a problem hiding this comment.
Build the chat bundle before copying it
The Railway build uses this Dockerfile directly from the repository, but apps/chat/dist is absent from the commit tree and no earlier stage builds @commonplace/chat. Consequently COPY apps/chat/dist ... fails before the workspace image can be produced. Add a frontend build stage or commit/provide the artifact within the Docker build context.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and it was fatal: apps/chat/dist is gitignored with zero tracked files, so the COPY had no source and the image could never build. Added a first stage that installs @commonplace/chat... and runs build:web, then COPY --from=web. It also copies apps/console/src/styles, which the token generator reads.
🤖 Addressed by Claude Code
| # --workspace, not --dir: the daemon's flag is a repeatable workspace root | ||
| # (apps/chat-server/src/config.ts). The token rides the environment rather than | ||
| # --host-token so it never appears in the container's process list. | ||
| setsid openwork-server \ |
There was a problem hiding this comment.
Invoke the built server through an available executable
When this entrypoint runs in the workspace image, openwork-server is not on PATH: the Dockerfile builds the workspace package but neither globally installs it nor adds a package node_modules/.bin directory to PATH, and the only globally installed binary is opencode. The container therefore exits with command not found; invoke the built CLI (or explicitly install/link its bin) instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. pnpm links workspace binaries into node_modules/.bin and nothing put that on PATH, so the entrypoint would have exited command not found. PATH now includes /srv/openwork/node_modules/.bin.
🤖 Addressed by Claude Code
| setsid openwork-server \ | ||
| --host 0.0.0.0 \ | ||
| --port "${OPENWORK_PORT}" \ | ||
| --workspace "${WORKSPACE_DIR}" & |
There was a problem hiding this comment.
Enable the managed OpenCode process in the workspace image
In the workspace-container deployment, no external OpenCode URL is supplied and this command does not set OPENWORK_MANAGE_OPENCODE=1. The new CLI starts the installed engine only when that variable is exactly 1 (apps/chat-server/src/cli.ts), so the daemon starts without an engine connection and chat sessions cannot run despite OpenCode being installed in the image.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. Verified at apps/chat-server/src/cli.ts:44: the managed engine starts only when the value is exactly 1. The image now sets OPENWORK_MANAGE_OPENCODE=1, so the daemon does not come up engineless with opencode installed but unused.
🤖 Addressed by Claude Code
|
|
||
| # The built chat register, served by the daemon's static handler. Built on the | ||
| # host or in a prior stage: this image does not carry the web app's toolchain. | ||
| COPY apps/chat/dist /srv/openwork/public |
There was a problem hiding this comment.
Configure the daemon to serve the copied web root
Even if the frontend artifact is supplied, copying it to /srv/openwork/public does not make it available: serveStaticUi is a no-op unless OPENWORK_WEB_ROOT is set, and neither this Dockerfile nor the entrypoint sets that variable. Requests for the chat app therefore fall through to the daemon's 404 response; configure the web root to this copied directory.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. serveStaticUi is gated on OPENWORK_WEB_ROOT, so the copied bundle was inert and / fell through to 404. Set to /srv/openwork/public, the directory the new build stage writes.
🤖 Addressed by Claude Code
|
|
||
| # The chat door. The daemon's own token store is seeded from the same value, | ||
| # so a client presenting it is an owner on both doors. | ||
| export OPENWORK_HOST_TOKEN="${WORKSPACE_TOKEN}" |
There was a problem hiding this comment.
Seed the client token from WORKSPACE_TOKEN
For standalone clients that connect with the documented shared workspace token, this only sets the host credential. Normal API routes authenticate bearer tokens against OPENWORK_TOKEN, which remains randomly generated, while the web client deliberately omits the host token for non-loopback servers. Thus WORKSPACE_TOKEN opens code-server but receives 401s from ordinary chat routes; export it as the client token as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. OPENWORK_TOKEN and OPENWORK_HOST_TOKEN are separate credentials in config.ts, and the web client withholds the host token from non-loopback servers, so WORKSPACE_TOKEN opened code-server and then got 401 from every chat route. The entrypoint now exports both from the one workspace token.
🤖 Addressed by Claude Code
| function injectBootstrap(html: string, token: string): string { | ||
| if (!shouldInjectBootstrapToken()) return html; | ||
|
|
||
| const clientToken = token.trim(); | ||
| if (!clientToken) return html; | ||
|
|
||
| const bootstrap = escapeScriptJson(JSON.stringify({ token: clientToken })); |
There was a problem hiding this comment.
Disable public bootstrap-token injection for console deployments
Once OPENWORK_WEB_ROOT is configured so the packaged UI is actually served, an unauthenticated GET / reaches this fallback and receives config.token embedded in the HTML because injection defaults to enabled. Anyone who can reach the workspace service can extract that collaborator bearer token and use client-authenticated file and session routes without a console session; require authentication before serving it or set OPENWORK_WEB_BOOTSTRAP_TOKEN=0 for the console deployment.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and it became reachable precisely because of the OPENWORK_WEB_ROOT fix in this same round. The image sets OPENWORK_WEB_BOOTSTRAP_TOKEN=0, so the static handler no longer embeds config.token in HTML served to an unauthenticated GET /.
🤖 Addressed by Claude Code
| function resolveSafeChildPath(root: string, child: string): string { | ||
| const rootResolved = resolve(root); | ||
| const candidate = resolve(rootResolved, child); | ||
| if (candidate === rootResolved) { | ||
| throw new ApiError(400, "invalid_path", "Path must point to a file"); | ||
| } | ||
| if (!candidate.startsWith(rootResolved + sep)) { | ||
| throw new ApiError(400, "invalid_path", "Path traversal is not allowed"); | ||
| } | ||
| return candidate; |
There was a problem hiding this comment.
Reject workspace paths that traverse symlinks
When a checkout contains a symlink, this helper validates only the lexical path and never resolves the symlink target. A collaborator can therefore request a path such as link/outside.txt, where link points outside the workspace, and the read, write, rename, or delete call sites in routes/files.ts operate on host files outside the authorized root; resolve and validate existing ancestors (and guard creation atomically) before filesystem access.
Useful? React with 👍 / 👎.
| ENV OPENWORK_PORT=8787 | ||
| ENV CODE_SERVER_PORT=8080 | ||
| RUN mkdir -p "${WORKSPACE_DIR}" | ||
| VOLUME ["/workspace"] |
There was a problem hiding this comment.
Persist OpenCode and daemon state on the workspace volume
Only /workspace is persisted, while OpenCode stores its session database under its data home (by default /root/.local/share/opencode) and the daemon stores runtime.sqlite, tokens, and configuration under /root/.config/openwork. A Railway redeploy therefore retains the checkout but discards chat transcripts, artifact/session metadata, token records, and runtime settings; place these state directories on the mounted volume or add dedicated persistent volumes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. opencode keeps its session database under its data home and the daemon keeps runtime.sqlite, tokens, and config under its config home, both defaulting to /root, so a redeploy preserved the checkout and discarded every transcript. XDG_DATA_HOME and XDG_CONFIG_HOME now point under /workspace/state, which is the mounted volume.
🤖 Addressed by Claude Code
| function signature(payload: string, secret: string): string { | ||
| return createHmac("sha256", secret) | ||
| .update("commonplace-active-workspace-v1\0") | ||
| .update(payload) | ||
| .digest("base64url"); |
There was a problem hiding this comment.
Do not give workspace containers the console signing secret
The workspace service is designed to execute user and agent code and exposes a code-server terminal, yet it receives the same symmetric COMMONPLACE_ACTIVE_WORKSPACE_SECRET used by the console to sign identity cookies. Any process or terminal user in one workspace can read that environment variable and generate valid HMAC cookies for arbitrary subjects, tenants, and workspace claims; containers should receive only an asymmetric verification key or a narrowly scoped credential that cannot mint console sessions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The most important finding here, and correct. The key is symmetric, so a container that runs agent code behind a published terminal could mint sessions for arbitrary subjects and tenants rather than verify its own. Removed from the service variables, and the entrypoint now exits 78 if it finds the variable, because a workspace that boots is one someone routes traffic to. The console authenticates at its own edge and reaches this service with WORKSPACE_TOKEN, already scoped to one workspace, so no second sign-in appears.
🤖 Addressed by Claude Code
|
Correction to my previous comment, and a fix for a mistake I made in it. That comment documented the three findings by quoting them verbatim, and I committed the same table to Fixed by amending Verified after the amend: back to 3 secrets, all in The three vendored findings are unchanged and remain verified false positives, for the reasons in my previous comment. They are canaries in the diagnostic sanitizer's own test suite, each an input to a The check will stay red until the three incidents are marked false-positive in workspace 907603. That is dashboard-side: this check is the GitGuardian GitHub App, not a 🤖 Addressed by Claude Code |
…, repair the workspace image Addresses review findings from CodeRabbit and Codex on PR #158. All were real. Console session escalation (CodeRabbit P1). consoleSessionActor built an owner Actor from a valid cookie and discarded claims.workspaceId, while the daemon serves several workspaces at once and handlers resolve :id against the whole configured set. A cookie signed for workspace A reached workspace B on the same daemon with owner rights. The actor now carries the signed workspace and assertConsoleWorkspace rejects a mismatch with 403, enforced once in the route dispatch so a route added later cannot forget it. Regression test added. Removed tokens still in use (CodeRabbit P1). OW3 deleted --dls-accent-rgb and --dls-secondary-rgb after finding three call sites. There were thirteen: the other ten were rgba(var(--dls-accent-rgb),0.14) inside Tailwind arbitrary values, which the original grep pattern did not match. Focus rings and tickers across eleven components silently resolved to nothing, because an undefined custom property is dropped rather than raised. All thirteen now use color-mix against the register, and check-token-references.mjs fails on any owned token used without being defined. Verified it catches the exact shipped bug. Workspace image (Codex P1s). It could not have run. apps/chat/dist is gitignored so the COPY had no source; a build stage now produces it. openwork-server was not on PATH. The engine needs OPENWORK_MANAGE_OPENCODE=1, the static handler needs OPENWORK_WEB_ROOT, and ordinary API routes authenticate against OPENWORK_TOKEN rather than the host token, so the shared credential opened the IDE and then got 401 from every chat route. opencode's session database and the daemon's runtime state defaulted under /root, so a redeploy kept the checkout and discarded every transcript; both now sit on the volume. OPENWORK_WEB_BOOTSTRAP_TOKEN=0 stops the static handler embedding a usable bearer token in HTML served to unauthenticated callers. Console signing key in the workspace container (Codex P1). The key is symmetric, and this container runs user and agent code behind a published terminal, so anything able to read the environment could mint sessions for any subject, tenant, or workspace rather than merely verify its own. It is removed from the service's variables and the entrypoint refuses to start if it finds it. The console authenticates at its own edge and reaches this service with WORKSPACE_TOKEN, which is already scoped to one workspace, so no sign-in is added.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Both reviewers found real defects. Everything below is fixed in Console session escalation (CodeRabbit P1). The worst of them, and mine. Removed tokens still in use (CodeRabbit P1). Worse than reported. OW3 deleted All thirteen now use The workspace image could not have run. Seven separate defects, all Codex, all confirmed against the code before fixing: The console signing key does not belong in a workspace container. The sharpest architectural point raised. It is symmetric, and that container runs agent code behind a published terminal, so anything able to read the environment could mint sessions for arbitrary subjects and tenants rather than merely verify its own. Removed from the service variables; the entrypoint now exits 78 if it finds the variable, because failing closed is the only safe behaviour when a workspace that boots is one someone routes traffic to. The console authenticates at its own edge and reaches the service with Not fixed, deliberately
Still openThe image has never been built. These fixes are reasoned from the code, not from a successful |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7402e8fba0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * Same-origin by construction: OW4 serves this register under the console's | ||
| * domain, which is what lets the browser attach the console's cookie at all. |
There was a problem hiding this comment.
Add the console-origin chat proxy
This assumes the register is reachable on the Console origin, but inspection of apps/console/src/app, its Next configuration, and repo-wide references to WORKSPACE_TOKEN and port 8787 finds no route or rewrite that forwards a Console path to this workspace service. Consequently the shipped register cannot be entered through the Console as OW4 describes, and the browser never sends the Console cookie to /session/console; add the authenticated reverse proxy and token-forwarding path before relying on same-origin construction.
Useful? React with 👍 / 👎.
| const requested = params.id; | ||
| if (!requested) return; | ||
| if (requested === actor.workspaceId) return; |
There was a problem hiding this comment.
Map console IDs to daemon workspace IDs
When Console-cookie authentication is enabled, actor.workspaceId is the identity service's workspace ID, while this deployment invokes the daemon with only --workspace <path>, causing buildWorkspaceInfos to generate a separate ws_<path-hash> ID. The UI obtains that generated ID from /workspaces and uses it in /workspace/:id requests, so this comparison rejects every such request with workspace_unauthorized; configure the daemon with the Console workspace ID or explicitly map the claim to the local workspace.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and this was a regression from my previous commit. The ids are different namespaces: buildWorkspaceInfos derives ws_<hash-of-path> while the console mints its own, so the comparison rejected every console request rather than only cross-workspace ones. Removed the comparison. The binding now keys off deployment shape: a single-workspace daemon has nowhere to escalate to, and a multi-workspace daemon refuses console auth and falls through to token auth.
🤖 Addressed by Claude Code
| const actor = await requireClient(request, config, tokens); | ||
| assertOpencodeProxyAllowed(actor, request.method, mount.restPath); | ||
| const workspace = await resolveWorkspace(config, mount.workspaceId); |
There was a problem hiding this comment.
Check workspace claims before proxying OpenCode
On a daemon configured with multiple workspaces and Console-cookie authentication, /workspace/<id>/opencode/... returns from this early proxy branch before the central assertConsoleWorkspace call. requireClient accepts the Console actor as an owner without checking mount.workspaceId, after which an attacker can substitute another configured workspace ID and read or mutate that workspace through OpenCode; validate the actor against mount.workspaceId before resolving the target.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. The early proxy branch returned before the central assertion, so the hole my previous commit claimed to close stayed open on that path. The replacement guard lives in consoleSessionActor, which this branch reaches through requireClient, so the proxy path is covered by construction rather than by a second call site someone has to remember.
🤖 Addressed by Claude Code
| const slug = SIMPLE_ICON_SLUGS[id] ?? id; | ||
| if (!SIMPLE_ICON_MISSES.has(slug) && /^[a-z0-9]+$/.test(slug)) { | ||
| candidates.push(`https://cdn.simpleicons.org/${slug}`); |
There was a problem hiding this comment.
Bundle provider icons instead of fetching them remotely
When the model or extension UI renders a provider with a recognized slug, the browser loads its icon from cdn.simpleicons.org; the fallback paths similarly send provider and configured service domains to Google's favicon endpoint. This creates automatic third-party network traffic and discloses the user's provider inventory despite OW1's zero-third-party-call acceptance criterion; serve the adopted icons locally or fall back to the existing monogram.
Useful? React with 👍 / 👎.
| "packages/openwork-types/**", | ||
| "packages/openwork-paths/**", | ||
| "packaging/workspace/**", |
There was a problem hiding this comment.
Watch openwork-ui changes for workspace rebuilds
The Docker web stage copies and bundles packages/openwork-ui, but this Railway watch list omits that package while listing the other OpenWork packages. A change to an exported UI component therefore does not trigger a workspace-service rebuild, leaving the deployed chat bundle on the previous implementation until some unrelated watched file changes; add packages/openwork-ui/** to the build inputs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct: the web stage bundles packages/openwork-ui but the watch list omitted it, so a component change would not have rebuilt the workspace service. Added to watchPatterns.
🤖 Addressed by Claude Code
| echo "workspace: cloning ${WORKSPACE_REPO_URL} into ${WORKSPACE_DIR}" | ||
| git clone --depth "${WORKSPACE_CLONE_DEPTH:-1}" "${WORKSPACE_REPO_URL}" "${WORKSPACE_DIR}" |
There was a problem hiding this comment.
Keep repository credentials out of logs and Git config
For a private HTTPS repository where WORKSPACE_REPO_URL contains a deploy token or userinfo, this prints the credential-bearing URL to container logs and git clone persists the same URL as remote.origin.url on the long-lived workspace volume. Anyone with log or code-server access can then recover the repository credential independently of WORKSPACE_TOKEN; pass credentials through a helper or SSH setup and configure a sanitized remote URL after cloning.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. The URL is now logged with userinfo stripped, and after cloning the stored remote is rewritten to the redacted form so the credential does not persist as remote.origin.url on a volume that code-server users can read.
🤖 Addressed by Claude Code
| wait -n | ||
| status=$? |
There was a problem hiding this comment.
Capture child failures before errexit terminates supervision
When either door exits nonzero, Bash's wait -n returns that child's nonzero status, so the active set -e terminates the entrypoint at this line before status=$? and shutdown run. The surviving code-server or chat process is consequently killed with the container rather than receiving the intended TERM and cleanup path, risking incomplete state writes during crashes; run the wait in an if/errexit-safe block before capturing its status.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. Under set -e, wait -n returning a nonzero child status terminated the script at that line, so shutdown never ran and the surviving door was killed by container teardown instead of receiving TERM. Wrapped in an if so the failure branch is errexit-safe and cleanup always runs.
🤖 Addressed by Claude Code
| addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => { | ||
| const body = await readJsonBody(ctx.request); | ||
| const reply = body.reply === "allow" ? "allow" : "deny"; | ||
| const result = ctx.approvals.respond(ctx.params.id, reply); |
There was a problem hiding this comment.
Distinguish approval IDs from workspace route IDs
When a Console-authenticated user responds to a permission prompt, the central assertConsoleWorkspace hook runs after host authentication and interprets this route's params.id as a workspace ID. An approval request ID never equals the cookie's workspace ID, so every POST /approvals/:id is rejected with workspace_unauthorized before this handler runs, leaving the tool call stuck; restrict the workspace assertion to routes whose :id actually denotes a workspace.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and the worst of my regressions: :id on this route is an approval id, so the assertion rejected every permission response with workspace_unauthorized and left tool calls stuck. That is the round-trip OW2 exists to prove. The central assertion is gone entirely rather than narrowed, since the namespace problem above made it wrong even on genuine workspace routes.
🤖 Addressed by Claude Code
| const healthResponse = () => jsonResponse({ | ||
| ok: true, | ||
| version: serverVersion, | ||
| opencodeVersion, | ||
| uptimeMs: Date.now() - config.startedAt, | ||
| }); |
There was a problem hiding this comment.
Fail health checks when the managed engine exits
When the managed OpenCode child exits after startup, openwork-server remains alive and this endpoint continues returning ok: true without checking the registered engine's isAlive state. The Docker and Railway health checks call only /health, so the container is never restarted even though every session operation has lost its engine; include engine liveness in readiness or terminate the daemon when its managed child exits.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. Fixed at the lifecycle rather than the endpoint: cli.ts now watches the managed engine and exits 70 when it dies, so the container healthcheck and the Railway ON_FAILURE policy restart the pair. Only armed for a managed engine, since an externally supplied opencode is somebody else`s lifecycle.
🤖 Addressed by Claude Code
| child.stdout?.on("data", (chunk) => { | ||
| output += chunk.toString(); |
There was a problem hiding this comment.
Stop accumulating managed-engine output after startup
After the listening URL is found, resolving the startup promise does not remove either stream listener, so every later OpenCode log line is appended permanently to output; the stdout handler also repeatedly splits the entire ever-growing string. An active long-running workspace therefore incurs unbounded memory growth and increasing per-log CPU until the daemon can be OOM-killed; detach the startup parsers once ready while continuing to drain or forward the streams.
Useful? React with 👍 / 👎.
Three of this round's findings are regressions from the previous commit. The binding I added compared claims.workspaceId against the route's :id, and that was wrong in two independent ways. The ids are different namespaces. This daemon derives ws_<hash-of-path> in buildWorkspaceInfos while the console mints its own, so the comparison rejected every console-authenticated request rather than only cross-workspace ones. And :id is not always a workspace. POST /approvals/:id carries an approval id, so the check rejected every permission response with workspace_unauthorized, breaking the exact round-trip OW2 depends on. It also missed the case it was written for: the opencode proxy branch returns before the central assertion ran, so the escalation stayed open on that path. Replaced with a guard on deployment shape. What bounds a console session is the container: OW5 runs one workspace per container and the console reaches it with a workspace-scoped token, so a single-workspace daemon has nowhere to escalate to. A daemon serving several has no such boundary and no reliable mapping between the id namespaces, so console auth is refused there and falls through to token auth, which fails closed. This removes the comparison entirely and covers the proxy branch, since it also authenticates through requireClient. Also from this round: Entrypoint, wait -n under set -e. A nonzero child status terminated the script at the wait itself, so shutdown never ran and the surviving door was killed by container teardown instead of TERM, risking a half-written state file. Entrypoint, clone credentials. A private HTTPS WORKSPACE_REPO_URL can carry a deploy token in its userinfo. It was printed to container logs and persisted by git as remote.origin.url on a volume any code-server user can read, so the credential outlived WORKSPACE_TOKEN. Logged redacted, and the stored remote is rewritten without userinfo after cloning. Railway watch patterns omitted packages/openwork-ui, which the web stage bundles, so a component change would not have rebuilt the workspace service. Engine watchdog. When the managed engine exited, the daemon stayed up and /health kept answering ok, so the container healthcheck saw a healthy service with no engine and the restart policy never fired. The daemon now exits 70.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Seven of the ten fixed in Three of these were regressions from my own previous commit, and one of them was serious. The workspace binding I added compared The replacement drops the comparison and guards on deployment shape instead. What actually bounds a console session is the container: OW5 runs one workspace per container and the console reaches it with a workspace-scoped token, so a single-workspace daemon has nowhere to escalate to. A daemon serving several has no such boundary and no reliable mapping between the id namespaces, so console auth is refused there and falls through to token auth, which fails closed. The proxy branch is covered by construction, since it authenticates through the same Also fixed: the Not fixed
Standing caveatEverything here is reasoned from source. The container has still never been built, and this round found that my previous round's fix was broken in three ways, which is the argument for treating a real |
The record named cdn.simpleicons.org only. Review found the fallback path also reaches Google's favicon endpoint, which discloses configured service domains rather than just the provider list, so the OW1 network-trace qualification is broader than written. Same disposition: the Noun Project icon set lands locally and removes both.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2276d292f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ! wait -n; then | ||
| status=$? |
There was a problem hiding this comment.
Preserve the failing door's exit status
Fresh evidence after the prior wait-loop fix: the ! operator inverts wait -n before $? is captured, so when a child exits with status 70 this assignment records 0. The entrypoint consequently exits successfully after any door failure, and the ON_FAILURE policy in packaging/workspace/railway.toml will not restart the workspace; capture the status without ! while keeping the command in an errexit-safe conditional.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and this was a bug in my own previous fix. Verified empirically rather than by reading: if ! cmd; then status=$?; fi captures 0 because $? is the negation's status, while cmd || status=$? captures 70. So the entrypoint would have exited successfully after any door failure and ON_FAILURE would never have restarted the service. Switched to the || form, which is equally errexit-safe and preserves the status.
🤖 Addressed by Claude Code
| ENV XDG_DATA_HOME=/workspace/state/data | ||
| ENV XDG_CONFIG_HOME=/workspace/state/config |
There was a problem hiding this comment.
Persist the daemon data directory
Fresh evidence after the persistence fix: auditLogPath() in apps/chat-server/src/audit.ts uses openworkServerDataDir(), whose default is $HOME/.openwork/openwork-server and does not consult either XDG variable set here. Because only /workspace is mounted, every redeploy still discards the audit trail returned by /workspace/:id/audit; set OPENWORK_DATA_DIR to a directory under /workspace/state as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and a real gap in my previous persistence fix: openworkServerDataDir() does not consult XDG at all, so the variables I set did nothing for the audit trail. It defaults to $HOME/.openwork and reads OPENWORK_DATA_DIR, which now points under /workspace/state and is created in the image.
🤖 Addressed by Claude Code
| !storedConnectionIsStaleDesktopRuntime && hasUsableConnection(normalizedBaseUrl, resolvedToken) | ||
| ? "stored-settings" | ||
| : hasUsableConnection(sameOriginBaseUrl, resolvedToken) | ||
| ? "same-origin" |
There was a problem hiding this comment.
Allow cookie-authenticated same-origin connections
Even after the Console proxy is added, the intended cookie-only OW4 flow has a same-origin URL but no bearer token because the session cookie is HttpOnly. Requiring resolvedToken here therefore classifies that connection as empty; use-workspace-route-state.ts then clears the client at its !resolvedToken check and never sends an API request on which the browser could attach the valid cookie. Treat a same-origin web connection as usable without a bearer token and let cookie authentication authorize its requests.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and it is the client-side half of the same gap as the missing proxy: the cookie-only flow was unreachable regardless of routing. A same-origin web deployment is now usable without a bearer token. Worth noting for review: no credentials change was needed, because fetch defaults to same-origin and A9's entry decision is a route on the console origin. Nothing is granted by the looser classification either, since the daemon still authenticates every request and the cookie only travels to the origin that set it.
🤖 Addressed by Claude Code
| ## Quick start | ||
|
|
||
| ```bash | ||
| npm install -g openwork-server |
There was a problem hiding this comment.
Install the renamed server package in the quick start
The publish script preserves the package name from package.json, which is @commonplace/chat-server, but this command installs the unscoped openwork-server package instead, so anyone following the published README either gets an unrelated upstream package or an install failure rather than this fork. The source command below has the same stale filter name; update both examples to select @commonplace/chat-server while retaining the openwork-server binary name.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. Both the published install and the source filter named the pre-rename package, so following the README fetched an unrelated upstream package rather than this fork. Updated to @commonplace/chat-server; the openwork-server binary name is unchanged, since that is what the entrypoint and PATH rely on.
🤖 Addressed by Claude Code
…ions through The errexit fix from the previous commit introduced a status-swallowing bug. `if ! wait -n; then status=$?; fi` captures the negation's own status, which is 0, not the child's. The entrypoint would have exited successfully after any door failure and Railway's ON_FAILURE policy would never have restarted the service. Verified both forms empirically: the if-bang form yields 0 and `wait -n || status=$?` yields 70. Using the || form, which is equally errexit-safe and keeps the status. Cookie-only same-origin connections were unreachable. hasUsableConnection requires a bearer token, but the console session is an HttpOnly cookie the page cannot read, so the OW4 flow classified as "empty" and use-workspace-route-state cleared the client before any request went out. The browser never got the chance to attach a cookie it already holds. A same-origin web deployment is now usable without a token. Nothing is granted by this: the cookie only travels to the origin that set it, fetch defaults to credentials: same-origin so it rides without further change, and the daemon still authenticates every request. Audit trail persistence. openworkServerDataDir() does not consult XDG at all: it defaults to $HOME/.openwork and reads OPENWORK_DATA_DIR, so the previous commit's XDG variables left the log behind /workspace/:id/audit on the container layer, discarded on every redeploy. Pointed at /workspace/state. README installed the pre-rename package name, so anyone following it got an unrelated upstream package rather than this fork.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4fe8cfe110
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const directory = workspace ? resolveOpencodeDirectory(workspace) : null; | ||
| if (directory && !headers.has("x-opencode-directory")) { | ||
| headers.set("x-opencode-directory", buildOpencodeDirectoryHeader(directory)); | ||
| } |
There was a problem hiding this comment.
Override caller-supplied OpenCode directory headers
When an authenticated client supplies x-opencode-directory, this condition preserves it instead of applying the directory belonging to mount.workspaceId. OpenCode uses this header as its working directory, so a collaborator can proxy requests with a path such as /etc or /workspace/state and make the engine operate outside the authorized workspace root. Always overwrite the header with the resolved workspace directory, or reject values that are not canonically contained within it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and the sharpest finding this round. The resolved workspace directory now wins unless the caller value is canonically inside it, which closes the escape while keeping the legitimate case of naming a subdirectory. isWithinDirectory is unit-tested directly, including traversal, percent-encoded input, and the sibling case where /workspace/repo-other must not match /workspace/repo. Named honestly in the code: it resolves .. but not symlinks, so a link inside the workspace pointing out still escapes. That is the same limitation as the file-routes finding and is tracked separately rather than half-solved here.
🤖 Addressed by Claude Code
| git clone --depth "${WORKSPACE_CLONE_DEPTH:-1}" "${WORKSPACE_REPO_URL}" "${WORKSPACE_DIR}" | ||
| git -C "${WORKSPACE_DIR}" remote set-url origin "${redacted_url}" |
There was a problem hiding this comment.
Preserve usable authentication for private Git remotes
When WORKSPACE_REPO_URL contains the only credential for a private HTTPS repository, rewriting origin to the redacted URL makes every later git fetch, pull, or push from the persistent workspace unauthenticated. The environment variable is not automatically consumed by Git, and this image configures no credential helper, so the checkout can only clone once and cannot synchronize afterward. Keep the remote sanitized, but configure an ephemeral helper or another non-persisted authentication mechanism for subsequent Git operations.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and a regression from my own previous commit: sanitizing the remote kept the credential off the volume but left the checkout able to clone once and never fetch again, since git does not read WORKSPACE_REPO_URL and the image configures no helper. Added a per-repository credential helper that reads the variable at call time, so fetch, pull, and push work while nothing is persisted to the volume. The remote stays sanitized.
🤖 Addressed by Claude Code
| const headers = new Headers(input.request.headers); | ||
| headers.delete("authorization"); | ||
| headers.delete("x-openwork-host-token"); | ||
| headers.delete("x-openwork-client-id"); | ||
| headers.delete("host"); | ||
| headers.delete("origin"); |
There was a problem hiding this comment.
Strip hop-by-hop headers before proxying requests
When a client or the Console reverse proxy sends a chunked request to an /opencode route, the copied headers still contain transfer-encoding: chunked even though the body is subsequently buffered and passed to fetch. Node's fetch rejects a caller-supplied transfer-encoding header, so these proxied POST requests fail with a 500 before reaching OpenCode. Remove transfer-encoding and the other hop-by-hop transport headers when rebuilding the upstream request.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. The body is buffered and handed to fetch, which rejects a caller-supplied transfer-encoding, so a chunked request would have 500ed before reaching the engine. Stripping transfer-encoding, connection, keep-alive, upgrade, te, trailer, and the two proxy-auth headers when rebuilding the upstream request.
🤖 Addressed by Claude Code
| server.on("error", (error: NodeJS.ErrnoException) => { | ||
| if (error.code === "EADDRINUSE" && !retriedFreePort) { | ||
| retriedFreePort = true; | ||
| server.listen(0, hostname); | ||
| return; |
There was a problem hiding this comment.
Fail instead of silently rebinding the server port
When the configured port is occupied, this silently starts the daemon on an OS-selected port even though the managed engine's OPENWORK_SERVER_URL, status responses, the workspace health check, and external clients continue using config.port. The process therefore remains alive on an undiscoverable port while callbacks and readiness checks target the unrelated listener that won the original port. Propagate EADDRINUSE so the supervisor can retry, or update every dependent URL and advertised port after rebinding.
Useful? React with 👍 / 👎.
| if (!ctx.actor?.tokenHash || session.actorTokenHash !== ctx.actor.tokenHash) { | ||
| throw new ApiError(403, "forbidden", "File session does not belong to this token"); |
There was a problem hiding this comment.
Support console actors when validating file sessions
In the cookie-authenticated Console flow, the actor has a subject and workspace identity but no tokenHash. File-session creation therefore stores an empty actorTokenHash, and this check rejects every subsequent renew, catalog, read, write, or close request with 403 because ctx.actor.tokenHash remains absent. Bind sessions to a stable identity available for both actor types, such as a keyed tuple of console subject, tenant, and workspace, instead of requiring a bearer-token hash.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and a direct consequence of the console actor I added: no bearer token means actorTokenHash stored empty, so the session refused its own owner on every renew, catalog, read, write, and close. Fixed at the actor rather than in the vendored file routes: the console actor now carries a derived hash over the subject, tenant, and workspace the console signed, which is stable across a session and distinct between subjects, with a console: prefix so it cannot collide with a real token hash.
🤖 Addressed by Claude Code
…tory escape File sessions were broken for every console user. routes/files.ts binds a session to actor.tokenHash and refuses any later request whose hash differs. A console actor holds no bearer token, so it stored an empty hash and was then refused by its own session on renew, catalog, read, write, and close. The actor now carries a derived hash over the subject, tenant, and workspace the console signed: stable across a session, distinct between subjects, and prefixed so it cannot collide with a real token hash. No change to the vendored file routes. Caller-supplied x-opencode-directory was a workspace escape. The engine uses that header as its working directory and upstream preserved the caller's value whenever it was present, so a header of /etc pointed the engine outside the authorized root. The resolved workspace directory now wins unless the caller's value is canonically inside it, which keeps the legitimate case of naming a subdirectory. isWithinDirectory is tested directly: root, descendants, traversal, percent-encoded values, undecodable input, and the sibling case where /workspace/repo-other must not match /workspace/repo. Hop-by-hop headers are stripped before the upstream request. The body is buffered and handed to fetch, which rejects a caller-supplied transfer-encoding, so a chunked upload would have failed with 500 before reaching the engine. Private repository sync was broken by my own previous commit. Sanitizing remote.origin.url kept the credential off the volume but left the checkout able to clone once and never fetch again, since git does not read WORKSPACE_REPO_URL and the image configures no helper. A per-repository credential helper now reads the variable at call time, so fetch, pull, and push work while nothing is written to the volume.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Four of five fixed in Two were caused by my own OW4 work. The console actor holds no bearer token, so file sessions stored an empty The directory escape was the sharpest finding. The engine treats Declined
Standing position, unchangedFive review rounds. Findings in my own fixes in four of them, two more this round. Everything verifiable statically passes: 32 tests, both packages typecheck, A real |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30e4da0270
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // request metadata-only results so renderer settings panes do not receive | ||
| // every raw secret value up front. Reload semantics are driven from the UI | ||
| // after a write; this surface is user-scoped, not workspace-scoped, so no audit. | ||
| addRoute(routes, "GET", "/env", "host-token", async (ctx) => { |
There was a problem hiding this comment.
Allow Console owners to use environment routes
In the intended cookie-only Console flow, the browser has no X-OpenWork-Host-Token, but every /env route uses host-token authentication, whose requireHostToken path never accepts the Console actor. The Environment settings UI calls these routes to list and save provider credentials, so authenticated Console users receive 401 responses and cannot configure the model credentials needed by the workspace; use the Console-aware host mode or have the authenticated proxy provide a scoped host credential.
Useful? React with 👍 / 👎.
| import { useBootState, useBootOverlayVisible } from "./boot-state"; | ||
| import { OwDotTicker } from "./dot-ticker"; | ||
|
|
||
| const RELEASES_URL = "https://github.com/different-ai/openwork/releases"; |
There was a problem hiding this comment.
Point boot recovery at a CommonPlace release
Whenever the web register reports a boot error, this recovery link directs the user to install a release from the donor different-ai/openwork project. That is not the canonical CommonPlace desktop host, so a user following the recovery instruction can install an unrelated application instead of repairing CommonPlace; link to the CommonPlace support/release destination or omit the download action.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and an OW1 miss rather than a nitpick: the severing pass removed donor hosts from the bundle but left this one, so a user hitting a boot error was sent to install a different application. Now opt-in via VITE_OPENWORK_RELEASES_URL like the other destinations, and the action is not rendered when unset rather than emitting an empty href.
🤖 Addressed by Claude Code
| return jsonResponse({ | ||
| runtime, | ||
| runtimeKeys: runtimeConfigKeys(runtime), | ||
| effectiveRuntime, |
There was a problem hiding this comment.
Redact injected credentials from runtime diagnostics
When THEOREM_API_KEY is configured, buildOpenworkRuntimeConfigObject injects it into effectiveRuntime.mcp.theorem.headers.Authorization, and this client-authenticated route returns that object verbatim both here and under sources.injected.config. Because client authentication admits even viewer tokens and this handler performs no scope check, any viewer can retrieve the workspace's Theorem bearer key, along with secret-bearing runtime/global MCP configuration; restrict this diagnostic to an owner or recursively redact credentials before serializing it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and this one is squarely my OW2 injection: the Theorem MCP header is built from THEOREM_API_KEY, and client auth admits a viewer, so any viewer could read the workspace bearer key. The payload now goes through the daemon's own sanitizeDiagnosticValue rather than a bespoke filter, so it follows the same rules as every other diagnostic surface. Tested with a control assertion that the key is present before redaction, so the test cannot pass by the key never having been there.
🤖 Addressed by Claude Code
| if (!isValidEnvKey(key)) { | ||
| throw new ApiError(400, "invalid_env_key", "Invalid environment variable name"); | ||
| } | ||
| const removed = await env.delete(key).catch(rethrowEnvStoreReadError); |
There was a problem hiding this comment.
Revoke engine auth when deleting a provider key
When the deleted environment key supplies a managed provider credential, this removes only the env-store record and never deletes the credential previously delivered to OpenCode's /auth/:provider endpoint. syncManagedProviderAuth also treats a missing stored credential as a skip, and OpenCode auth persists outside the child process, so the provider continues accepting requests with the supposedly removed key immediately and after restarts; map the removed key to managed providers and clear their engine auth as part of this operation.
Useful? React with 👍 / 👎.
| resolve(packageRoot, "dist/bin/openwork-server"), | ||
| resolve(outputRoot, "dist/bin/openwork-server") |
There was a problem hiding this comment.
Publish binaries for each supported platform
The publish flow runs build:bin, which compiles only for the publisher's current OS and architecture, and then places that single native executable at the platform-neutral dist/bin/openwork-server path. Anyone installing the documented global package on a different platform receives that same binary and fails with an incompatible executable rather than starting the server; publish platform-specific packages or include all targets and select the matching binary in the launcher.
Useful? React with 👍 / 👎.
|
|
||
| return { | ||
| type: "console", | ||
| scope: "owner", |
There was a problem hiding this comment.
Preserve workspace roles in Console actors
The Console issues the active-workspace cookie after checking only that the subject is a member, even though its workspace contract carries a role and permissions, but this conversion hardcodes every such member to owner. A read-only or otherwise restricted workspace member can therefore use cookie authentication for owner-only operations such as token management, approvals, workspace deletion, and runtime upgrades; include an authorized role or scope in the signed claim and derive the daemon scope from it instead of elevating every member.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and mine. The console checks membership only and the signed claim carries no role, so this daemon cannot distinguish a read-only member from an admin, and I minted owner for all of them. Now collaborator, which is what the chat register actually needs. Owner-only operations are deliberately unreachable by console session until the console signs a role and the scope derives from it. That is the same missing piece as the console-origin route in A9, so it is one console-side change rather than two.
🤖 Addressed by Claude Code
|
|
||
| export function HTMLPreview({ className, ...props }: HTMLPreviewProps) { | ||
| if (props.type === "text") { | ||
| return <iframe srcDoc={props.content} title={props.title} className={cn("h-full w-full border-0", className)} sandbox="allow-scripts allow-same-origin" />; |
There was a problem hiding this comment.
Isolate HTML previews from the application origin
When a user opens an agent- or collaborator-controlled HTML artifact, this srcDoc frame grants both scripts and same-origin access. The document consequently runs with the application's origin and can access parent, mutate the chat UI, read origin storage, or issue authenticated same-origin requests; keep the frame in an opaque sandbox by removing allow-same-origin, disable scripts, or serve previews from a separate origin.
Useful? React with 👍 / 👎.
…orem key Two security defects, both created by my own OW2 and OW4 work. Console sessions minted owner for every member. The console issues the cookie after checking membership only: its workspace contract carries a role but the signed claim does not, so this daemon cannot tell a read-only member from an admin. Owner reached token management, approvals, workspace deletion, and runtime upgrades. The actor is now collaborator, which is what the chat register needs. Owner-only operations are deliberately unreachable by console session until the console signs a role and the scope derives from it, which is the same missing piece as the console-origin route in A9. The runtime-config diagnostic leaked that key. OW2 injects the Theorem MCP with an Authorization header built from THEOREM_API_KEY, and the route is client auth, which admits a viewer token, so any viewer could read the workspace's bearer key along with whatever the runtime and global MCP configs carry. The payload now passes through the daemon's own diagnostic sanitizer rather than a bespoke filter, so it follows the same rules as every other diagnostic surface. Tested with a control assertion first, so the test cannot pass by the key never having been present. Also an OW1 miss: the boot-error overlay linked to the donor's releases page, so a CommonPlace user hitting a boot failure was sent to install a different application. Opt-in through VITE_OPENWORK_RELEASES_URL like the other destinations, and the action is not rendered when unset rather than emitting an empty href.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Three of seven fixed in Two were security defects I created. Console sessions minted The third was an OW1 miss: the boot-error overlay linked to the donor's releases page. Declined, and a pattern worth naming
The last is the one I would prioritize if this were my call: an agent-controlled HTML artifact running on the application origin can read storage and issue authenticated same-origin requests. It is vendored and pre-existing, and the fix is a product decision about what artifact previews are allowed to do, not a review-round edit. The That is now three findings converging on one gap: the console must sign a role, and the console-origin route must exist. Until both land, OW4 is a mechanism with no caller, and I would rather say that than keep patching around it. Standing positionSix rounds. Defects in my own work in five of them, two more here, both security. Everything statically verifiable passes: 34 tests, both packages typecheck, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c12823326
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const consoleActor = consoleSessionActor(request, config); | ||
| if (consoleActor) return consoleActor; |
There was a problem hiding this comment.
Require owner scope before accepting Console host access
When Console-cookie authentication is enabled, consoleSessionActor now returns a collaborator-scoped actor, but this branch accepts it for every host-only route without checking that scope. Fresh evidence after the earlier role-elevation finding is that changing the actor from owner to collaborator did not close the escalation: any Console member can still list or mint owner tokens, delete workspaces, or trigger runtime upgrades through auth: "host" routes. Require consoleActor.scope === "owner" before returning it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and the most important finding in the round: my previous commit did not close this. I changed consoleSessionActor from owner to collaborator and never re-read the branch that consumes the scope, so requireHost kept returning the actor unconditionally and every console member still reached token minting, workspace deletion, and runtime upgrades. The comment on that branch still asserted the owner premise I had just removed.
Now gated on scope === "owner", written against the scope rather than the actor type so a console that later signs an owner role needs no change here. Console actors are collaborator today, so this rejects them all.
Worth naming why it was invisible: every test I had written asserted the shape of the actor and none asserted what the authorization path did with it. consoleActorSatisfiesHost is now extracted and exported so the decision is testable directly, covering collaborator, viewer, absent, and owner.
I also swept the remaining actor.scope consumers rather than waiting for the next round. assertOpencodeProxyAllowed and requireClientScope are rank-based and handle collaborator correctly. requireHost was the only broken one.
🤖 Addressed by Claude Code
| const path = join(workspace.path, file.path); | ||
| await ensureDir(dirname(path)); | ||
| await writeFile(path, file.content, "utf8"); |
There was a problem hiding this comment.
Contain imported files across symlinked directories
When the checkout contains a symlink such as .opencode/plugins -> /outside, an approved workspace import with path .opencode/plugins/tool.js passes the lexical portable-path checks and this writeFile follows the link, overwriting a file outside the authorized workspace. This is fresh evidence beyond the previously reported file-session helper: the independent workspace-import path performs its own unchecked joined write, so fixing routes/files.ts does not protect imports. Resolve and validate existing ancestors before applying the import.
Useful? React with 👍 / 👎.
| const nextContent = buildFrontmatter(normalizeCommandFrontmatter(parsed.data)) + parsed.body.replace(/^\n?/, "\n"); | ||
| await writeFile(filePath, nextContent, "utf8"); |
There was a problem hiding this comment.
Keep command listing read-only
When OPENWORK_READONLY=1 and a legacy command contains model: null, GET /workspace/:id/commands reaches listCommandsInDir, which calls this repair function and rewrites the command file. The normal bootstrap repair is deliberately skipped in read-only mode, but the listing path bypasses that guard, allowing even a viewer token to mutate the checkout through a GET without approval or audit; move the repair to a writable migration path or suppress the write while listing.
Useful? React with 👍 / 👎.
| username: opencodeUsername, | ||
| password: opencodePassword, |
There was a problem hiding this comment.
Redact upstream passwords from viewer workspace listings
When a remote OpenCode workspace is configured with opencodePassword, any viewer token can call GET /workspaces and receive that password together with the upstream baseUrl. The viewer can then authenticate directly to the remote OpenCode server and bypass this daemon's read-only scope checks entirely; omit upstream credentials from the client serializer or expose them only through an owner-only management route.
Useful? React with 👍 / 👎.
| addRoute(routes, "POST", "/experimental/google-workspace/smoke-test", "client", async () => { | ||
| return jsonResponse(await googleWorkspaceRunScopeSmokeTest(config)); |
There was a problem hiding this comment.
Restrict the mutating Google smoke test
When a viewer token calls this client-authenticated route, no scope or read-only check runs before googleWorkspaceRunScopeSmokeTest creates a real Drive file and Gmail draft in the connected account. This lets a nominally read-only viewer repeatedly mutate and clutter an owner's Google Workspace; require collaborator or owner scope and honor ensureWritable before running the smoke test.
Useful? React with 👍 / 👎.
| }); | ||
| }); | ||
| if (!callbackServer) throw new Error("Could not start Google Workspace OAuth callback server."); | ||
| const redirectUri = `http://127.0.0.1:${port}/`; |
There was a problem hiding this comment.
Use a reachable OAuth callback for remote deployments
When chat-server runs in the Railway workspace container, this redirect sends the user's browser to a random 127.0.0.1 port on the user's own machine, while the callback listener was opened on loopback inside the remote container. The authorization callback therefore never reaches the pending flow and Google Workspace connection always expires in the deployed web register; use a stable Console-origin callback or another externally reachable broker callback for remote deployments.
Useful? React with 👍 / 👎.
The elevation fix in the last commit changed consoleSessionActor from owner to collaborator and stopped there. requireHost returned the console actor without checking scope, so host-only routes kept accepting it and the escalation never closed: any console member still reached token minting, workspace deletion, and runtime upgrades. The comment on that branch still asserted the owner assumption I had just removed. The bearer path directly below it has always made the check explicitly. The branch is now gated on owner scope, written against the scope rather than the actor type so a console that later signs an owner role needs no change here. Console actors are collaborator today, so this rejects them all. Why it was invisible: every test asserted the shape of the actor, none asserted what the authorization path did with it. consoleActorSatisfiesHost is extracted and exported so the decision itself is testable, and the new tests cover collaborator, viewer, absent, and owner. Swept the other consumers of actor.scope rather than waiting for the next review round to find them. assertOpencodeProxyAllowed and requireClientScope are both rank-based and handle collaborator correctly: viewer stays read-only, owner-required routes still reject. requireHost was the only broken consumer.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
One fixed in My previous commit did not close the escalation it claimed to close. I changed Why no test caught it: every test I had written asserted the shape of the actor, none asserted what the authorization path did with it. That is a category of test I was missing entirely, not a case I forgot. I also swept the remaining DeclinedWorkspace import following symlinks, read-only bypass in command listing, All five are vendored upstream at The The OAuth callback is the one finding here genuinely caused by this PR's deployment shape: loopback redirect works on desktop and cannot work in the Railway container OW5 introduces. It belongs with the console-origin route, since both need the same thing, a stable Console-origin callback. Where this leaves the PRSeven rounds. Defects in my own work in six of them, including this one, where a security fix from the previous round turned out to change nothing. I am not going to keep going round for round. Every remaining open finding is either vendored code I should not be editing piecemeal, or blocked on console-side work that does not exist yet: the console must sign a role, and the console-origin route must exist. Four separate findings now converge on that one gap. Local signal is green (39 tests, both packages typecheck, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b221e7efa0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # --workspace, not --dir: the daemon's flag is a repeatable workspace root | ||
| # (apps/chat-server/src/config.ts). The token rides the environment rather than | ||
| # --host-token so it never appears in the container's process list. | ||
| setsid openwork-server \ |
There was a problem hiding this comment.
Invoke the built chat-server entrypoint
In the workspace image, this command is not resolvable: the Dockerfile runs the package's ordinary build, which creates apps/chat-server/dist/cli.js, but it neither installs @commonplace/chat-server as a dependency nor adds apps/chat-server/bin to PATH (only the root node_modules/.bin is included). Consequently setsid exits with status 127 and the supervisor shuts down code-server on every container start; invoke the built CLI with Bun or explicitly link/install the package binary.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed, with one correction to the premise. apps/chat-server/bin/openwork-server.mjs is tracked in git and pnpm does create a root node_modules/.bin/openwork-server shim, so the finding as stated is not quite right for a full workspace install. But the image runs a filtered install, and I cannot confirm the shim exists there without building the image, which is precisely the class of assumption that has failed repeatedly on this PR.
Rather than argue the resolution semantics, the invocation is now bun /srv/openwork/apps/chat-server/dist/cli.js: what pnpm build emits and what the package's own start script runs, by a path that cannot fail to resolve. The PATH entry stays for anything else that wants it.
🤖 Addressed by Claude Code
| headers.set("Content-Type", contentTypeForPath(relativePath)); | ||
| headers.set("Content-Length", String(info.size)); | ||
| headers.set("Content-Disposition", `inline; filename="${basename(relativePath)}"`); |
There was a problem hiding this comment.
Serve workspace HTML outside the authenticated origin
In a cookie-authenticated Console deployment, a collaborator-controlled .html file requested through this route is returned as text/html with Content-Disposition: inline from the application origin. If a signed-in user is navigated to that URL, the file's scripts execute with the Console origin and its HttpOnly session cookie, allowing authenticated same-origin API calls; this remains independently exploitable even if the artifact preview iframe is sandboxed. Force HTML to download, use an inert MIME type, or serve raw workspace content from a separate origin.
Useful? React with 👍 / 👎.
| if (scope === "viewer" && m !== "GET" && m !== "HEAD") { | ||
| throw new ApiError(403, "forbidden", "Viewer tokens are read-only"); |
There was a problem hiding this comment.
Enforce read-only mode before proxying mutations
When OPENWORK_READONLY=1, an owner or collaborator can still send POST, PATCH, or DELETE requests through either /opencode/* proxy branch because this gate checks only whether the actor is a viewer. Those branches return before any routed handler can call ensureWritable, and the managed OpenCode engine is not itself configured read-only, so requests such as session creation or permission/config mutations still succeed despite the daemon advertising read-only mode; reject non-read methods at the proxy boundary whenever config.readOnly is set.
Useful? React with 👍 / 👎.
| requireClientScope(ctx, "collaborator"); | ||
| const workspace = await resolveWorkspace(config, ctx.params.id); | ||
| const body = await readJsonBody(ctx.request); | ||
| const scope = normalizeOpencodeScope(typeof body.scope === "string" ? body.scope : null); |
There was a problem hiding this comment.
Gate global OpenCode config writes with host auth
When approval mode is auto, any collaborator token can submit scope: "global" here and overwrite the host user's global OpenCode configuration rather than the selected workspace's project config. That configuration is shared by every workspace and can add plugins, MCPs, or other executable behavior, so routine remote access can persistently affect unrelated workspaces; global command access is already host-gated elsewhere, and the global branch here should likewise require owner/host authentication.
Useful? React with 👍 / 👎.
| if (scope === "global") { | ||
| const dir = join(homedir(), ".config", "opencode", "commands"); | ||
| return listCommandsInDir(dir, "global"); |
There was a problem hiding this comment.
Resolve global resources through the OpenCode config directory
When XDG_CONFIG_HOME or OPENCODE_CONFIG_DIR is configured, global commands are read from the wrong directory because this hardcodes $HOME/.config/opencode; the workspace Docker image sets XDG_CONFIG_HOME=/workspace/state/config, so commands used by OpenCode under /workspace/state/config/opencode/commands never appear in this API. The global skill and plugin inventory repeats the same hardcoded path, so all three listings can be empty or stale; derive these directories from the shared globalOpencodeConfigDir resolver.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not fixed, but worth flagging that this one is caused by my change rather than pre-existing: the hardcoded $HOME/.config/opencode was harmless until the workspace image set XDG_CONFIG_HOME=/workspace/state/config to keep engine state on the volume. So global commands, skills, and plugins now resolve to a directory the engine does not use.
Leaving it because the correct fix is to route all three listings through the shared globalOpencodeConfigDir resolver in vendored code I have not fully read, and this PR has a consistent record of my edits to such code needing a follow-up fix of their own. It should land with the other vendored path-resolution work.
🤖 Addressed by Claude Code
| } catch { | ||
| return { schemaVersion: 1, updatedAt: Date.now(), tokens: [] }; |
There was a problem hiding this comment.
Preserve a corrupt token store instead of treating it as empty
If tokens.json is truncated by an interrupted write or contains invalid JSON, this catch silently returns an empty store. Every previously issued scoped token then starts receiving 401 responses without any diagnostic, and the next token creation or revocation overwrites the corrupt file with the empty in-memory state, permanently discarding the records and the evidence needed for recovery; surface a store-read error and use an atomic replacement write rather than interpreting corruption as no tokens.
Useful? React with 👍 / 👎.
| const content = await readFile(path, "utf8"); | ||
| const rawLines = content.trim().split("\n").filter(Boolean); | ||
| if (!rawLines.length) return []; | ||
| const slice = rawLines.slice(-Math.max(1, limit)); |
There was a problem hiding this comment.
Read only the requested tail of the audit log
On a long-lived workspace, every audit request reads and splits the entire append-only JSONL file before applying the requested limit, so even ?limit=1 eventually incurs memory and latency proportional to the workspace's complete history. readLastAudit has the same behavior and is called by the ordinary workspace-config read path, allowing a sufficiently large persisted audit trail to slow routine UI loads or exhaust the daemon; seek or stream backward from the end of the file and parse only the required entries.
Useful? React with 👍 / 👎.
The PATH entry I added two rounds ago assumed pnpm creates a bin shim in the root node_modules/.bin for a workspace project. That holds for the full workspace install on a developer machine, and the image runs a filtered install, where I cannot confirm it without building the image. A missing shim exits 127 and the supervisor takes code-server down with it on every start. Invoking dist/cli.js directly removes the resolution dependency. It is what pnpm build emits and what the package's own start script runs, so it is the same entrypoint by a path that cannot fail to resolve.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b440b72c6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| addRoute(routes, "POST", "/experimental/extensions/call", "client", async (ctx) => { | ||
| if (ctx.actor?.scope === "viewer") { | ||
| throw new ApiError(403, "forbidden", "Viewer tokens cannot call extension actions"); | ||
| } | ||
| const body = await readJsonBody(ctx.request); | ||
| return jsonResponse(await callExperimentalExtensionAction(config, env, body, await getConnectSnapshot(config, { ...connectSnapshotBaseOptions, ...connectSnapshotOptionsFromBody(body) }))); |
There was a problem hiding this comment.
Enforce read-only mode for extension actions
When OPENWORK_READONLY=1, a collaborator can still invoke mutating actions through this route because it rejects viewers but never calls ensureWritable. Actions registered here include drive_update_file, calendar_create_event, gmail_create_draft, and chat_send_message, so a nominally read-only deployment can modify or send data in the owner's connected Google account; apply the read-only gate before dispatching any side-effecting extension action.
Useful? React with 👍 / 👎.
| status=0 | ||
| wait -n || status=$? | ||
| echo "workspace: a door exited with ${status}; stopping the other" >&2 | ||
| shutdown | ||
| exit "${status}" |
There was a problem hiding this comment.
Treat a clean door exit as a failure
When either code-server or chat-server unexpectedly exits with status 0, wait -n leaves status at 0 and this exits the container successfully after shutting down the other door. The deployment uses restartPolicyType = "ON_FAILURE" in packaging/workspace/railway.toml, so that clean child exit leaves the entire workspace offline without a restart; translate an unexpected zero status to a nonzero supervisor status.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. A door exiting cleanly is still a stopped workspace, and leaving status at 0 meant the supervisor reported success while ON_FAILURE left the container down with neither door running. This line is only reached because something that should have run forever stopped, so a nonzero status is the only correct outcome. Now translated to 70.
For the record, this is the fourth defect in this same supervisor block: --dir versus --workspace, the errexit termination, the ! swallowing the status, and now the clean exit. Each was real and each was only visible after the previous fix landed. It is the clearest illustration on this PR of why the container needs to actually run.
🤖 Addressed by Claude Code
| const includeDirs = ctx.url.searchParams.get("includeDirs") !== "false"; | ||
| const limit = parseCatalogLimit(ctx.url.searchParams.get("limit")); | ||
|
|
||
| const entries = await listWorkspaceCatalogEntries(workspace.path); |
There was a problem hiding this comment.
Stop catalog traversal at the requested limit
For a large checkout containing directories such as .git or node_modules, this call recursively reads and stores every filesystem entry before the prefix, cursor, and requested limit are applied below. Consequently even the default 2,000-item snapshot can scan millions of entries, block the server, and consume memory proportional to the whole checkout; prune by prefix/cursor and stop or stream once the requested page is available.
Useful? React with 👍 / 👎.
| let bytes: Buffer; | ||
| try { | ||
| bytes = Buffer.from(body.dataBase64, "base64"); | ||
| } catch { | ||
| throw new ApiError(400, "invalid_payload", "dataBase64 is invalid"); |
There was a problem hiding this comment.
Reject malformed base64 before writing files
When dataBase64 contains malformed input, Node's Buffer.from(value, "base64") decodes permissively instead of throwing, so this catch is ineffective: values such as %%% become an empty buffer and other invalid strings become arbitrary bytes that are then written over the requested file. Validate the base64 representation strictly before decoding; the file-session batch decoder at line 826 needs the same treatment.
Useful? React with 👍 / 👎.
| const env = { | ||
| ...process.env, | ||
| ...options.env, | ||
| OPENCODE_SERVER_USERNAME: username, | ||
| OPENCODE_SERVER_PASSWORD: password, | ||
| }; |
There was a problem hiding this comment.
Build a restricted environment for the managed engine
When managed OpenCode is enabled, this spreads the daemon's complete environment into the engine despite the provider-auth bridge relying on a fixed allowlist. Command and shell tools launched by OpenCode inherit values such as OPENWORK_HOST_TOKEN, repository URL credentials, and unrelated service keys, so a collaborator who can run an agent command can read those values and potentially elevate to owner or access external services; construct an explicit non-secret environment containing only the runtime variables the engine requires.
Useful? React with 👍 / 👎.
| await mkdir(runtimeStorageDir(config), { recursive: true }); | ||
| const tmp = `${path}.${randomUUID()}.tmp`; | ||
| await writeFile(tmp, content, "utf8"); | ||
| await rename(tmp, path); |
There was a problem hiding this comment.
Store the generated runtime config with private permissions
When THEOREM_API_KEY or secret-bearing MCP/provider configuration is present, content contains those credentials, but this temporary file is created with the process umask rather than an explicit private mode and then becomes the final config through rename. Under a typical 0022 umask the resulting file is 0644, allowing other local users to read the bearer keys; create it with mode 0600 and enforce that mode on an unchanged existing file as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and independently confirmed: I reported this same exposure from my own audit of the OW2 surface last round. The file carries the Theorem Authorization header and any provider credentials, and under a typical 0022 umask it landed 0644. In the workspace image it also sits on the volume the IDE door serves, so a terminal user could read a tenant-scoped key.
Created 0600 now, with the mode set at creation rather than after rename so the secret is never briefly world-readable, and re-asserted when content is unchanged since the writer short-circuits there and an older file would keep its bits forever.
Tested empirically, which was worth doing: my first version of the test asserted nothing, because writeFile's mode option only applies on creation, so the permissive-file setup left the file at 0600 and never exercised the tighten path. It uses chmod now.
Note this is a mitigation, not the fix. The underlying problem is that THEOREM_API_KEY is tenant-scoped and lives in a workspace that runs agent code; file mode does not help when code-server runs as the same uid. The real fix is a workspace-scoped credential.
🤖 Addressed by Claude Code
| const url = await new Promise<string>((resolve, reject) => { | ||
| const timeout = setTimeout(() => reject(new Error(`Timeout waiting for OpenCode server after ${options.timeoutMs ?? 15000}ms`)), options.timeoutMs ?? 15000); |
There was a problem hiding this comment.
Terminate the child when managed-engine startup fails
If OpenCode remains running but never emits the expected listening line, or emits a line whose URL cannot be parsed, this startup promise rejects while the spawned child is left alive and unreaped. An embedded caller that catches the error and retries can therefore accumulate engine processes and occupied ports, and the CLI may also remain held open by the child pipes; kill and await the child on every timeout, parse failure, or startup exception.
Useful? React with 👍 / 👎.
| const { path: runtimeConfigPath } = await writeOpenworkRuntimeConfigFile(config, workspace.id); | ||
| keepOpenworkRuntimeConfigFileFresh(config, workspace.id); |
There was a problem hiding this comment.
Unsubscribe the embedded runtime-config listener on stop
Each managed embedded-server start registers a global runtime-config write listener here, but the returned unsubscribe function is discarded and handle.stop() never removes it. Repeated start/stop cycles therefore retain every old server config and keep rewriting files for stopped instances; global runtime writes can also make stale listeners rebuild the active server's derived file using their captured workspace ID. Save the unsubscribe callback and invoke it during normal stop and startup-error cleanup.
Useful? React with 👍 / 👎.
| const form = await ctx.request.formData(); | ||
| const file = form.get("file"); |
There was a problem hiding this comment.
Enforce the inbox limit before buffering the upload
When an authenticated collaborator sends a multipart body larger than OPENWORK_INBOX_MAX_BYTES, request.formData() buffers and parses the entire payload before file.size is checked. The advertised limit therefore protects only the eventual file write, not daemon memory; with the 250 MB default, one request can consume substantially more than that and exhaust a small workspace container. Reject oversized Content-Length values early and use a streaming, byte-limited multipart parser so chunked requests are bounded too.
Useful? React with 👍 / 👎.
| function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) { | ||
| const target = new URL(baseUrl); | ||
| const trimmedPath = path.replace(/^\/opencode/, ""); | ||
| target.pathname = trimmedPath.startsWith("/") ? trimmedPath : `/${trimmedPath}`; | ||
| target.search = search; |
There was a problem hiding this comment.
Preserve path prefixes in proxied OpenCode base URLs
When a remote OpenCode server is exposed below a reverse-proxy prefix, such as https://host/engine, assigning target.pathname here discards /engine; proxying /opencode/session consequently calls https://host/session instead of https://host/engine/session. Other client URL helpers preserve configured base paths, so these otherwise valid remote deployments fail only through the proxy routes; append the normalized proxy path to the configured base pathname.
Useful? React with 👍 / 👎.
…0600 A door exiting with status 0 left the supervisor status at 0, so the container exited successfully and Railway's ON_FAILURE policy left the workspace offline with neither door running. This line is only reached because something that should have run forever stopped, so the only correct status here is nonzero. That is the fourth defect in this supervisor block, each one real and each one exposed only after the previous fix. The generated engine config is now created 0600. It carries OW2's Theorem Authorization header and whatever credentials the runtime config holds, and it was written with the process umask, so under a typical 0022 it landed 0644 and every local user could read the bearer key. In the workspace image that file sits on the volume the IDE door serves. The mode is set at creation rather than after rename so the secret is never briefly world-readable, and it is re-asserted when the content is unchanged, since a file from an older build would otherwise keep permissive bits forever. This is the same exposure I reported from my own audit of the OW2 surface last round, independently found in review. Tested empirically. The first version of the test asserted nothing, because writeFile's mode option applies only on creation, so the permissive-file setup left the file at 0600 and the tighten path was never exercised. It uses chmod now.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fac0c083e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
…leaks Nine findings from the review of fac0c08, all in vendored route and inventory code that admits viewer-scoped client tokens. Credentials reaching viewers: - The diagnostic sanitizer matched keys against a fixed set of exact strings assembled from the keys this fork writes, which is the wrong population. GITHUB_TOKEN, accessToken, refreshToken and clientSecret all mean "credential" and none matched. Keys are now tokenized into segments, and headers/environment/env records redact every value regardless of what the upstream server named its variables. - GET /workspace/:id/mcp returned each MCP's raw config, headers and environment included. The export path next door already redacts exactly those fields, so the cheaper route leaked what the careful one protected. Both now share redactMcpConfig. - GET /workspace/:id/opencode-config returned raw config text, and ?scope=global reads the host-wide file whose provider credentials belong to every workspace on the box. The write side already required collaborator; the read now does too. Writes and resources without a gate: - google-workspace/disconnect revokes an OAuth token and rewrites the account vault but never called ensureWritable, so OPENWORK_READONLY=1 could still lose the connected account. - POST /workspace/:id/claude-plugins resolved a GitHub repository tree and downloaded every skill, command and agent file before the collaborator check. Authorization now precedes resolution. - POST /dev/log is unauthenticated by design and so must be bounded by design; it now reads the body through a running byte limit rather than buffering first, and caps entry count. Correctness: - A failed initial prompt left the just-created session behind with no id in the error response, so every retry added another empty session. - listMcpFromRuntimeSnapshot emitted a global entry and a runtime entry under the same name; consumers taking the first match inspected the definition that is not live. - Host-token-only remote creation returned 201 and persisted a workspace with no credential. Rejected rather than retained: the host token maps to an owner-scope actor, so storing it per-workspace would trade a visible failure for a privilege upgrade at rest. One review suggestion is deliberately not taken. Deriving the remote workspace id from host and id via workspaceIdForOpenwork would break remote addressing: rem_<remoteWorkspaceId> is a reversible encoding and four call sites recover the upstream id by slicing the prefix, so a one-way hash would make every remote call target an id the upstream host has never heard of. The collision is real, so it is handled where it does damage: registering a colliding id from a different host now 409s instead of silently deleting the first host's record. Baseline before this change was 603 pass / 29 fail; after, the same 29 pre-existing failures remain and nine new tests pass. The two new sanitizer cases were negative-tested against the previous implementation and fail there.
aac3487 to
e7c4726
Compare
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e7c47262b3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The runtime config file above only covers workspaces[0]. Push every | ||
| // workspace's runtime-DB MCPs into the engine so they aren't invisible | ||
| // until a manual reload. Best-effort. | ||
| if (managedOpencode) { | ||
| void syncAllWorkspacesRuntimeMcpToEngine(config); |
There was a problem hiding this comment.
Apply runtime configuration for every managed workspace
When the repeatable --workspace option configures multiple local workspaces, the managed engine's OPENCODE_CONFIG is built and refreshed only for the selected first local workspace. This startup repair synchronizes only MCP entries for the others, so their runtime providers, plugins, disabled-provider settings, default agent, and external-directory permissions are silently ignored even though their settings writes succeed; materialize directory-scoped configuration for each workspace or run separate managed engines.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real, and reported rather than repaired. OPENCODE_CONFIG is one file read by one engine process, so the fix is either a managed engine per workspace or directory-scoped project config, and both are engine surgery that SPEC-COMMONPLACE-OPENWORK-FORK-1.0 OW6 gates behind the seam audit that has not run. What is fixed is "silently": startup now logs which local workspaces are configuration-inert and which categories are ignored for them, so an operator sees it instead of inferring it from behavior. Leaving this thread open as a live OW6 input.
🤖 Addressed by Claude Code
There was a problem hiding this comment.
Not fixed, but the reason it was deferred was wrong and that is now on the record as spec amendment A16.
This was parked behind OW6 on the reading that both remedies you named were engine surgery. They are not, and the evidence is in this repository: mcp.ts already inspects opencodeConfigPath(workspaceRoot) as a config.project layer alongside the global one, and the runtime map merges over both. So "materialize directory-scoped configuration for each workspace" is configuration through a layer the engine already reads, not a second transport and not a second client-construction site. It sits inside the A5 stay-on-the-opencode-head decision rather than gated by it.
Leaving this thread open on purpose: the correct classification is recorded, the fix is not written. What exists today is still only the startup warning naming which workspaces are configuration-inert, which is a smaller thing than the fix and should not be mistaken for it.
🤖 Recorded by Claude Code
…actions Nine findings from the review of e7c4726. Arbitrary host file read (P1). resolveSafeChildPath was lexical: resolve() never touches the filesystem, so a symlink inside a checkout pointing at /etc passed the child-path check and stat() and createReadStream() then followed it. A viewer could read any file the daemon could. Containment now resolves the real target, walking to the nearest existing ancestor so a write to a not-yet-created file under a symlinked directory is caught too. There were two copies of the lexical check, so the rule moved to workspace-paths.ts and both callers bind to it; a containment rule that exists twice is one that gets fixed once. Credentials reaching viewers. GET /workspace/:id/config returned the merged project and runtime config, credentials included; it is redacted for viewers rather than gated, because the shell reads it for every scope and a 403 would blank the workspace. GET /workspace/:id/export with ?sensitive=include turned off the exclusion sanitizer for any client scope, so that mode now requires collaborator. Writes without a gate. google-workspace/active-account rewrites the account vault and never called ensureWritable, the same omission the disconnect route had. Resource exhaustion. read-batch held every base64 string before serializing, so 64 files under the 5 MB per-file limit was ~427 MB in memory; a total batch ceiling now bounds the response and over-limit files are reported rather than dropped. File sessions evicted whichever session expired first, globally, so one viewer holding maximum-TTL sessions could push out collaborators' live ones; capacity pressure now falls on the actor causing it, and a store full of other actors' sessions returns 429. Donor-bound UI. buildFeedbackUrl called new URL("") whenever VITE_OPENWORK_FEEDBACK_URL was unset, which is the documented default after OW1 severed the donor endpoint, so every feedback click threw behind a button that still looked live. It returns null now and the action is hidden. Report issue pointed at github.com/different-ai/openwork, so CommonPlace bug reports reached maintainers who cannot act on them; it now defaults to this repository. The Discord invite was the donor's too, which the review did not flag but is the same OW1 violation, so it is opt-in and hidden when unset. All three destinations live in one module now. One finding is reported rather than repaired. OPENCODE_CONFIG is a single file read by a single engine process, so only the booted workspace's providers, plugins, disabled providers, default agent, and permissions reach the engine while the others' settings writes succeed and do nothing. Both remedies -- a managed engine per workspace, or directory-scoped project config -- are engine surgery that OW6 gates behind the seam audit. Startup now names the configuration-inert workspaces instead of leaving an operator to infer it from behavior. Verification: both packages typecheck, chat builds, audit 36/36. Nine new tests. The symlink cases were negative-tested against the lexical check and fail there. Two reload-watcher timing tests fail on this machine with these changes stashed, so they are not from this diff.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb7f3dcf2c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Hard fork of
different-ai/openworkvendored as the CommonPlace chat register,per SPEC-COMMONPLACE-OPENWORK-FORK-1.0. OW1, OW3, OW4, OW5, and OW6 are
complete. OW2 is partial and the gap is named below. OW7 is unblocked and not
started.
Reading this diff
1071 files, but 838 of them are
apps/chatand are vendored upstream code at2f2dde65796428109a665f3b733843fe3896b933. The review surface is much smaller:apps/chat/scripts/*console-register*apps/chat/src/app/index.cssapps/chat-server/src/console-session.tsapps/chat-server/src/theorem-mcp.tspackaging/workspace/scripts/audit-openwork-fork.shapps/chat/MODIFICATIONS.mdEverything else under
apps/chatandpackages/openwork-*is upstream asvendored, with CommonPlace edits marked by the deliverable that owns them.
What each deliverable did
OW1, vendor and sever. Telemetry, the Den control plane, the hosted model
catalog, and marketplace entries are removed. Bundle scan is clean of donor
hosts and of PostHog. MIT notices preserved,
UPSTREAM.mdpins the exact SHA.No import resolves into
/ee.OW3, token and shader binding. The fork's theme is generated from the
console's registers rather than restated, because the fork is a separate Vite
app and cannot import the console's CSS at runtime. Two traps are encoded in
the generator: the console's bare selector is dark and the fork's
:rootislight, so a direct copy would invert every colour; and a
var()head naming atoken the fork does not emit resolves to nothing, which is invisible in review,
so it is a hard error. Both semantic layers are bound, not just the shadcn
slots, because upstream's own
--dls-*layer is what most components read.Geist shipped as a font file that no CSS rule ever named. Dropped. The three
faces the tokens declare now ship and nothing else.
Shader mounts went from 8 to 1. The activity orbs each held a WebGL context,
one per in-flight message, against a per-document limit near 16.
OW4, auth and serving.
chat-serververifies the console's signed HttpOnlycp_active_workspacecookie, so an authenticated console user reaches theregister with no second sign-in and the browser holds no token. Verify half
only: this daemon never mints a session. The wire contract is pinned against a
frozen fixture and against a live import of the console's own encoder, because
the two halves live in different apps and nothing else would fail if they
diverged.
Den view surfaces are deleted, including the per-organization brand accent that
overwrote
--dls-accentat runtime, which is the second design authority thespec forbids.
OW5, workspace container. One image, two doors, one checkout, one token.
The registers see the same filesystem because it is the same filesystem, so
there is no sync step and no reconciler that can be wrong.
OW6, seam audit. Decision: stay on the opencode head. Reversal conditions
recorded.
Gaps, stated plainly
OW2's live proof is incomplete. The configuration contract is done and the
engine accepts the generated config. The MCP handshake, the permission
round-trip, and todos/events are not proven: every app-scoped route on opencode
1.17.11 deadlocks, verified against a 150 second timeout. Five causes were ruled
out by direct experiment, including stock opencode with no config of ours, which
deadlocks identically and is the decisive control. Amendment A12 carries the
full list so the next attempt does not repeat it.
The OW5 image has never been built. No Docker daemon was available. The
structural claims are gated statically by
packaging/workspace/check-two-doors.mjs,and the frozen-lockfile check that the build depends on now runs in the audit,
but
docker buildremains the acceptance step.A live network trace has not been run. The OW1 acceptance evidence is a
static bundle scan.
cdn.simpleicons.orgis still fetched for providerfavicons and closes with the Noun Project icon swap.
The Den client library survives. Only the view layer is deleted. Provider
auth and MCP connections still read
den.ts, so its symbols remain in thebundle. That unwind belongs with whatever replaces provider auth.
Verification
scripts/audit-openwork-fork.sh)apps/chat-serverpnpm build:websucceedsopenworklabs.com, zero PostHog, zero DEN flagsOne bug worth calling out
The OW3 font change was committed without
pnpm-lock.yaml. The OW5 Dockerfileinstalls with
--frozen-lockfile, so the image would have failed at that layerwith
ERR_PNPM_OUTDATED_LOCKFILE. Verified in both directions and fixed in3d73edf1. Docker was unavailable here, so the build could not have caught itand every other gate stayed green while the deploy was broken. The audit now
runs the frozen-lockfile check itself.
🤖 Generated with Claude Code