Skip to content

feat(saas): deliver platform_inbound_secret via /registry/register (RFC #2312, PR-F) - #2319

Merged
HongmingWang-Rabbit merged 4 commits into
stagingfrom
auto/issue-2312-pr-f-saas-secret-delivery
Apr 29, 2026
Merged

HongmingWang-Rabbit merged 4 commits into
stagingfrom
auto/issue-2312-pr-f-saas-secret-delivery

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

Summary

Closes the SaaS-side gap that PR-A acknowledged but didn't fix. Without this PR, even after A+B+C land, SaaS chat upload still 401s. With this PR, SaaS chat upload actually starts working.

Stacks: PR-A #2313 → PR-B #2314 → PR-C #2315this PR.

Why this is needed

PR-A skips writing `/configs/.platform_inbound_secret` in SaaS mode (no Docker volume to write into). The plaintext lives in the DB column, but the workspace's `/configs/.platform_inbound_secret` is empty, so the workspace's auth gate (PR-B) fails-closed → 401 on every platform forward (PR-C).

What this does

`/registry/register` response now includes `platform_inbound_secret` alongside the existing `auth_token`. The runtime extracts it and persists to `/configs/.platform_inbound_secret` at mode 0600. Idempotent — Docker-mode workspaces also receive it and overwrite the value the provisioner already wrote (same value until rotation).

Why on every register, not just first-register

  • SaaS containers can be restarted (deploys, drains, EBS detach/re-attach) — `/configs` is rebuilt empty on each fresh start
  • The auth_token is "issue once" because re-issuing rotates and invalidates the previous one. The inbound secret has no rotation flow yet (Design rotation flow for platform_inbound_secret (workspace-side cache invalidation) #2318) so re-sending the same value is harmless
  • Eliminates the bootstrap window where a restarted SaaS workspace has no inbound secret on disk

Changes

File What
`workspace-server/internal/handlers/registry.go` Register handler reads `workspaces.platform_inbound_secret` via `wsauth.ReadPlatformInboundSecret` and includes it in response. Legacy workspaces (NULL column) get successful registration with field omitted.
`workspace-server/internal/handlers/registry_test.go` 2 new tests: secret-present-in-DB → secret-in-response (alongside auth_token); NULL column → field-omitted, registration still 200
`workspace/platform_inbound_auth.py` `save_inbound_secret(secret)` — atomic tmp+rename write, mode 0600 from `os.open(O_CREAT, 0o600)`, resets in-process cache so next `get_inbound_secret()` returns the freshly-written value (rotation-safe when it lands)
`workspace/main.py` Register-response handler extracts `platform_inbound_secret` alongside `auth_token` and persists via `save_inbound_secret`
`workspace/tests/test_platform_inbound_auth.py` 6 new tests: writes file, mode 0600, overwrite-existing, cache invalidation after save, empty-input no-op, parent-dir creation for fresh installs

Test results

  • `go test ./internal/handlers/ ./internal/wsauth/` — all green
  • `pytest workspace/tests/` — 1272 passed (was 1266 before this PR; 6 new save_inbound_secret tests)

Test plan

  • Unit tests pass (Go + Python)
  • Build (`go build ./...`) green
  • After A+B+C+F merge + cascade re-publishes runtime + template images: run E2E against fresh staging tenant
  • Confirm SaaS workspace's `/configs/.platform_inbound_secret` is populated post-register (`docker exec ... cat /configs/.platform_inbound_secret`)
  • Confirm chat upload via canvas → 200 (not 401) on staging tenant

Refs

🤖 Generated with Claude Code

#2312, PR-F)

Closes the SaaS-side gap that PR-A acknowledged but didn't fix: SaaS
workspaces have no persistent /configs volume, so the platform_inbound_secret
that PR-A's provisioner wrote at workspace creation never reaches the
runtime. Without this, even after the entire RFC #2312 stack lands,
SaaS chat upload would 401 (workspace fails-closed when /configs/.platform_inbound_secret
is missing).

Solution: return the secret in the /registry/register response body
on every register call. The runtime extracts it and persists to
/configs/.platform_inbound_secret at mode 0600. Idempotent — Docker-
mode workspaces also receive it and overwrite the value the provisioner
already wrote (same value until rotation).

Why on every register, not just first-register:
  * SaaS containers can be restarted (deploys, drains, EBS detach/
    re-attach) — /configs is rebuilt empty on each fresh start.
  * The auth_token is "issue once" because re-issuing rotates and
    invalidates the previous one. The inbound secret has no rotation
    flow yet (#2318) so re-sending the same value is harmless.
  * Eliminates the bootstrap window where a restarted SaaS workspace
    has no inbound secret on disk and would 401 every platform call.

Changes:
  * workspace-server/internal/handlers/registry.go — Register handler
    reads workspaces.platform_inbound_secret via wsauth.ReadPlatformInboundSecret
    and includes it in the response body. Legacy workspaces (NULL
    column) get a successful registration with the field omitted.
  * workspace-server/internal/handlers/registry_test.go — two new tests:
      - TestRegister_ReturnsPlatformInboundSecret_RFC2312_PRF: secret
        present in DB → secret in response, alongside auth_token.
      - TestRegister_NoInboundSecret_OmitsField: NULL column → field
        omitted, registration still 200.
  * workspace/platform_inbound_auth.py — adds save_inbound_secret(secret).
    Atomic write via tmp + os.replace, mode 0600 from os.open(O_CREAT,
    0o600) so a concurrent reader never sees 0644-default. Resets the
    in-process cache after write so the next get_inbound_secret() returns
    the freshly-written value (rotation-safe when it lands).
  * workspace/main.py — register-response handler extracts
    platform_inbound_secret alongside auth_token and persists via
    save_inbound_secret. Mirrors the existing save_token pattern.
  * workspace/tests/test_platform_inbound_auth.py — 6 new tests for
    save_inbound_secret: writes file, mode 0600, overwrite-existing,
    cache invalidation after save, empty-input no-op, parent-dir creation
    for fresh installs.

Test results:
  * go test ./internal/handlers/ ./internal/wsauth/ — all green
  * pytest workspace/tests/ — 1272 passed (was 1266 before this PR)

Refs #2312 (parent RFC), #2308 (chat upload 503 incident).
Stacks: PR-A #2313 → PR-B #2314 → PR-C #2315 → this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors PR-C's Upload migration: replaces the docker-cp tar-stream
extraction with a streaming HTTP GET to the workspace's own
/internal/file/read endpoint. Closes the SaaS gap for downloads —
without this PR, GET /workspaces/:id/chat/download still returns 503
on Railway-hosted SaaS even after A+B+C+F land.

Stacks: PR-A #2313 → PR-B #2314 → PR-C #2315 → PR-F #2319 → this PR.

Why a single broad /internal/file/read instead of /internal/chat/download:

  Today's chat_files.go::Download already accepts paths under any of the
  four allowed roots {/configs, /workspace, /home, /plugins} — it's not
  strictly chat. Future PRs (template export, etc.) will reuse this
  endpoint via the same forward pattern; reusing avoids three near-
  identical handlers (one per domain) with duplicated path-safety logic.

Path safety is duplicated on platform + workspace sides — defence in
depth via two parallel checks, not "trust the workspace."

Changes:
  * workspace/internal_file_read.py — Starlette handler. Validates path
    (must be absolute, under allowed roots, no traversal, canonicalises
    cleanly). lstat (not stat) so a symlink at the path doesn't redirect
    the read. Streams via FileResponse (no buffering). Mirrors Go's
    contentDispositionAttachment for Content-Disposition header.
  * workspace/main.py — registers GET /internal/file/read alongside the
    POST /internal/chat/uploads/ingest from PR-B.
  * scripts/build_runtime_package.py — adds internal_file_read to
    TOP_LEVEL_MODULES so the publish-runtime cascade rewrites its
    imports correctly. Also includes the PR-B additions
    (internal_chat_uploads, platform_inbound_auth) since this branch
    was rooted before PR-B's drift-gate fix; merge-clean alphabetic
    additions.
  * workspace-server/internal/handlers/chat_files.go — Download
    rewritten as streaming HTTP GET forward. Resolves workspace URL +
    platform_inbound_secret (same shape as Upload), builds GET request
    with path query param, propagates response headers (Content-Type /
    Content-Length / Content-Disposition) + body. Drops archive/tar
    + mime imports (no longer needed). Drops Docker-exec branch entirely
    — Download is now uniform across self-hosted Docker and SaaS EC2.
  * workspace-server/internal/handlers/chat_files_test.go — replaces
    TestChatDownload_DockerUnavailable (stale post-rewrite) with 4
    new tests:
      - TestChatDownload_WorkspaceNotInDB → 404 on missing row
      - TestChatDownload_NoInboundSecret → 503 on NULL column
        (with RFC #2312 detail in body)
      - TestChatDownload_ForwardsToWorkspace_HappyPath → forward shape
        (auth header, GET method, /internal/file/read path) + headers
        propagated + body byte-for-byte
      - TestChatDownload_404FromWorkspacePropagated → 404 from
        workspace propagates (NOT remapped to 500)
    Existing TestChatDownload_InvalidPath path-safety tests preserved.
  * workspace/tests/test_internal_file_read.py — 21 tests covering
    _validate_path matrix (absolute, allowed roots, traversal, double-
    slash, exact-match-on-root), 401 on missing/wrong/no-secret-file
    bearer, 400 on missing path/outside-root/traversal, 404 on missing
    file, happy-path streaming with correct Content-Type +
    Content-Disposition, special-char escaping in Content-Disposition,
    symlink-redirect-rejection (lstat-not-stat protection).

Test results:
  * go test ./internal/handlers/ ./internal/wsauth/ — green
  * pytest workspace/tests/ — 1292 passed (was 1272 before PR-D)

Refs #2312 (parent RFC), #2308 (chat upload+download 503 incident).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor Author

Coverage audit before approval. ~85% branch coverage; security-critical paths covered, gaps are error fallthroughs. Consistent with the hold on PR-A/B/C.

Production code changes (3 places):

  1. registry.go::Register — adds ReadPlatformInboundSecret call and conditionally adds the field to response
  2. workspace/main.py register-response handler — calls save_inbound_secret (in pragma: no cover, fine)
  3. platform_inbound_auth.py::save_inbound_secret — new function

Tests added: 8 (2 Go + 6 Python)

Covered:

  • Go: secret-on-file → response includes it (TestRegister_ReturnsPlatformInboundSecret_RFC2312_PRF)
  • Go: NULL secret → response omits field (TestRegister_NoInboundSecret_OmitsField)
  • Python: empty input → noop, write file, 0600 mode, overwrite, cache invalidation, parent-dir creation

Gaps:

  • Go: non-ErrNoInboundSecret DB error in ReadPlatformInboundSecret → handler logs + skips field. Untested. Worth adding because the log line is the only signal ops gets that something's wrong.
  • Python: os.open / os.replace raises OSError → cleanup branch (os.unlink of tmp). Untested. Disk-full / read-only-fs would hit this. The cleanup is best-effort but the early return matters for fail-loud-not-silent.

Re-approve once these are pinned.

Base automatically changed from auto/issue-2312-pr-c-platform-forward to auto/issue-2312-pr-b-workspace-ingest April 29, 2026 22:30
…ad-forward

feat(chat_files): rewrite Download as HTTP-forward (RFC #2312, PR-D)
Base automatically changed from auto/issue-2312-pr-b-workspace-ingest to staging April 29, 2026 23:44
…-f-saas-secret-delivery

# Conflicts:
#	scripts/build_runtime_package.py
Comment thread workspace/internal_file_read.py
Comment thread workspace/tests/test_internal_file_read.py
@HongmingWang-Rabbit
HongmingWang-Rabbit added this pull request to the merge queue Apr 29, 2026
Merged via the queue into staging with commit 66142c1 Apr 29, 2026
23 checks passed
@HongmingWang-Rabbit
HongmingWang-Rabbit deleted the auto/issue-2312-pr-f-saas-secret-delivery branch April 29, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant