From 8664125280b812ff3e7ad235cbcf3b0e0ee639a6 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 30 Aug 2026 09:25:11 -0500 Subject: [PATCH 1/7] Repin the direct workspace Install the released root-capable CLI with the current source Skill, while keeping the beginner application directory as Drawing Board's default. Record that the pinned workspace image is now anonymously available and preserve the remaining full Codespace qualification boundary. --- .devcontainer/agent-versions.env | 6 +- .devcontainer/image/receipt.json | 4 +- AGENTS.md | 9 ++- CONTRIBUTING.md | 30 ++++---- DIRECT_COMPILATION_PLAN.md | 115 ++++++++++++++++------------ script/check | 14 ++-- script/check-firstdraft-wrapper.mjs | 4 +- script/devcontainer-smoke | 19 ++++- 8 files changed, 119 insertions(+), 82 deletions(-) diff --git a/.devcontainer/agent-versions.env b/.devcontainer/agent-versions.env index d5e7016..ecdc7d1 100644 --- a/.devcontainer/agent-versions.env +++ b/.devcontainer/agent-versions.env @@ -1,11 +1,11 @@ -# Last reviewed together on 2026-08-28. Re-review every pin before each template release. +# Last reviewed together on 2026-08-30. Re-review every pin before each template release. CLAUDE_CODE_VERSION=2.1.226 CODEX_VERSION=0.147.0 -FIRSTDRAFT_CLI_VERSION=0.2.1 +FIRSTDRAFT_CLI_VERSION=0.2.2 # CLI 0.2.x requires staging to serve First Draft API 0.3.x before template promotion. FIRSTDRAFT_CLI_DEFAULT_API_URL=https://firstdraft.com FIRSTDRAFT_STAGING_API_URL=https://staging.firstdraft.com -FIRSTDRAFT_SKILLS_REVISION=160d33a5a7d9f9b2282729ecfd3b2e24a1123143 +FIRSTDRAFT_SKILLS_REVISION=0a765f88d1cd500168e18ce1adda03802773f35e FIRSTDRAFT_CLAUDE_SKILL_NAME=create-full-stack-app # Codex namespaces the canonical source checkout with its root plugin manifest. FIRSTDRAFT_CODEX_SKILL_NAME=firstdraft:create-full-stack-app diff --git a/.devcontainer/image/receipt.json b/.devcontainer/image/receipt.json index 178bb33..9411de3 100644 --- a/.devcontainer/image/receipt.json +++ b/.devcontainer/image/receipt.json @@ -24,8 +24,8 @@ "linux/amd64": "sha256:9325cc36d84884a2d79234c50805bddb4ab05ae33816c3920e6077b7ab20f470", "linux/arm64": "sha256:58fa619ec460a34b411d0eab1eb790881e833875dc4c88b6324eb3bcfbac505a" }, - "visibility": "private", - "anonymous_pull": "not_yet_observed", + "visibility": "public", + "anonymous_pull": "passed", "comparison_codespace": "not_yet_observed" }, "verification": { diff --git a/AGENTS.md b/AGENTS.md index 4de744c..b740cd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,12 @@ name the Skill or translate the request into a command. approved remote when that repository exists, or preserve it under the Drawing Board's ignored, bind-mounted `tmp/` before starting a different Compile. Do not use `/tmp`, the container home, or another path that a Codespace rebuild discards. -- If a Codespaces forwarded-port URL reaches Rails' **Blocked hosts** page, report the known target-owned defect and - stop. Do not edit or clear `config.hosts`, or add host-admission environment variables to the Drawing Board or the - generated application. +- The pinned CLI also supports explicit POSIX current-root adoption with `--output .`, but that is not this + template's default. Select it only when the user asks to turn this exact root into the application and has approved + moving the existing Drawing Board material under `design/`; never infer it from a generic request to Compile. +- If a Codespaces forwarded-port URL reaches Rails' **Blocked hosts** page, preserve the exact generated revision, + report the observed response, and stop. Do not edit or clear `config.hosts`, or add host-admission environment + variables to the Drawing Board or the generated application. - Zero-flag `bin/firstdraft plan compile` is a separate mode for a user who explicitly asks for a private GitHub repository. Never switch modes to recover from an ambiguous start. Neither mode deploys the application. - Use `bin/firstdraft` for every First Draft command. When the installed Skill shows `firstdraft ...`, pass those diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f31a541..ece7896 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,29 +80,29 @@ runs the template-root runtime smoke twice for every pull request. The generated a separate qualification input because `./application` is absent from the template checkout. A change to an exact pin should name the compatible upstream revision or package and preserve the same version in every checked consumer. -The current candidate consumes a private development image by immutable manifest digest. CI authenticates with its -job token so the private candidate can be reviewed, but ordinary template-derived Codespaces are not qualified to -pull it yet. The public/anonymous boundary is a later explicit and irreversible package-visibility action. To update -the image: +The current template consumes a public development image by immutable manifest digest. A credential-free manifest +request reproduced that exact multi-platform index, so ordinary template-derived Codespaces can pull it without +access to the First Draft organization. CI still authenticates with its job token, but that is not an availability +requirement. To update the image: 1. change `.devcontainer/Dockerfile` or `.devcontainer/image/devcontainer.json`; 2. let the current Dev Container CLI regenerate `.devcontainer/image/devcontainer-lock.json`, then review every resolved Feature version and digest rather than editing the lock by hand; 3. push one `devcontainer-image-candidate-safe-` tag to run the candidate-only image workflow; -4. verify both private image platforms, then record that private receipt and immutable digest for review; -5. after separate visibility approval, make only the corrected package public, prove a credential-free pull by - immutable digest, and update the receipt's observation; and -6. run the contracts, the built-container smoke twice, and one fresh non-prebuilt Codespace comparison before the - public image enters the ordinary template. +4. verify both image platforms, then record the reviewed receipt and immutable digest; +5. prove a credential-free manifest read by immutable digest and update the receipt's observation; and +6. run the contracts, the built-container smoke twice, and one fresh non-prebuilt Codespace comparison before + calling the successor digest qualified for the ordinary template. The candidate workflow does not move a stable or `latest` tag. The image receipt binds the source revision, source -tree, workflow run, platforms, and manifest digest consumed by the template, while naming anonymous pull and the -comparison Codespace as unobserved until they actually run. The Docker-outside-of-Docker Feature reaches the host -daemon: that host is a disposable VM in Codespaces, but it is the developer's own machine on the supported local -path. Do not run an untrusted workspace or agent with that socket mounted. The workspace starts only its exact -Compose-owned Selenium service when `script/application-smoke` or `script/selenium start` requests browser proof. +tree, workflow run, platforms, and manifest digest consumed by the template. The current receipt records anonymous +access as passed and leaves the comparison Codespace unobserved until it actually runs. The Docker-outside-of-Docker +Feature reaches the host daemon: that host is a disposable VM in Codespaces, but it is the developer's own machine +on the supported local path. Do not run an untrusted workspace or agent with that socket mounted. The workspace +starts only its exact Compose-owned Selenium service when `script/application-smoke` or `script/selenium start` +requests browser proof. The comparison Codespace must also prove that `script/selenium` can resolve the Compose project from that runtime's -container identity; the current private-image receipt does not claim that observation or a speculative fallback. +container identity; the current public-image receipt does not claim that observation or a speculative fallback. ## Credentials and external systems diff --git a/DIRECT_COMPILATION_PLAN.md b/DIRECT_COMPILATION_PLAN.md index f0c6141..ee0543a 100644 --- a/DIRECT_COMPILATION_PLAN.md +++ b/DIRECT_COMPILATION_PLAN.md @@ -7,8 +7,8 @@ developing and testing the generated Rails Foundation in that same Codespace. Th remains the authentication provider, and the existing GitHub Publication flow remains available for callers that want a separate repository. -The first useful slice is `./application`. Compiling into the Drawing Board repository root is a later packet after -the nested flow has been used and evaluated. +The beginner/default slice is `./application`. CLI 0.2.2 also supports explicit current-root adoption, but Drawing +Board qualifies the nested flow first and does not select root adoption unless the user deliberately chooses it. ## Packet 1: direct artifact output in the CLI @@ -116,7 +116,7 @@ their owning tools rather than copying them. Drawing Board continues to install so plugin publication or catalog promotion is not required for this packet. CLI `latest`, plugin publication, and catalog promotion remain separate release choices. -The released packet-2.5 inputs observed on 2026-08-28 are: +The original packet-2.5 inputs observed on 2026-08-28 were: - CLI `0.2.1`, source/tag commit `d38ef3e54a6476b3a91f22a17fe7bd47aa6d6d68`, tree `e62ee3ff1fb6d188c5d2c5a6e5e0efd50b40245f`, published under npm's `next` tag while `latest` remains `0.1.0`; @@ -124,11 +124,26 @@ The released packet-2.5 inputs observed on 2026-08-28 are: `6f3db12c017e884d8b14c66f7d82e64229ec2073`, installed by Drawing Board from source; and - staging advertising First Draft API contract `0.3.0`. -Drawing Board now pins that CLI/Skills pair and teaches direct `./application` output as its ordinary path. The -candidate plugin `0.2.1` digest is `36e3e80db76d4af6c2af96d87fe42e00b944aab01e16584e6eb5149dc3f196b1`, but this source-pin packet does not publish -plugin bytes, move a catalog, or move npm's `latest` dist-tag. The `next` tag moved to CLI `0.2.1` in step 3. Packet -3 owns the observed non-prebuilt journey rather than inferring it from these compatible release coordinates; the -result below also preserves what that journey did not yet prove. +That Drawing Board revision pinned the CLI/Skills pair and taught direct `./application` output as its ordinary +path. The candidate plugin `0.2.1` digest is +`36e3e80db76d4af6c2af96d87fe42e00b944aab01e16584e6eb5149dc3f196b1`, but this source-pin packet does not publish +plugin bytes, move a catalog, or move npm's `latest` dist-tag. At that boundary, npm's `next` tag selected CLI +`0.2.1`. Packet 3 owns the observed non-prebuilt journey rather than inferring it from these compatible release +coordinates; the result below also preserves what that journey did not yet prove. + +The 2026-08-30 successor pin candidate uses: + +- CLI `0.2.2` from source/tag commit `799a184cb2453ceadf5575f7b46ba975e084f192`, tree + `7c66247b4d8460b130a5d65443466575a9a3cea1`, package SHA-256 + `42814e22249da7f46a186814cbfcb883c62f081b6c25bd8951f54cb43bc1902a`, published under npm's `next` tag while + `latest` remains `0.1.0`; and +- Skills source `0a765f88d1cd500168e18ce1adda03802773f35e`, tree + `4a6c87a5853d13332f7a4b04be01ed46c3e08605`, candidate package SHA-256 + `6ba0efb4fcb2dbf06d412ea8847593593fa832dc9cbcb419857a74c42e6cf74f`, requiring exact CLI `0.2.2`. + +Drawing Board installs Skills from that exact source revision, so the unpublished plugin package does not block this +template. CLI 0.2.2 retains absent `./application` output and zero-flag Publication while adding explicit current-root +adoption. The beginner journey continues to select `./application`; root adoption remains a deliberate alternative. ## Packet 3: one real non-prebuilt Codespace journey @@ -193,53 +208,55 @@ in-Codespace conversational authoring pass, boot the browser process through ste step 7's retained-context explanation and bounded source change. Those remain explicit acceptance work rather than being inferred from installation, `bin/rails server`, or Compilation success. +### Successor qualification still required + +The CLI 0.2.2/current-Skills/public-image candidate needs one fresh template-derived Codespace observation before +it becomes the current end-to-end receipt. The run should retain: + +1. credential-free retrieval of the exact workspace-image manifest and the create-to-available and first-SSH times; +2. installation of the exact CLI and Skills pins, both Skill links, and their compatibility record; +3. one signed-in continuing agent, the approved Neighborhood Guide Plan, and one task-scoped staging token; +4. one approved `--output ./application` Compile with no Publication or Service-created GitHub repository; +5. the nested parentless initialization and complete generated-application smoke; +6. the generated app's own `bin/dev` through the ordinary GitHub forwarded URL, without a local host or CSRF patch; + and +7. the same agent explaining one retained Plan decision, making one bounded source change that follows it, and + passing the focused generated-app check. + +Only that retained run may move `comparison_codespace` from `not_yet_observed` to `passed`. Root adoption is a +separate optional observation and is not a prerequisite for the beginner `./application` workflow. + Retain exact Service, CLI, Drawing Board, Plan, GapSet, artifact, generated tree, nested initial commit, container, database, smoke output, and browser coordinates. If the Compilation start has an unknown outcome without a retained identity, abort the qualification, preserve its Project/request/response/timing evidence, and ask a Service operator to reconcile it; do not retry, switch modes, or create a replacement Project as an improvised recovery. Stop the Codespace after proof or a recorded abort. Do not treat a local Docker rehearsal as the Codespace observation. -## Later packet: compile into an existing root +## Available alternative: compile into an existing root -`firstdraft plan compile --output .` is deliberately not part of the first slice. It should work in an arbitrary -directory rather than recognize Drawing Board specially. Because `.` already exists, the later noninteractive -contract needs an explicit relocation option, provisionally: +CLI 0.2.2 implements POSIX current-root adoption in any eligible real directory rather than recognizing Drawing +Board specially: ```sh -firstdraft plan compile --output . --move-existing-to design +firstdraft plan compile --output . ``` -The intended result is the generated Foundation at the working root and preexisting non-secret design materials -under `./design`. If the root is already a Git repository, its history is retained. If it is not, the later packet -must explicitly choose and document whether root initialization belongs in the command; the implementation may not -silently assume that `.git/info/exclude` exists or manufacture a parentless repository without that decision. - -Before moving anything, the transaction inventories every existing path's physical location and -tracked/staged/untracked/ignored state, including the applicable repository `.gitignore` files, -`.git/info/exclude`, and configured global excludes. It carries forward every exclusion that protects a moved path -before relocation and never automatically stages a path that was previously untracked. It stages and validates the -complete generated artifact outside the working root before mutating any existing path or Git metadata. In the -Drawing Board instance, `.env`, `.firstdraft/state.json`, and other credential or concurrency state must remain -ignored and must never be staged or committed. The private candidate at -`design/.firstdraft/foundation-plan.json` also remains deliberately untracked; the generated root -`.firstdraft/submitted-foundation-plan.json` is the tracked exact Plan record. The generated root `.gitignore` and -both generated artifact-owned `.firstdraft` files remain exact. - -Any later root-mode write to `.git/info/exclude` or other existing Git metadata is an explicit, journaled part of -the relocation transaction. A failure that leaves any relocated bytes on disk retains the protective exclusions; -the transaction may remove them only after a full rollback restores both the original bytes and their original -ignore sources. A recoverable partial result reports which protections remain. The command must also define -collisions for `.firstdraft`, `.gitignore`, `AGENTS.md`, and other artifact paths; reject nested or linked paths, an -existing `design` destination, and an unsafe partial relocation; and preserve both original design bytes and exact -generated bytes. - -Qualification must prove that root Git sees previously tracked non-secret design paths at their moved names and the -generated Foundation paths, that previously untracked paths were not swept into the index, and that every -credential/private-state path remains ignored and untracked with a clean credential scan. There is no interactive -prompt in this agent-first phase. - -This later packet replaces neither `--output ./application` nor GitHub Publication. Its detailed filesystem -transaction should be designed only after the nested flow is exercised. +The CLI reserves the root before network work, verifies the artifact outside it, and journals the installation. +On success it moves every preexisting non-Git top-level entry beneath `design/`, installs the generated Foundation +at the root, and reports the root-adoption result. It preserves an existing `.git` directory and history, stages the +tracked moves and exact generated paths without staging previously untracked or ignored files, and leaves a non-Git +root non-Git. The authoring Plan and private CLI state move under `design/.firstdraft`, which remains the location +for later First Draft commands. + +Root adoption rejects unsupported platforms, nested worktrees, unsafe entry types, an existing `design` or +`.firstdraft-root-output`, unclean tracked Git state, unmerged or sparse state, submodules, and concurrent adoption. +A failed transaction either restores the original identities or retains its private journal for explicit recovery. +It never creates a Git repository, starts Publication, deploys, or substitutes for absent `./application` output. + +Drawing Board deliberately keeps `./application` as the beginner default because it preserves a visible boundary +between design material and generated source and has a qualified initializer/smoke workflow. The successor +qualification should prove that default path first. A separate, explicit root-adoption observation may then verify +the current-root result without turning it into the template default. ## Ownership and sequencing @@ -247,9 +264,9 @@ transaction should be designed only after the nested flow is exercised. - That does not remove release coupling: a generated artifact file-set, ignore-rule, or mode change, or a generated Ruby/Node/PostgreSQL bump, requires a coordinated Drawing Board update to its `.firstdraft` allowlist, exact-byte fixture, container pins, and smoke assertions in the same candidate. -- CLI owns the direct mode, output-path validation, polling, artifact verification, and exact materialization. The - later root mode deliberately widens that owner into relocation and existing Git metadata; its transaction and - rollback contract require a separate review after packet 3 rather than inheriting packet 1's approval. +- CLI owns direct mode, output-path validation, polling, artifact verification, exact materialization, and the + current-root relocation transaction. Drawing Board selects the absent `application/` path by default and must not + restate or reimplement the root transaction. - Drawing Board owns its combined Dev Container and nested-repository initialization. - The authoring Skill teaches the coherent command sequence only after the CLI contract lands; it does not duplicate detailed transport or container contracts. @@ -257,8 +274,8 @@ transaction should be designed only after the nested flow is exercised. - Broad Foundation Plan realization gaps and the documentation/website audit are separate work lanes. Land packet 1 and packet 2 independently after their repository checks and reviews. Complete packet 2.5 and prove -its exact released/pinned tuple before packet 3. Use packet 3 to decide whether the root-output packet is still -valuable and to refine its relocation contract. +its exact released/pinned tuple before packet 3. Qualify the successor `./application` journey before making any +Drawing Board claim about the optional root-output experience. ## Review questions diff --git a/script/check b/script/check index e0faa40..eb74699 100755 --- a/script/check +++ b/script/check @@ -142,10 +142,10 @@ required(configuration.containerEnv?.SELENIUM_HOST === "selenium", "Generated sy required(JSON.stringify(configuration.forwardPorts) === JSON.stringify([3000, 5432]), "Rails and PostgreSQL ports must be forwarded."); required(compose.includes("- ..:/workspaces/drawing-board:cached"), "Compose must mount the complete Drawing Board."); required(compose.includes(`${imageReceipt.publication.package}@${imageReceipt.publication.manifest}`), "The workspace image must match its immutable receipt."); -required(imageReceipt.publication.visibility === "private", "The workspace image must remain private before explicit visibility approval."); -required(imageReceipt.publication.anonymous_pull === "not_yet_observed", "Anonymous pull must remain unclaimed before visibility approval."); +required(imageReceipt.publication.visibility === "public", "The workspace image must retain its observed public visibility."); +required(imageReceipt.publication.anonymous_pull === "passed", "The exact workspace image must retain its anonymous-pull observation."); required(imageReceipt.publication.comparison_codespace === "not_yet_observed", "The comparison Codespace must remain unclaimed before it runs."); -required(imageReceipt.verification?.platforms?.["linux/arm64"]?.runtime === "not_observed", "The private candidate must not claim arm64 runtime proof without a retained observation."); +required(imageReceipt.verification?.platforms?.["linux/arm64"]?.runtime === "not_observed", "The reviewed image must not claim arm64 runtime proof without a retained observation."); required(compose.includes("- postgres-data:/var/lib/postgresql"), "PostgreSQL 18 data must use its parent volume target."); required(compose.includes("condition: service_healthy"), "The workspace must wait for PostgreSQL readiness."); required(!/^\s{6}selenium:\s*$/m.test(compose.split(" selenium:")[0]), "Selenium must not block ordinary workspace startup."); @@ -164,11 +164,11 @@ required(imageWorkflow.includes("docker/login-action@dbcb813823bdd20940b903addbd required(imageWorkflow.includes("devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450"), "The image builder must remain exact."); required(imageWorkflow.includes("imageName: ghcr.io/firstdraft/drawing-board-workspace"), "Unsafe image history must remain isolated from the corrected package."); required(imageWorkflow.includes("platform: linux/amd64,linux/arm64"), "The development image must cover Codespaces and local Apple Silicon."); -required(/^\s{2}packages: read$/m.test(ciWorkflow), "Private-image CI must request read-only package access."); -required(ciWorkflow.includes("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7"), "Private-image CI must use the reviewed checkout action."); +required(/^\s{2}packages: read$/m.test(ciWorkflow), "Workspace-image CI must request read-only package access."); +required(ciWorkflow.includes("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7"), "Workspace-image CI must use the reviewed checkout action."); required(/^\s{10}fetch-depth: 0$/m.test(ciWorkflow), "CI must fetch source history so it can verify source-commit blobs."); -required(ciWorkflow.includes("docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0"), "Private-image CI must use the reviewed registry login action."); -required(ciWorkflow.includes("password: ${{ secrets.GITHUB_TOKEN }}"), "Private-image CI must authenticate with its job token."); +required(ciWorkflow.includes("docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0"), "Workspace-image CI must retain the reviewed registry login action."); +required(ciWorkflow.includes("password: ${{ secrets.GITHUB_TOKEN }}"), "Workspace-image CI must retain its job-token login."); required(ciWorkflow.includes("FIRSTDRAFT_REQUIRE_IMAGE_SOURCE_COMMIT=1 script/check"), "CI must fail when the image-source commit is absent."); required(ciWorkflow.includes("script/check-depth-one"), "CI must exercise the real depth-one receipt contract."); required(ciWorkflow.includes("script/devcontainer-smoke && script/devcontainer-smoke"), "CI must prove the Dev Container smoke is repeatable."); diff --git a/script/check-firstdraft-wrapper.mjs b/script/check-firstdraft-wrapper.mjs index d0805b6..348fda3 100644 --- a/script/check-firstdraft-wrapper.mjs +++ b/script/check-firstdraft-wrapper.mjs @@ -27,7 +27,7 @@ const fs = require("node:fs"); const arguments_ = process.argv.slice(2); if (arguments_.length === 1 && arguments_[0] === "--version") { process.stdout.write("firstdraft " + - (process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.2.1") + "\\n"); + (process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.2.2") + "\\n"); if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) { process.stderr.write("A benign version notice.\\n"); } @@ -164,7 +164,7 @@ fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify({ root: testRepository, stdio: "ignore", }), - /standalone First Draft CLI must be exactly 0\.2\.1/, + /standalone First Draft CLI must be exactly 0\.2\.2/, ); } finally { fs.rmSync(temporaryRoot, {force: true, recursive: true}); diff --git a/script/devcontainer-smoke b/script/devcontainer-smoke index 587baae..6912709 100755 --- a/script/devcontainer-smoke +++ b/script/devcontainer-smoke @@ -74,7 +74,8 @@ FIRSTDRAFT_STAGING_API_URL="$FIRSTDRAFT_STAGING_API_URL" node -e ' claude_skill_path="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/$FIRSTDRAFT_CLAUDE_SKILL_NAME" codex_skill_path="$HOME/.agents/skills/create-full-stack-app" -expected_skill_path="$HOME/.cache/firstdraft/skills/$FIRSTDRAFT_SKILLS_REVISION/skills/create-full-stack-app" +skills_checkout="$HOME/.cache/firstdraft/skills/$FIRSTDRAFT_SKILLS_REVISION" +expected_skill_path="$skills_checkout/skills/create-full-stack-app" for skill_path in "$claude_skill_path" "$codex_skill_path"; do test -L "$skill_path" test -r "$skill_path/SKILL.md" @@ -82,6 +83,22 @@ for skill_path in "$claude_skill_path" "$codex_skill_path"; do test "$(cd "$skill_path" && pwd -P)" = "$expected_skill_path" done grep -Fq "firstdraft_cli plan compile --output ./application" "$codex_skill_path/SKILL.md" +grep -Fq "current-root adoption" "$codex_skill_path/SKILL.md" + +FIRSTDRAFT_CLI_VERSION="$FIRSTDRAFT_CLI_VERSION" \ + SKILLS_COMPATIBILITY_PATH="$skills_checkout/release/compatibility.json" \ + node -e ' + const fs = require("node:fs"); + const compatibility = JSON.parse( + fs.readFileSync(process.env.SKILLS_COMPATIBILITY_PATH, "utf8"), + ); + const expectedCli = [`= ${process.env.FIRSTDRAFT_CLI_VERSION}`]; + const valid = compatibility.format === "firstdraft.release-compatibility/1" && + compatibility.component === "skills" && + JSON.stringify(compatibility.requires?.cli) === JSON.stringify(expectedCli) && + /^[0-9a-f]{64}$/.test(compatibility.plugin_source?.tarball_sha256 ?? ""); + process.exit(valid ? 0 : 1); + ' prompt_input="$(codex debug prompt-input "Use the create-full-stack-app Skill.")" skill_target="$(cd "$codex_skill_path" && pwd -P)/SKILL.md" From 4a4205823825a66ff13789ff451d1deea684e8f7 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 30 Aug 2026 10:32:47 -0500 Subject: [PATCH 2/7] Restore maintained Codespaces SSH The custom OpenSSH entrypoint can start under ordinary Docker while never receiving control in Codespaces, leaving the forwarded SSH endpoint without a banner. Return lifecycle ownership to the pinned devcontainers sshd Feature and retain only the key-only, non-root policy locally. --- .devcontainer/Dockerfile | 13 +- .devcontainer/image/devcontainer-lock.json | 5 + .devcontainer/image/devcontainer.json | 1 + .devcontainer/workspace-init | 14 -- CONTRIBUTING.md | 7 +- script/check | 12 +- script/check-image-receipt.mjs | 7 +- script/devcontainer-image-smoke | 176 ++++++++------------- script/devcontainer-smoke | 12 +- 9 files changed, 102 insertions(+), 145 deletions(-) delete mode 100755 .devcontainer/workspace-init diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8b8812d..b305d34 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,28 +6,17 @@ LABEL org.opencontainers.image.source="https://github.com/firstdraft/drawing-boa LABEL org.opencontainers.image.description="First Draft Drawing Board development environment" USER root -RUN apt-get update && \ - export DEBIAN_FRONTEND=noninteractive && \ - apt-get install --yes --no-install-recommends lsof openssh-client openssh-server && \ - rm -f /etc/ssh/ssh_host_* && \ - rm -rf /var/lib/apt/lists/* && \ - install -d -m 0755 /etc/ssh/sshd_config.d /run/sshd && \ +RUN install -d -m 0755 /etc/ssh/sshd_config.d && \ printf '%s\n' \ - 'Port 2222' \ 'AuthenticationMethods publickey' \ 'PermitRootLogin no' \ 'PasswordAuthentication no' \ 'KbdInteractiveAuthentication no' \ 'PubkeyAuthentication yes' \ - 'UsePAM yes' \ > /etc/ssh/sshd_config.d/99-foundation.conf -COPY .devcontainer/workspace-init /usr/local/share/firstdraft-workspace-init.sh -RUN chmod 0755 /usr/local/share/firstdraft-workspace-init.sh - USER vscode -ENTRYPOINT ["/usr/local/share/firstdraft-workspace-init.sh"] CMD ["sleep", "infinity"] # The Rails image installs Ruby through mise, whose activation normally happens diff --git a/.devcontainer/image/devcontainer-lock.json b/.devcontainer/image/devcontainer-lock.json index 13545db..881b032 100644 --- a/.devcontainer/image/devcontainer-lock.json +++ b/.devcontainer/image/devcontainer-lock.json @@ -15,6 +15,11 @@ "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" + }, "ghcr.io/rails/devcontainer/features/activestorage:1": { "version": "1.1.1", "resolved": "ghcr.io/rails/devcontainer/features/activestorage@sha256:7fa8fff898ac33076ebf65631d3c6c902dc9bad87de3e5dfa645f3e2d7a35c07", diff --git a/.devcontainer/image/devcontainer.json b/.devcontainer/image/devcontainer.json index 865a9c6..6d58c24 100644 --- a/.devcontainer/image/devcontainer.json +++ b/.devcontainer/image/devcontainer.json @@ -9,6 +9,7 @@ "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/rails/devcontainer/features/activestorage:1": {}, "ghcr.io/devcontainers/features/node:1": { "version": "24.18.0" }, + "ghcr.io/devcontainers/features/sshd:1": {}, "ghcr.io/rails/devcontainer/features/postgres-client:1": { "version": "18" } }, "remoteUser": "vscode" diff --git a/.devcontainer/workspace-init b/.devcontainer/workspace-init deleted file mode 100755 index f42401c..0000000 --- a/.devcontainer/workspace-init +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -sudo install -d -m 0755 /run/sshd -sudo ssh-keygen -A -sudo /usr/sbin/sshd -t -if ! pgrep -x sshd >/dev/null; then - sudo /usr/sbin/sshd -fi - -if (($# == 0)); then - set -- sleep infinity -fi -exec "$@" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ece7896..7ce362a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ cross-repository sequence and its safety boundaries live in | `script/check` | Fast source, pin, wrapper, and credential contracts | | `script/check-depth-one` | Receipt validation in a real one-commit checkout without image-source history | | `script/check-image-receipt.mjs` | Exact source, publication, platform, and rejected-package receipt contract | -| `script/devcontainer-image-smoke` | Non-vacuous image-layer, locked Feature-ID, no-command, host-key, and PostgreSQL checks | +| `script/devcontainer-image-smoke` | Locked Feature-ID, maintained SSH lifecycle, and PostgreSQL checks | | `script/devcontainer-smoke` | Runtime smoke executed inside the built Dev Container | | `script/initialize-application` | Parentless nested Git initialization for direct-download output | | `script/selenium` | On-demand Selenium start, status, and stop inside the Dev Container | @@ -94,6 +94,11 @@ requirement. To update the image: 6. run the contracts, the built-container smoke twice, and one fresh non-prebuilt Codespace comparison before calling the successor digest qualified for the ordinary template. +The image uses the maintained `ghcr.io/devcontainers/features/sshd:1` Feature for the SSH server lifecycle expected +by Codespaces and keeps only the key-only, non-root policy in the Dockerfile. Do not replace the Feature entrypoint +with a custom OpenSSH startup script; the ordinary local image smoke is not proof that a different entrypoint will +be started by Codespaces. + The candidate workflow does not move a stable or `latest` tag. The image receipt binds the source revision, source tree, workflow run, platforms, and manifest digest consumed by the template. The current receipt records anonymous access as passed and leaves the comparison Codespace unobserved until it actually runs. The Docker-outside-of-Docker diff --git a/script/check b/script/check index eb74699..96ace10 100755 --- a/script/check +++ b/script/check @@ -25,6 +25,7 @@ const expectedFeatures = [ "ghcr.io/devcontainers/features/github-cli:1", "ghcr.io/rails/devcontainer/features/activestorage:1", "ghcr.io/devcontainers/features/node:1", + "ghcr.io/devcontainers/features/sshd:1", "ghcr.io/rails/devcontainer/features/postgres-client:1", ].sort(); const configuredFeatures = Object.keys(imageConfiguration.features ?? {}).sort(); @@ -68,7 +69,7 @@ node script/check-image-receipt.mjs # shellcheck disable=SC1091 source .devcontainer/agent-versions.env -for script in .devcontainer/setup-agents .devcontainer/workspace-init bin/agent-doctor bin/review-plan-with-claude bin/review-plan-with-codex script/application-smoke script/check script/check-depth-one script/devcontainer-image-smoke script/devcontainer-smoke script/initialize-application script/selenium; do +for script in .devcontainer/setup-agents bin/agent-doctor bin/review-plan-with-claude bin/review-plan-with-codex script/application-smoke script/check script/check-depth-one script/devcontainer-image-smoke script/devcontainer-smoke script/initialize-application script/selenium; do bash -n "$script" if [[ ! -x "$script" ]]; then echo "$script must be executable." >&2 @@ -153,9 +154,12 @@ required(compose.includes("selenium/standalone-chromium:4.47.0-20260808@sha256:1 required(!compose.split(" selenium:")[1].split(" postgres:")[0].includes("restart:"), "Selenium must remain stopped after a workspace restart until requested again."); required(dockerfile.includes("ARG RUBY_VERSION=4.0.5"), "The Drawing Board Ruby image must match generated Foundations."); required(dockerfile.includes("ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION@sha256:e1bd336b0f49207a2a235299f7163bf00687b24582b911be21a58f0e5c1198cd"), "Use the reviewed Rails Dev Container image digest."); -required(dockerfile.includes("apt-get install --yes --no-install-recommends lsof openssh-client openssh-server"), "OpenSSH must be installed in the reviewed image layer."); -required(dockerfile.includes("rm -f /etc/ssh/ssh_host_*"), "Build-time SSH host keys must be removed in the installation layer."); -required(dockerfile.includes('ENTRYPOINT ["/usr/local/share/firstdraft-workspace-init.sh"]'), "The image must generate SSH host keys through its runtime entrypoint."); +required(!dockerfile.includes("apt-get install") && !dockerfile.includes("openssh-server"), "OpenSSH lifecycle must remain owned by the maintained sshd Feature."); +required(!dockerfile.includes("ENTRYPOINT"), "The Dockerfile must not replace the maintained Feature entrypoint chain."); +required(dockerfile.includes("'AuthenticationMethods publickey'") && + dockerfile.includes("'PermitRootLogin no'") && + dockerfile.includes("'PasswordAuthentication no'") && + dockerfile.includes("'KbdInteractiveAuthentication no'"), "The official SSH listener must remain key-only and non-root."); required(dockerfile.includes('CMD ["sleep", "infinity"]'), "The published image must remain running when no command is supplied."); required(imageWorkflow.includes("tags: [devcontainer-image-candidate-safe-*]"), "Image publication must remain limited to corrected-package candidates."); required(imageWorkflow.includes("docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0"), "The QEMU setup action must remain exact."); diff --git a/script/check-image-receipt.mjs b/script/check-image-receipt.mjs index 6da8380..2e5003e 100755 --- a/script/check-image-receipt.mjs +++ b/script/check-image-receipt.mjs @@ -27,7 +27,6 @@ const expectedInputPaths = [ ".devcontainer/Dockerfile", ".devcontainer/image/devcontainer.json", ".devcontainer/image/devcontainer-lock.json", - ".devcontainer/workspace-init", ".github/workflows/devcontainer-image.yml", "script/devcontainer-image-smoke", ].sort(); @@ -51,14 +50,10 @@ required(["not_yet_observed", "passed"].includes(receipt.publication?.anonymous_ required(["not_yet_observed", "passed"].includes(receipt.publication?.comparison_codespace), "The receipt must name the comparison-Codespace observation state."); const platforms = receipt.verification?.platforms ?? {}; -required(platforms["linux/amd64"]?.layers_contain_no_ssh_host_keys === true, "The amd64 layer census must be retained."); required(platforms["linux/amd64"]?.locked_feature_ids_present_once_in_metadata === true, "The amd64 locked-Feature-ID metadata check must be retained."); -required(platforms["linux/amd64"]?.image_rest_contains_no_ssh_host_keys === true, "The amd64 image-rest check must be retained."); -required(platforms["linux/amd64"]?.no_command_stays_running === true, "The amd64 no-command entrypoint check must be retained."); -required(platforms["linux/amd64"]?.two_started_containers_have_distinct_ed25519_host_keys === true, "The amd64 per-container host-key check must be retained."); +required(platforms["linux/amd64"]?.official_sshd_feature_starts_key_only_listener === true, "The amd64 maintained-SSH lifecycle check must be retained."); required(/^18\.\d+$/.test(platforms["linux/amd64"]?.postgresql_client ?? ""), "The amd64 PostgreSQL client receipt must retain the observed 18.x release."); required(platforms["linux/amd64"]?.psql_major === 18 && platforms["linux/amd64"]?.pg_dump_major === 18, "The amd64 PostgreSQL client tools must use major 18."); -required(platforms["linux/arm64"]?.layers_contain_no_ssh_host_keys === true, "The arm64 layer census must be retained."); required(platforms["linux/arm64"]?.locked_feature_ids_present_once_in_metadata === true, "The arm64 locked-Feature-ID metadata check must be retained."); required(["not_observed", "passed"].includes(platforms["linux/arm64"]?.runtime), "The receipt must name the arm64 runtime-observation state."); required(/^[0-9a-f]{64}$/.test(receipt.verification?.workflow_log_sha256 ?? ""), "The receipt must bind the exact workflow log."); diff --git a/script/devcontainer-image-smoke b/script/devcontainer-image-smoke index 4446deb..6ebb6c6 100755 --- a/script/devcontainer-image-smoke +++ b/script/devcontainer-image-smoke @@ -27,40 +27,6 @@ cleanup() { } trap cleanup EXIT -docker image save --output "${root}/image.tar" "${image}" -mkdir "${root}/archive" -tar -xf "${root}/image.tar" -C "${root}/archive" - -layers_file="${root}/layers" -jq -er ' - if type == "array" and length == 1 and - (.[0].Layers | type == "array" and length > 0) - then .[0].Layers[] - else error("expected one saved image with a nonempty Layers array") - end -' "${root}/archive/manifest.json" > "${layers_file}" -[[ -s "${layers_file}" ]] || { - echo "The saved development image exposed no layers to inspect." >&2 - exit 1 -} - -layers=() -while IFS= read -r layer; do - layers+=("${layer}") -done < <(sort -u "${layers_file}") -((${#layers[@]} > 0)) || { - echo "The saved development image exposed no unique layers to inspect." >&2 - exit 1 -} -for layer in "${layers[@]}"; do - entries="${root}/$(printf '%s' "${layer}" | tr '/:' '__').entries" - tar -tf "${root}/archive/${layer}" > "${entries}" - if grep -Eq '(^|/)etc/ssh/ssh_host_[^/]+_key(\.pub)?$' "${entries}"; then - echo "An SSH host key is recoverable from image layer ${layer}." >&2 - exit 1 - fi -done - docker image inspect "${image}" > "${root}/image-inspect.json" node - "${root}/image-inspect.json" "${repo_root}/.devcontainer/image/devcontainer-lock.json" <<'NODE' const fs = require("node:fs"); @@ -95,97 +61,93 @@ for (const id of lockedIds) { throw new Error(`Published Feature metadata contains a different major for ${id}.`); } } +const sshd = entries.find((entry) => entry?.id === "ghcr.io/devcontainers/features/sshd:1"); +if (sshd?.entrypoint !== "/usr/local/share/ssh-init.sh") { + throw new Error("The maintained sshd Feature entrypoint is absent from image metadata."); +} NODE if [[ "${layers_only}" == true ]]; then - echo "Development image layers and locked Feature-ID metadata passed." + echo "Development image locked Feature-ID metadata passed." exit 0 fi -if docker run --rm --entrypoint bash "${image}" -lc 'compgen -G "/etc/ssh/ssh_host_*" >/dev/null'; then - echo "The development image contains an SSH host key before container start." >&2 - exit 1 -fi - -fingerprints=() -key_identities=() postgresql_clients=() -fingerprint_digest() { - awk '{print $2}' <<<"$1" -} -key_identity() { - awk 'NF >= 2 {print $1, $2; exit}' <<<"$1" +container="drawing-board-image-smoke-$$" +containers+=("${container}") +docker run --detach --name "${container}" --publish 127.0.0.1::2222 \ + --entrypoint /usr/local/share/ssh-init.sh "${image}" sleep infinity >/dev/null +[[ "$(docker inspect --format '{{.State.Running}}' "${container}")" == "true" ]] || { + echo "The development image did not remain running with the maintained sshd Feature entrypoint." >&2 + exit 1 } -for ordinal in 1 2; do - container="drawing-board-image-smoke-${ordinal}-$$" - containers+=("${container}") - if [[ "${ordinal}" == "1" ]]; then - docker run --detach --name "${container}" "${image}" >/dev/null - else - docker run --detach --name "${container}" "${image}" sleep infinity >/dev/null +for _attempt in {1..20}; do + if docker exec "${container}" pgrep -x sshd >/dev/null; then + break fi [[ "$(docker inspect --format '{{.State.Running}}' "${container}")" == "true" ]] || { - echo "Container ${ordinal} did not remain running." >&2 + echo "The development image exited while starting the maintained SSH listener." >&2 exit 1 } - for _attempt in {1..20}; do - if docker exec "${container}" test -f /etc/ssh/ssh_host_ed25519_key.pub; then - break - fi - [[ "$(docker inspect --format '{{.State.Running}}' "${container}")" == "true" ]] || { - echo "Container ${ordinal} exited while generating its SSH host key." >&2 - exit 1 - } - sleep 0.25 - done - docker exec "${container}" test -f /etc/ssh/ssh_host_ed25519_key.pub || { - echo "Container ${ordinal} did not generate its SSH host key." >&2 - exit 1 - } - fingerprint_line="$(docker exec "${container}" ssh-keygen -l -E sha256 -f /etc/ssh/ssh_host_ed25519_key.pub)" - fingerprint="$(fingerprint_digest "${fingerprint_line}")" - [[ "${fingerprint}" == SHA256:* ]] || { - echo "Container ${ordinal} did not generate an Ed25519 SSH host key." >&2 - exit 1 - } - fingerprints+=("${fingerprint}") - public_key="$(docker exec "${container}" cat /etc/ssh/ssh_host_ed25519_key.pub)" - identity="$(key_identity "${public_key}")" - [[ "${identity}" == ssh-ed25519\ * ]] || { - echo "Container ${ordinal} exposed an invalid Ed25519 public key." >&2 - exit 1 - } - key_identities+=("${identity}") - psql_version="$(docker exec "${container}" psql --version)" - pg_dump_version="$(docker exec "${container}" pg_dump --version)" - printf '%s\n%s\n' "${psql_version}" "${pg_dump_version}" - psql_release="$(sed -nE 's/^psql \(PostgreSQL\) (18\.[0-9]+).*$/\1/p' <<<"${psql_version}")" - pg_dump_release="$(sed -nE 's/^pg_dump \(PostgreSQL\) (18\.[0-9]+).*$/\1/p' <<<"${pg_dump_version}")" - [[ -n "${psql_release}" && "${psql_release}" == "${pg_dump_release}" ]] || { - echo "Container ${ordinal} did not expose matching PostgreSQL 18 client releases." >&2 + sleep 0.25 +done +docker exec "${container}" pgrep -x sshd >/dev/null || { + echo "The maintained sshd Feature did not start its listener." >&2 + exit 1 +} +docker exec "${container}" test -s /tmp/sshd.log +docker exec --user root "${container}" /usr/sbin/sshd -t +sshd_configuration="$(docker exec --user root "${container}" /usr/sbin/sshd -T)" +for expected in \ + "authenticationmethods publickey" \ + "kbdinteractiveauthentication no" \ + "passwordauthentication no" \ + "permitrootlogin no" \ + "port 2222" \ + "pubkeyauthentication yes" \ + "usepam yes"; do + grep -Fx "${expected}" <<<"${sshd_configuration}" >/dev/null || { + echo "The maintained SSH listener is missing: ${expected}." >&2 exit 1 } - postgresql_clients+=("${psql_release}") done -docker cp "${containers[0]}:/etc/ssh/ssh_host_ed25519_key.pub" "${root}/duplicate-key.pub" >/dev/null -awk '{print $1, $2, "deliberately-different-comment"}' "${root}/duplicate-key.pub" > "${root}/duplicate-key-with-new-comment.pub" -duplicate_comment_line="$(ssh-keygen -l -E sha256 -f "${root}/duplicate-key-with-new-comment.pub")" -[[ "$(fingerprint_digest "${duplicate_comment_line}")" == "${fingerprints[0]}" ]] || { - echo "The host-key comparison is sensitive to public-key comments." >&2 - exit 1 -} -[[ "$(key_identity "$(<"${root}/duplicate-key-with-new-comment.pub")")" == "${key_identities[0]}" ]] || { - echo "The exact duplicate-key negative did not preserve key material." >&2 +host_port="$(docker port "${container}" 2222/tcp | sed -nE 's/^127\.0\.0\.1:([0-9]+)$/\1/p')" +[[ "${host_port}" =~ ^[0-9]+$ ]] || { + echo "The maintained SSH listener exposed no loopback port." >&2 exit 1 } -if [[ "${fingerprints[0]}" == "${fingerprints[1]}" || "${key_identities[0]}" == "${key_identities[1]}" ]]; then - echo "Separate containers reused the same SSH host key." >&2 - exit 1 -fi -[[ "${postgresql_clients[0]}" == "${postgresql_clients[1]}" ]] || { - echo "Separate containers exposed different PostgreSQL client releases." >&2 +SSH_PORT="${host_port}" node <<'NODE' +const net = require("node:net"); +const socket = net.createConnection({host: "127.0.0.1", port: Number(process.env.SSH_PORT)}); +let bytes = ""; +const fail = (message) => { + console.error(message); + socket.destroy(); + process.exit(1); +}; +socket.setTimeout(5_000, () => fail("The maintained SSH listener sent no banner.")); +socket.on("error", (error) => fail(`The maintained SSH listener could not be reached: ${error.message}`)); +socket.on("data", (chunk) => { + bytes += chunk; + const newline = bytes.indexOf("\n"); + if (newline === -1) return; + const banner = bytes.slice(0, newline).trim(); + if (!banner.startsWith("SSH-2.0-OpenSSH_")) fail(`Unexpected SSH banner: ${banner}`); + console.log(banner); + socket.destroy(); +}); +NODE + +psql_version="$(docker exec "${container}" psql --version)" +pg_dump_version="$(docker exec "${container}" pg_dump --version)" +printf '%s\n%s\n' "${psql_version}" "${pg_dump_version}" +psql_release="$(sed -nE 's/^psql \(PostgreSQL\) (18\.[0-9]+).*$/\1/p' <<<"${psql_version}")" +pg_dump_release="$(sed -nE 's/^pg_dump \(PostgreSQL\) (18\.[0-9]+).*$/\1/p' <<<"${pg_dump_version}")" +[[ -n "${psql_release}" && "${psql_release}" == "${pg_dump_release}" ]] || { + echo "The development image did not expose matching PostgreSQL 18 client releases." >&2 exit 1 } +postgresql_clients+=("${psql_release}") -echo "Development image layers, PostgreSQL ${postgresql_clients[0]} clients, and per-container SSH host keys passed." +echo "Development image maintained-SSH lifecycle and PostgreSQL ${postgresql_clients[0]} clients passed." diff --git a/script/devcontainer-smoke b/script/devcontainer-smoke index 6912709..fe75abc 100755 --- a/script/devcontainer-smoke +++ b/script/devcontainer-smoke @@ -35,7 +35,17 @@ docker compose version >/dev/null test -x /usr/sbin/sshd sudo /usr/sbin/sshd -t -sudo /usr/sbin/sshd -T | grep -Fx "usepam yes" >/dev/null +sshd_configuration="$(sudo /usr/sbin/sshd -T)" +for expected in \ + "authenticationmethods publickey" \ + "kbdinteractiveauthentication no" \ + "passwordauthentication no" \ + "permitrootlogin no" \ + "port 2222" \ + "pubkeyauthentication yes" \ + "usepam yes"; do + grep -Fx "${expected}" <<<"${sshd_configuration}" >/dev/null +done pgrep -x sshd >/dev/null shared_path="$(node -e ' From ef84c2a02deaffcdd6f3f0a6e95a65d014a74a11 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 30 Aug 2026 10:43:18 -0500 Subject: [PATCH 3/7] Make SSH image probes nonvacuous Require an observed banner and the image default command, and name the arm64 branch as metadata-only. These checks close the review holes without taking SSH lifecycle ownership back from the maintained Feature. --- .github/workflows/devcontainer-image.yml | 2 +- script/check-image-receipt.mjs | 1 + script/devcontainer-image-smoke | 30 +++++++++++++++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml index 50bc342..8cdda8f 100644 --- a/.github/workflows/devcontainer-image.yml +++ b/.github/workflows/devcontainer-image.yml @@ -53,4 +53,4 @@ jobs: docker pull --platform linux/amd64 "${IMAGE%:*}@$amd64" script/devcontainer-image-smoke "${IMAGE%:*}@$amd64" docker pull --platform linux/arm64 "${IMAGE%:*}@$arm64" - script/devcontainer-image-smoke --layers-only "${IMAGE%:*}@$arm64" + script/devcontainer-image-smoke --metadata-only "${IMAGE%:*}@$arm64" diff --git a/script/check-image-receipt.mjs b/script/check-image-receipt.mjs index 2e5003e..c7b7203 100755 --- a/script/check-image-receipt.mjs +++ b/script/check-image-receipt.mjs @@ -52,6 +52,7 @@ required(["not_yet_observed", "passed"].includes(receipt.publication?.comparison const platforms = receipt.verification?.platforms ?? {}; required(platforms["linux/amd64"]?.locked_feature_ids_present_once_in_metadata === true, "The amd64 locked-Feature-ID metadata check must be retained."); required(platforms["linux/amd64"]?.official_sshd_feature_starts_key_only_listener === true, "The amd64 maintained-SSH lifecycle check must be retained."); +required(platforms["linux/amd64"]?.no_command_stays_running === true, "The amd64 default-command runtime check must be retained."); required(/^18\.\d+$/.test(platforms["linux/amd64"]?.postgresql_client ?? ""), "The amd64 PostgreSQL client receipt must retain the observed 18.x release."); required(platforms["linux/amd64"]?.psql_major === 18 && platforms["linux/amd64"]?.pg_dump_major === 18, "The amd64 PostgreSQL client tools must use major 18."); required(platforms["linux/arm64"]?.locked_feature_ids_present_once_in_metadata === true, "The arm64 locked-Feature-ID metadata check must be retained."); diff --git a/script/devcontainer-image-smoke b/script/devcontainer-image-smoke index 6ebb6c6..84ef6bb 100755 --- a/script/devcontainer-image-smoke +++ b/script/devcontainer-image-smoke @@ -1,13 +1,13 @@ #!/usr/bin/env bash set -Eeuo pipefail -layers_only=false -if [[ "${1:-}" == "--layers-only" ]]; then - layers_only=true +metadata_only=false +if [[ "${1:-}" == "--metadata-only" ]]; then + metadata_only=true shift fi -image="${1:?Usage: script/devcontainer-image-smoke [--layers-only] IMAGE@sha256:DIGEST}" +image="${1:?Usage: script/devcontainer-image-smoke [--metadata-only] IMAGE@sha256:DIGEST}" case "${image}" in *@sha256:[0-9a-f][0-9a-f]*|sha256:[0-9a-f][0-9a-f]*) ;; *) @@ -67,12 +67,20 @@ if (sshd?.entrypoint !== "/usr/local/share/ssh-init.sh") { } NODE -if [[ "${layers_only}" == true ]]; then +if [[ "${metadata_only}" == true ]]; then echo "Development image locked Feature-ID metadata passed." exit 0 fi postgresql_clients=() +default_container="drawing-board-image-smoke-default-$$" +containers+=("${default_container}") +docker run --detach --name "${default_container}" "${image}" >/dev/null +[[ "$(docker inspect --format '{{.State.Running}}' "${default_container}")" == "true" ]] || { + echo "The development image did not remain running with its default command." >&2 + exit 1 +} + container="drawing-board-image-smoke-$$" containers+=("${container}") docker run --detach --name "${container}" --publish 127.0.0.1::2222 \ @@ -95,7 +103,6 @@ docker exec "${container}" pgrep -x sshd >/dev/null || { echo "The maintained sshd Feature did not start its listener." >&2 exit 1 } -docker exec "${container}" test -s /tmp/sshd.log docker exec --user root "${container}" /usr/sbin/sshd -t sshd_configuration="$(docker exec --user root "${container}" /usr/sbin/sshd -T)" for expected in \ @@ -121,21 +128,28 @@ SSH_PORT="${host_port}" node <<'NODE' const net = require("node:net"); const socket = net.createConnection({host: "127.0.0.1", port: Number(process.env.SSH_PORT)}); let bytes = ""; +let complete = false; const fail = (message) => { + if (complete) return; + complete = true; console.error(message); + process.exitCode = 1; socket.destroy(); - process.exit(1); }; socket.setTimeout(5_000, () => fail("The maintained SSH listener sent no banner.")); socket.on("error", (error) => fail(`The maintained SSH listener could not be reached: ${error.message}`)); +socket.on("end", () => fail("The maintained SSH listener closed without a complete banner.")); +socket.on("close", () => fail("The maintained SSH listener closed without a complete banner.")); socket.on("data", (chunk) => { bytes += chunk; const newline = bytes.indexOf("\n"); if (newline === -1) return; const banner = bytes.slice(0, newline).trim(); if (!banner.startsWith("SSH-2.0-OpenSSH_")) fail(`Unexpected SSH banner: ${banner}`); + if (complete) return; + complete = true; console.log(banner); - socket.destroy(); + socket.end(); }); NODE From 1488d6337847a0dfd7da9b7c11de895e81160421 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 30 Aug 2026 10:47:38 -0500 Subject: [PATCH 4/7] Bind the maintained SSH boundary Require successor receipts to retire the superseded host-key claims and describe the accepted tunneled-development policy. Also keep the default-command and banner probes nonvacuous for their full observation window. --- CONTRIBUTING.md | 6 ++++-- script/check-image-receipt.mjs | 24 ++++++++++++++++++++++++ script/devcontainer-image-smoke | 8 ++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ce362a..3437cdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ cross-repository sequence and its safety boundaries live in | `script/check` | Fast source, pin, wrapper, and credential contracts | | `script/check-depth-one` | Receipt validation in a real one-commit checkout without image-source history | | `script/check-image-receipt.mjs` | Exact source, publication, platform, and rejected-package receipt contract | -| `script/devcontainer-image-smoke` | Locked Feature-ID, maintained SSH lifecycle, and PostgreSQL checks | +| `script/devcontainer-image-smoke` | Default command, locked Feature-ID, maintained SSH lifecycle, and PostgreSQL checks | | `script/devcontainer-smoke` | Runtime smoke executed inside the built Dev Container | | `script/initialize-application` | Parentless nested Git initialization for direct-download output | | `script/selenium` | On-demand Selenium start, status, and stop inside the Dev Container | @@ -97,7 +97,9 @@ requirement. To update the image: The image uses the maintained `ghcr.io/devcontainers/features/sshd:1` Feature for the SSH server lifecycle expected by Codespaces and keeps only the key-only, non-root policy in the Dockerfile. Do not replace the Feature entrypoint with a custom OpenSSH startup script; the ordinary local image smoke is not proof that a different entrypoint will -be started by Codespaces. +be started by Codespaces. Image-layer host keys supplied by the maintained Feature are accepted for this disposable, +GitHub-tunneled development environment; client authentication remains key-only and root login remains denied. This +supersedes the per-container-host-key experiment, but does not approve consumption of its quarantined package. The candidate workflow does not move a stable or `latest` tag. The image receipt binds the source revision, source tree, workflow run, platforms, and manifest digest consumed by the template. The current receipt records anonymous diff --git a/script/check-image-receipt.mjs b/script/check-image-receipt.mjs index c7b7203..529ab52 100755 --- a/script/check-image-receipt.mjs +++ b/script/check-image-receipt.mjs @@ -50,19 +50,43 @@ required(["not_yet_observed", "passed"].includes(receipt.publication?.anonymous_ required(["not_yet_observed", "passed"].includes(receipt.publication?.comparison_codespace), "The receipt must name the comparison-Codespace observation state."); const platforms = receipt.verification?.platforms ?? {}; +required(JSON.stringify(Object.keys(platforms["linux/amd64"] ?? {}).sort()) === JSON.stringify([ + "locked_feature_ids_present_once_in_metadata", + "no_command_stays_running", + "official_sshd_feature_starts_key_only_listener", + "pg_dump_major", + "postgresql_client", + "psql_major", +].sort()), "The amd64 verification receipt must contain the exact maintained-image observations."); required(platforms["linux/amd64"]?.locked_feature_ids_present_once_in_metadata === true, "The amd64 locked-Feature-ID metadata check must be retained."); required(platforms["linux/amd64"]?.official_sshd_feature_starts_key_only_listener === true, "The amd64 maintained-SSH lifecycle check must be retained."); required(platforms["linux/amd64"]?.no_command_stays_running === true, "The amd64 default-command runtime check must be retained."); required(/^18\.\d+$/.test(platforms["linux/amd64"]?.postgresql_client ?? ""), "The amd64 PostgreSQL client receipt must retain the observed 18.x release."); required(platforms["linux/amd64"]?.psql_major === 18 && platforms["linux/amd64"]?.pg_dump_major === 18, "The amd64 PostgreSQL client tools must use major 18."); +required(JSON.stringify(Object.keys(platforms["linux/arm64"] ?? {}).sort()) === JSON.stringify([ + "locked_feature_ids_present_once_in_metadata", + "runtime", +].sort()), "The arm64 verification receipt must contain the exact maintained-image observations."); required(platforms["linux/arm64"]?.locked_feature_ids_present_once_in_metadata === true, "The arm64 locked-Feature-ID metadata check must be retained."); required(["not_observed", "passed"].includes(platforms["linux/arm64"]?.runtime), "The receipt must name the arm64 runtime-observation state."); required(/^[0-9a-f]{64}$/.test(receipt.verification?.workflow_log_sha256 ?? ""), "The receipt must bind the exact workflow log."); +required(JSON.stringify(Object.keys(receipt.policy?.ssh ?? {}).sort()) === JSON.stringify([ + "client_authentication", + "image_layer_host_keys", + "lifecycle", + "root_login", +].sort()), "The receipt must bind the exact maintained SSH policy boundary."); +required(receipt.policy.ssh.lifecycle === "official_devcontainers_sshd_feature", "The receipt must retain the maintained SSH lifecycle owner."); +required(receipt.policy.ssh.image_layer_host_keys === "accepted_for_disposable_github_tunneled_development", "The receipt must retain the accepted image-layer host-key boundary."); +required(receipt.policy.ssh.client_authentication === "public_key_only", "The receipt must retain key-only client authentication."); +required(receipt.policy.ssh.root_login === "denied", "The receipt must retain denied SSH root login."); + required(receipt.rejected_predecessor?.required_visibility === "private_forever", "The rejected package must remain permanently private."); const rejectedPackage = receipt.rejected_predecessor?.package; const expectedRejectedPackage = ["ghcr.io/firstdraft", "drawing-board-devcontainer"].join("/"); required(rejectedPackage === expectedRejectedPackage, "The receipt must name the exact rejected package."); +required(receipt.rejected_predecessor?.reason === "Quarantined under the superseded per-container-host-key policy; it remains unapproved for consumption.", "The rejected-package reason must not restate the superseded categorical host-key policy."); const trackedPaths = childProcess.execFileSync("git", ["ls-files", "-z"], { encoding: "utf8" }).split("\0").filter(Boolean); for (const path of trackedPaths) { if (path === receiptPath || !fs.statSync(path).isFile()) continue; diff --git a/script/devcontainer-image-smoke b/script/devcontainer-image-smoke index 84ef6bb..3a64025 100755 --- a/script/devcontainer-image-smoke +++ b/script/devcontainer-image-smoke @@ -149,7 +149,7 @@ socket.on("data", (chunk) => { if (complete) return; complete = true; console.log(banner); - socket.end(); + socket.destroy(); }); NODE @@ -163,5 +163,9 @@ pg_dump_release="$(sed -nE 's/^pg_dump \(PostgreSQL\) (18\.[0-9]+).*$/\1/p' <<<" exit 1 } postgresql_clients+=("${psql_release}") +[[ "$(docker inspect --format '{{.State.Running}}' "${default_container}")" == "true" ]] || { + echo "The development image did not stay running with its default command." >&2 + exit 1 +} -echo "Development image maintained-SSH lifecycle and PostgreSQL ${postgresql_clients[0]} clients passed." +echo "Development image default command, maintained-SSH lifecycle, and PostgreSQL ${postgresql_clients[0]} clients passed." From 85d2035b68b254a2fcbf28be42ad5158422012e9 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sun, 30 Aug 2026 11:09:45 -0500 Subject: [PATCH 5/7] Bind the repaired workspace image Consume the immutable multi-platform image built from the maintained SSH Feature repair and retain the exact hosted, platform, anonymous-access, and SSH-policy receipts before the fresh Codespaces comparison. --- .devcontainer/compose.yaml | 2 +- .devcontainer/image/receipt.json | 46 +++++++++++++++++--------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index fe50ec1..cb270e8 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -1,6 +1,6 @@ services: rails-app: - image: ghcr.io/firstdraft/drawing-board-workspace@sha256:7e19db9fba85a96a157dc7fc3fb7b22138d52a52d29b7c29a8bccf76e67be94f + image: ghcr.io/firstdraft/drawing-board-workspace@sha256:06602be5cc829d5142c12b06c505dbf8353a3ade6751ca4bf01a785ea2c3e6e3 volumes: - ..:/workspaces/drawing-board:cached command: sleep infinity diff --git a/.devcontainer/image/receipt.json b/.devcontainer/image/receipt.json index 9411de3..27f652a 100644 --- a/.devcontainer/image/receipt.json +++ b/.devcontainer/image/receipt.json @@ -2,27 +2,26 @@ "format": "firstdraft.drawing-board-development-image/1", "source": { "repository": "firstdraft/drawing-board", - "commit": "ac9e56438896424e49c4d4d3dd16eda0523fa301", - "tree": "617af7255cb697ef22a85e580316cc7b39fd8248", - "tag": "devcontainer-image-candidate-safe-ac9e564" + "commit": "1488d6337847a0dfd7da9b7c11de895e81160421", + "tree": "4af88c915789dda2fa6d5e366e35f672981a5030", + "tag": "devcontainer-image-candidate-safe-1488d63" }, "inputs": { - ".devcontainer/Dockerfile": "254f6d24cc242edff62b7cb927182600daf287844af252ad31b61e86016a7574", - ".devcontainer/image/devcontainer.json": "45d55afb832a2ff8803da94541ef9c6a9e72b49d028fab807bb7c5f4771b0f8c", - ".devcontainer/image/devcontainer-lock.json": "acf4120fe5260b1579df0285db7c86c7377aed59df8a75f3ea8c3c0e46b45aa1", - ".devcontainer/workspace-init": "2a76af1205092a381c755a78b161e9971538dc07d3c0bd260b785a56c8f2dc6c", - ".github/workflows/devcontainer-image.yml": "f59269fb6bbdf50eb86681ba7a09da5697ed510f2636436e54c639fa0ba9adf6", - "script/devcontainer-image-smoke": "45dfee61bb59c1106ca230741e86d77690f1abe6bf6b653d55e528e16982eda8" + ".devcontainer/Dockerfile": "ff81e7bf2b56191d20d592429d29c1be30e0fabaffd9ae9f3c4f0f52e73cd480", + ".devcontainer/image/devcontainer.json": "5143f0ed66fd97b986b86f8be171516d194796ca3137f0a14d7580e0bb9bbe75", + ".devcontainer/image/devcontainer-lock.json": "9efc5a704e887e1c66679f574cfcb6d5eb9d0ff6b80e3fe3dffd9cbdc7db894b", + ".github/workflows/devcontainer-image.yml": "d442d1a1383fb470e5d3e6fa14f2a29b7b09b5dd33a44cfa93575c7467f806a7", + "script/devcontainer-image-smoke": "272260b7bbdbe61613e43ee3e73df2eaf3941442aaa6dc7a27a848d2da1e2134" }, "publication": { - "workflow_run": 33267389991, - "build_job": 99139776455, - "verify_job": 99141426403, + "workflow_run": 33320822128, + "build_job": 99282366552, + "verify_job": 99284135796, "package": "ghcr.io/firstdraft/drawing-board-workspace", - "manifest": "sha256:7e19db9fba85a96a157dc7fc3fb7b22138d52a52d29b7c29a8bccf76e67be94f", + "manifest": "sha256:06602be5cc829d5142c12b06c505dbf8353a3ade6751ca4bf01a785ea2c3e6e3", "platforms": { - "linux/amd64": "sha256:9325cc36d84884a2d79234c50805bddb4ab05ae33816c3920e6077b7ab20f470", - "linux/arm64": "sha256:58fa619ec460a34b411d0eab1eb790881e833875dc4c88b6324eb3bcfbac505a" + "linux/amd64": "sha256:1c8a08347080623ce27daf13651327142eee2ac163907430614869435db2fdae", + "linux/arm64": "sha256:0d09a17e7618d69bc2fa1c57754cbb0453363a19f061686f517efceee59bde10" }, "visibility": "public", "anonymous_pull": "passed", @@ -31,27 +30,32 @@ "verification": { "platforms": { "linux/amd64": { - "layers_contain_no_ssh_host_keys": true, "locked_feature_ids_present_once_in_metadata": true, - "image_rest_contains_no_ssh_host_keys": true, "no_command_stays_running": true, - "two_started_containers_have_distinct_ed25519_host_keys": true, + "official_sshd_feature_starts_key_only_listener": true, "postgresql_client": "18.6", "psql_major": 18, "pg_dump_major": 18 }, "linux/arm64": { - "layers_contain_no_ssh_host_keys": true, "locked_feature_ids_present_once_in_metadata": true, "runtime": "not_observed" } }, - "workflow_log_sha256": "2774dcd347a32b8313c65969ce28f35eef87a42edd2f248f1ae548c3db2cf447" + "workflow_log_sha256": "8c538bf40f5fa2fc6ead4b4f96afcfb779a0ec429e22c49b74d0df3fb0eb224d" + }, + "policy": { + "ssh": { + "lifecycle": "official_devcontainers_sshd_feature", + "image_layer_host_keys": "accepted_for_disposable_github_tunneled_development", + "client_authentication": "public_key_only", + "root_login": "denied" + } }, "rejected_predecessor": { "package": "ghcr.io/firstdraft/drawing-board-devcontainer", "manifest": "sha256:27f652c012ff5684a034612300bee43d0c12b72f0e63ee6527ad28e9a403ccf5", "required_visibility": "private_forever", - "reason": "Build-time SSH host private keys are recoverable from OCI layers." + "reason": "Quarantined under the superseded per-container-host-key policy; it remains unapproved for consumption." } } From a023afc78a969837b2cf89dd9c21090adf279a6c Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 1 Sep 2026 17:21:47 -0500 Subject: [PATCH 6/7] Repair Codespaces private ports Retain VS Code navigator compatibility for the observed remote\nextension-host failure, but use the proven public-to-private\nregistration refresh for relay 502 responses.\n\nRefuse active listeners and restore private visibility so the\ntransient reset cannot expose an application. --- .devcontainer/devcontainer.json | 8 +- CONTRIBUTING.md | 36 ++++ DIRECT_COMPILATION_PLAN.md | 24 ++- script/check | 6 + script/check-codespaces-private-port.mjs | 205 +++++++++++++++++++++++ script/refresh-codespaces-private-port | 94 +++++++++++ 6 files changed, 370 insertions(+), 3 deletions(-) create mode 100755 script/check-codespaces-private-port.mjs create mode 100755 script/refresh-codespaces-private-port diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4c291d4..174aba3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,10 +26,14 @@ "forwardPorts": [3000, 5432], "customizations": { "vscode": { - "extensions": ["Shopify.ruby-lsp", "bradlc.vscode-tailwindcss"] + "extensions": ["Shopify.ruby-lsp", "bradlc.vscode-tailwindcss"], + "settings": { + "extensions.supportNodeGlobalNavigator": true + } } }, "remoteUser": "vscode", "onCreateCommand": "sudo chown -R vscode:vscode /home/vscode/.claude /home/vscode/.codex /home/vscode/.cache", - "postCreateCommand": ".devcontainer/setup-agents" + "postCreateCommand": ".devcontainer/setup-agents", + "postAttachCommand": "script/refresh-codespaces-private-port" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3437cdf..0c2cc7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ cross-repository sequence and its safety boundaries live in | `script/check-image-receipt.mjs` | Exact source, publication, platform, and rejected-package receipt contract | | `script/devcontainer-image-smoke` | Default command, locked Feature-ID, maintained SSH lifecycle, and PostgreSQL checks | | `script/devcontainer-smoke` | Runtime smoke executed inside the built Dev Container | +| `script/refresh-codespaces-private-port` | Safe post-attach refresh for the private Rails forwarded-port registration | | `script/initialize-application` | Parentless nested Git initialization for direct-download output | | `script/selenium` | On-demand Selenium start, status, and stop inside the Dev Container | | `script/application-smoke` | Setup, PostgreSQL, readiness, and full CI proof for a generated `./application` | @@ -111,6 +112,41 @@ requests browser proof. The comparison Codespace must also prove that `script/selenium` can resolve the Compose project from that runtime's container identity; the current public-image receipt does not claim that observation or a speculative fallback. +The runtime Dev Container opts the remote extension host into Node's supported `navigator` global through +`extensions.supportNodeGlobalNavigator`. A 2026-09-01 browser-Codespaces observation found VS Code 1.133.0 and the +GitHub Codespaces extension 1.18.16 loading Axios and Microsoft Dev Tunnels while VS Code's migration guard still +replaced that global with a throwing getter and raised `PendingMigrationError`. The private forwarded URL then +returned 502 before a healthy Rails server received the request. This is the conventional VS Code migration setting +documented in the +[VS Code 1.101 release notes](https://code.visualstudio.com/updates/v1_101). The exact VS Code 1.133.0 source +[registers it at the default window scope](https://github.com/microsoft/vscode/blob/a5b500951314efd502d07465bd138dfbd714a960/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts#L363-L367), +which accepts remote settings, and the +[remote server turns it into the extension host's `--supportGlobalNavigator` argument](https://github.com/microsoft/vscode/blob/a5b500951314efd502d07465bd138dfbd714a960/src/vs/server/node/extensionHostConnection.ts#L283-L290). +Dev Container settings are applied to the remote Codespaces machine as described by +[GitHub's Dev Container documentation](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers). +A fresh Codespace proved that the setting supplies `--supportGlobalNavigator` and removes the migration error, but +the unchanged private forwarded URL still returned a relay-level 502. The setting remains because it closes that +independently observed extension-host failure; it is not the tunnel repair. + +The repository's long-running student Rails template supplied the missing control: at exact revision +[`7bfb0c17`](https://github.com/appdev-projects/rails-8-template/blob/7bfb0c173b13203dbbae612ea410b893d041d240/bin/fix-ports#L1-L9), +its post-attach hook changes port 3000 from public back to private specifically to repair Codespaces 502 responses. +Repeating that transition once in the fresh Drawing Board Codespace changed the unchanged request from relay 502 +with no Rails log to Rails 403 with an exact `Blocked hosts` log. `script/refresh-codespaces-private-port` performs the same +registration refresh on every Codespaces attach, but only while port 3000 has no listener. It reports every GitHub +CLI operation, restores and verifies private visibility, and fails +instead of exposing an active application or hiding an error. GitHub documents +[`CODESPACES` and `CODESPACE_NAME`](https://docs.github.com/en/codespaces/developing-in-a-codespace/default-environment-variables-for-your-codespace) +as the runtime discriminator and +[private as the default forwarded-port visibility](https://docs.github.com/en/codespaces/developing-in-a-codespace/forwarding-ports-in-your-codespace); +the current CLI's visibility command is the supported control surface. This is a containment for an observed +provider registration defect, not a custom tunnel or application workaround. + +The repaired tunnel exposed the already-recorded generated Rails HostAuthorization boundary. Do not copy the +student template's broad `config.hosts.clear` or disabled origin check into Drawing Board. Generated-app host and +Origin handling remain target-owned follow-up work and require their own exact browser GET and state-changing POST +proof. + ## Credentials and external systems Never commit a First Draft API token, GitHub token, agent credential, or generated `.env`. `script/check` scans the diff --git a/DIRECT_COMPILATION_PLAN.md b/DIRECT_COMPILATION_PLAN.md index ee0543a..abcabbe 100644 --- a/DIRECT_COMPILATION_PLAN.md +++ b/DIRECT_COMPILATION_PLAN.md @@ -208,6 +208,26 @@ in-Codespace conversational authoring pass, boot the browser process through ste step 7's retained-context explanation and bounded source change. Those remain explicit acceptance work rather than being inferred from installation, `bin/rails server`, or Compilation success. +### Observed successor tunnel blocker on 2026-09-01 + +The successor attempt in Codespace `fd-direct-025-85d2035-gggqjg9r42vvv4` reached a distinct provider-side blocker. +VS Code 1.133.0 (`a5b500951314efd502d07465bd138dfbd714a960`) launched its Node 24.18.0 remote extension host without +`--supportGlobalNavigator`; GitHub Codespaces extension 1.18.16 then raised `PendingMigrationError` while loading +Axios and Microsoft Dev Tunnels. Puma was healthy on `0.0.0.0:3000`, local GET returned 200, and the Ports view +resolved the exact process and private URL, but both an authenticated browser request and an official +`X-Github-Token` request returned 502 before Rails received them. + +The runtime Dev Container now carries VS Code's documented `extensions.supportNodeGlobalNavigator` migration +setting. A fresh immutable-ref Codespace proved the extension-host flag and absence of the migration error, but its +private URL still returned relay 502 before Rails. In Codespace `fd-nav-ca8165f-0901-www7pwj4v25446`, pre-reset +request `3a23a4e6-92e0-4475-a345-34ff755a5f7a` reproduced that boundary with local HTTP 200. A controlled comparison +then applied the existing student Rails template's exact public-to-private visibility reset. Post-reset request +`09174d73-2baa-480d-a41c-bb6e42c4b2fb` immediately reached the unchanged Rails process and returned the separately +expected `Blocked hosts` response. Drawing Board now runs a guarded, diagnostic version of that reset on Codespaces +attach only while port 3000 has no listener, and restores and verifies private visibility. This tunnel containment +does not retire the 2026-08-28 generated Rails host-admission finding or prove a state-changing POST; those remain +target-owned correction and proof. + ### Successor qualification still required The CLI 0.2.2/current-Skills/public-image candidate needs one fresh template-derived Codespace observation before @@ -218,7 +238,9 @@ it becomes the current end-to-end receipt. The run should retain: 3. one signed-in continuing agent, the approved Neighborhood Guide Plan, and one task-scoped staging token; 4. one approved `--output ./application` Compile with no Publication or Service-created GitHub repository; 5. the nested parentless initialization and complete generated-application smoke; -6. the generated app's own `bin/dev` through the ordinary GitHub forwarded URL, without a local host or CSRF patch; +6. the generated app's own `bin/dev` through the ordinary private GitHub forwarded URL, with the remote extension + host using `--supportGlobalNavigator`, no `PendingMigrationError`, the post-attach port refresh ending private, + and both GET and state-changing POST reaching Rails through the target-owned narrow host/Origin handling; and 7. the same agent explaining one retained Plan decision, making one bounded source change that follows it, and passing the focused generated-app check. diff --git a/script/check b/script/check index 96ace10..ab06f4d 100755 --- a/script/check +++ b/script/check @@ -138,9 +138,13 @@ required(configuration.service === "rails-app", "The Dev Container must attach t required(JSON.stringify(configuration.runServices) === JSON.stringify(["rails-app", "postgres"]), "Only the workspace and PostgreSQL may start by default."); required(configuration.workspaceFolder === "/workspaces/drawing-board", "The workspace path must stay stable."); required(configuration.remoteUser === "vscode", "The Rails runtime uses the non-root vscode user."); +required(JSON.stringify(configuration.customizations?.vscode?.settings) === + JSON.stringify({ "extensions.supportNodeGlobalNavigator": true }), + "The remote extension host settings must contain only the reviewed navigator migration setting."); required(configuration.containerEnv?.DB_HOST === "postgres", "Generated Foundations must reach PostgreSQL by service name."); required(configuration.containerEnv?.SELENIUM_HOST === "selenium", "Generated system tests must reach Selenium by service name."); required(JSON.stringify(configuration.forwardPorts) === JSON.stringify([3000, 5432]), "Rails and PostgreSQL ports must be forwarded."); +required(configuration.postAttachCommand === "script/refresh-codespaces-private-port", "Codespaces must refresh the private Rails port on attach."); required(compose.includes("- ..:/workspaces/drawing-board:cached"), "Compose must mount the complete Drawing Board."); required(compose.includes(`${imageReceipt.publication.package}@${imageReceipt.publication.manifest}`), "The workspace image must match its immutable receipt."); required(imageReceipt.publication.visibility === "public", "The workspace image must retain its observed public visibility."); @@ -178,6 +182,8 @@ required(ciWorkflow.includes("script/check-depth-one"), "CI must exercise the re required(ciWorkflow.includes("script/devcontainer-smoke && script/devcontainer-smoke"), "CI must prove the Dev Container smoke is repeatable."); NODE +node script/check-codespaces-private-port.mjs + if ! git check-ignore --quiet application/example; then echo "Generated application output must remain outside the Drawing Board Git repository." >&2 exit 1 diff --git a/script/check-codespaces-private-port.mjs b/script/check-codespaces-private-port.mjs new file mode 100755 index 0000000..1486e8a --- /dev/null +++ b/script/check-codespaces-private-port.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import {spawnSync} from "node:child_process"; +import {fileURLToPath} from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const refresher = path.join(repositoryRoot, "script", "refresh-codespaces-private-port"); +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "drawing-board-port-refresh-test-")); +const mockBin = path.join(temporaryRoot, "bin"); +const statePath = path.join(temporaryRoot, "visibility"); +const logPath = path.join(temporaryRoot, "gh.log"); +const privateAttemptsPath = path.join(temporaryRoot, "private-attempts"); + +function writeExecutable(name, contents) { + const destination = path.join(mockBin, name); + fs.writeFileSync(destination, contents, {mode: 0o755}); + fs.chmodSync(destination, 0o755); +} + +function run(changes = {}, pathValue = `${mockBin}:/usr/bin:/bin`) { + return spawnSync(refresher, [], { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: pathValue, + PORT_REFRESH_LOG: logPath, + PORT_REFRESH_STATE: statePath, + PORT_REFRESH_PRIVATE_ATTEMPTS: privateAttemptsPath, + CODESPACES: "true", + CODESPACE_NAME: "drawing-board-test", + ...changes, + }, + }); +} + +function logLines() { + if (!fs.existsSync(logPath)) return []; + return fs.readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); +} + +try { + fs.mkdirSync(mockBin); + writeExecutable( + "gh", + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >>"$PORT_REFRESH_LOG" +if [[ "$1 $2" == "codespace ports" && "\${3:-}" != "visibility" ]]; then + state="$(<"$PORT_REFRESH_STATE")" + [[ "$state" == "missing" ]] || printf '%s\\n' "$state" + exit 0 +fi +if [[ "$1 $2 $3" == "codespace ports visibility" ]]; then + visibility="\${4#*:}" + if [[ "$visibility" == "public" && "\${MOCK_PUBLIC_FAILURE:-false}" == "true" ]]; then + exit 42 + fi + if [[ "$visibility" == "private" && "\${MOCK_PRIVATE_FAILURES:-0}" != "0" ]]; then + attempts=0 + [[ ! -f "$PORT_REFRESH_PRIVATE_ATTEMPTS" ]] || attempts="$(<"$PORT_REFRESH_PRIVATE_ATTEMPTS")" + attempts="$((attempts + 1))" + printf '%s' "$attempts" >"$PORT_REFRESH_PRIVATE_ATTEMPTS" + if (( attempts <= MOCK_PRIVATE_FAILURES )); then + exit 43 + fi + fi + printf '%s' "$visibility" >"$PORT_REFRESH_STATE" + exit 0 +fi +exit 64 +`, + ); + writeExecutable( + "ss", + `#!/usr/bin/env bash +case "\${MOCK_LISTENER:-false}" in + true) printf '%s\\n' 'LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:*'; exit 0 ;; + false) exit 0 ;; + error) exit 2 ;; +esac +`, + ); + + fs.writeFileSync(statePath, "private"); + const first = run(); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /Refreshing the unbound Codespaces port 3000 registration/); + assert.match(first.stdout, /private visibility confirmed/); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.deepEqual(logLines(), [ + "codespace ports --codespace drawing-board-test --json sourcePort,visibility --jq .[] | select(.sourcePort == 3000) | .visibility", + "codespace ports visibility 3000:public --codespace drawing-board-test", + "codespace ports visibility 3000:private --codespace drawing-board-test", + "codespace ports --codespace drawing-board-test --json sourcePort,visibility --jq .[] | select(.sourcePort == 3000) | .visibility", + ]); + + fs.writeFileSync(logPath, ""); + const repeated = run(); + assert.equal(repeated.status, 0, repeated.stderr); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.equal(logLines().filter((line) => line.includes(" visibility ")).length, 2); + + fs.writeFileSync(logPath, ""); + const listener = run({MOCK_LISTENER: "true"}); + assert.notEqual(listener.status, 0); + assert.match(listener.stderr, /Refusing to re-register port 3000 while a listener is active/); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.equal(logLines().some((line) => line.includes(" visibility ")), false); + + fs.writeFileSync(logPath, ""); + const listenerProbeError = run({MOCK_LISTENER: "error"}); + assert.notEqual(listenerProbeError.status, 0); + assert.match(listenerProbeError.stderr, /Could not determine whether port 3000 has a listener/); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.equal(logLines().some((line) => line.includes(" visibility ")), false); + + fs.writeFileSync(statePath, "public"); + fs.writeFileSync(logPath, ""); + const exposedListener = run({MOCK_LISTENER: "true"}); + assert.notEqual(exposedListener.status, 0); + assert.match(exposedListener.stderr, /Restoring forwarded port 3000 from public to private/); + assert.match(exposedListener.stderr, /Refusing to re-register port 3000 while a listener is active/); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.equal(logLines().filter((line) => line.includes(" visibility ")).length, 1); + + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + const failedPublic = run({MOCK_PUBLIC_FAILURE: "true"}); + assert.notEqual(failedPublic.status, 0); + assert.match(failedPublic.stderr, /Port refresh was interrupted; restoring private visibility/); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.deepEqual(logLines().filter((line) => line.includes(" visibility ")), [ + "codespace ports visibility 3000:public --codespace drawing-board-test", + "codespace ports visibility 3000:private --codespace drawing-board-test", + ]); + + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + fs.rmSync(privateAttemptsPath, {force: true}); + const transientPrivateFailure = run({MOCK_PRIVATE_FAILURES: "1"}); + assert.notEqual(transientPrivateFailure.status, 0); + assert.match(transientPrivateFailure.stderr, /Port refresh was interrupted; restoring private visibility/); + assert.doesNotMatch(transientPrivateFailure.stderr, /URGENT/); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + assert.equal(logLines().filter((line) => line.includes("visibility 3000:private")).length, 2); + + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + fs.rmSync(privateAttemptsPath, {force: true}); + const permanentPrivateFailure = run({MOCK_PRIVATE_FAILURES: "2"}); + assert.notEqual(permanentPrivateFailure.status, 0); + assert.match(permanentPrivateFailure.stderr, /URGENT: Codespaces did not restore private visibility/); + assert.equal(fs.readFileSync(statePath, "utf8"), "public"); + + fs.writeFileSync(statePath, "missing"); + fs.writeFileSync(logPath, ""); + const missing = run(); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /did not register forwarded port 3000/); + assert.equal(logLines().some((line) => line.includes(" visibility ")), false); + + fs.writeFileSync(logPath, ""); + const outside = run({CODESPACES: "false", CODESPACE_NAME: ""}); + assert.equal(outside.status, 0, outside.stderr); + assert.match(outside.stdout, /skipped outside GitHub Codespaces/); + assert.deepEqual(logLines(), []); + + const missingName = run({CODESPACE_NAME: ""}); + assert.notEqual(missingName.status, 0); + assert.match(missingName.stderr, /CODESPACE_NAME is required/); + + const realSs = spawnSync("ss", ["--version"], {encoding: "utf8"}); + if (realSs.status === 0) { + const realBin = path.join(temporaryRoot, "real-bin"); + fs.mkdirSync(realBin); + fs.copyFileSync(path.join(mockBin, "gh"), path.join(realBin, "gh")); + fs.chmodSync(path.join(realBin, "gh"), 0o755); + const realPath = `${realBin}:${process.env.PATH}`; + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(3000, "127.0.0.1", resolve); + }); + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + const realListener = run({}, realPath); + assert.notEqual(realListener.status, 0); + assert.match(realListener.stderr, /Refusing to re-register port 3000 while a listener is active/); + assert.equal(logLines().some((line) => line.includes(" visibility ")), false); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + + fs.writeFileSync(logPath, ""); + const realNoListener = run({}, realPath); + assert.equal(realNoListener.status, 0, realNoListener.stderr); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + } +} finally { + fs.rmSync(temporaryRoot, {recursive: true, force: true}); +} diff --git a/script/refresh-codespaces-private-port b/script/refresh-codespaces-private-port new file mode 100755 index 0000000..654e5c2 --- /dev/null +++ b/script/refresh-codespaces-private-port @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +port=3000 + +if [[ "${CODESPACES:-}" != "true" ]]; then + echo "Codespaces port refresh skipped outside GitHub Codespaces." + exit 0 +fi + +if [[ -z "${CODESPACE_NAME:-}" ]]; then + echo "CODESPACE_NAME is required when CODESPACES=true." >&2 + exit 1 +fi + +for command in gh ss; do + if ! command -v "${command}" >/dev/null 2>&1; then + echo "Codespaces port refresh requires ${command} on PATH." >&2 + exit 1 + fi +done + +port_visibility() { + local visibility + visibility="$( + gh codespace ports \ + --codespace "${CODESPACE_NAME}" \ + --json sourcePort,visibility \ + --jq ".[] | select(.sourcePort == ${port}) | .visibility" + )" + + if [[ -z "${visibility}" ]]; then + echo "Codespaces did not register forwarded port ${port}; inspect the Ports panel and Dev Container logs." >&2 + return 1 + fi + if [[ "${visibility}" == *$'\n'* ]]; then + echo "Codespaces reported more than one forwarded-port record for ${port}." >&2 + return 1 + fi + + printf '%s' "${visibility}" +} + +set_visibility() { + local visibility="$1" + gh codespace ports visibility "${port}:${visibility}" --codespace "${CODESPACE_NAME}" +} + +public_transition_started=0 +restore_private_on_exit() { + local status="$?" + if [[ "${public_transition_started}" == "1" ]]; then + echo "Port refresh was interrupted; restoring private visibility for port ${port}." >&2 + if ! set_visibility private; then + echo "URGENT: Codespaces did not restore private visibility for port ${port}; change it to Private in the Ports panel." >&2 + fi + fi + return "${status}" +} +trap restore_private_on_exit EXIT + +current_visibility="$(port_visibility)" +if [[ "${current_visibility}" != "private" ]]; then + echo "Restoring forwarded port ${port} from ${current_visibility} to private before refresh." >&2 + public_transition_started=1 + set_visibility private + if [[ "$(port_visibility)" != "private" ]]; then + echo "Codespaces did not confirm private visibility for port ${port}; stopping before refresh." >&2 + exit 1 + fi + public_transition_started=0 +fi + +if ! listener_rows="$(ss -H -ltn "sport = :${port}")"; then + echo "Could not determine whether port ${port} has a listener; stopping before any visibility change." >&2 + exit 1 +fi +if [[ -n "${listener_rows}" ]]; then + echo "Refusing to re-register port ${port} while a listener is active; stop the server and rerun script/refresh-codespaces-private-port." >&2 + exit 1 +fi + +echo "Refreshing the unbound Codespaces port ${port} registration; final visibility will remain private." +public_transition_started=1 +set_visibility public +set_visibility private +if [[ "$(port_visibility)" != "private" ]]; then + echo "Codespaces did not confirm restored private visibility for port ${port}." >&2 + exit 1 +fi + +public_transition_started=0 +trap - EXIT +echo "Codespaces port ${port} registration refreshed; private visibility confirmed." From d48495b8943b893a0f76bac95d6abab1fb75daca Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 1 Sep 2026 18:03:31 -0500 Subject: [PATCH 7/7] Qualify Codespaces port recovery Accept only the observed no-listener registration removal and retrieve the scoped session token without exposing it. --- CONTRIBUTING.md | 12 +- DIRECT_COMPILATION_PLAN.md | 4 +- script/check-codespaces-private-port.mjs | 89 ++++++++++++- script/refresh-codespaces-private-port | 161 ++++++++++++++++++++--- 4 files changed, 234 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c2cc7d..d33e4a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,10 +132,14 @@ The repository's long-running student Rails template supplied the missing contro [`7bfb0c17`](https://github.com/appdev-projects/rails-8-template/blob/7bfb0c173b13203dbbae612ea410b893d041d240/bin/fix-ports#L1-L9), its post-attach hook changes port 3000 from public back to private specifically to repair Codespaces 502 responses. Repeating that transition once in the fresh Drawing Board Codespace changed the unchanged request from relay 502 -with no Rails log to Rails 403 with an exact `Blocked hosts` log. `script/refresh-codespaces-private-port` performs the same -registration refresh on every Codespaces attach, but only while port 3000 has no listener. It reports every GitHub -CLI operation, restores and verifies private visibility, and fails -instead of exposing an active application or hiding an error. GitHub documents +with no Rails log to Rails 403 with an exact `Blocked hosts` log. `script/refresh-codespaces-private-port` performs +the same registration refresh on every Codespaces attach, but only while port 3000 has no listener. Codespaces can +remove that unbound registration between the public and private commands; the script accepts only that exact +no-listener result, after which the next server started in the integrated terminal is forwarded privately by +default. It reports every other GitHub CLI error and fails instead of exposing an active application or hiding an +unexpected result. Lifecycle commands obtain the Codespace name and session-scoped `GITHUB_TOKEN` from Codespaces' +protected shared environment when they have not yet been exported into their process; they never print or persist +either value. GitHub documents [`CODESPACES` and `CODESPACE_NAME`](https://docs.github.com/en/codespaces/developing-in-a-codespace/default-environment-variables-for-your-codespace) as the runtime discriminator and [private as the default forwarded-port visibility](https://docs.github.com/en/codespaces/developing-in-a-codespace/forwarding-ports-in-your-codespace); diff --git a/DIRECT_COMPILATION_PLAN.md b/DIRECT_COMPILATION_PLAN.md index abcabbe..b4f4acc 100644 --- a/DIRECT_COMPILATION_PLAN.md +++ b/DIRECT_COMPILATION_PLAN.md @@ -224,7 +224,9 @@ request `3a23a4e6-92e0-4475-a345-34ff755a5f7a` reproduced that boundary with loc then applied the existing student Rails template's exact public-to-private visibility reset. Post-reset request `09174d73-2baa-480d-a41c-bb6e42c4b2fb` immediately reached the unchanged Rails process and returned the separately expected `Blocked hosts` response. Drawing Board now runs a guarded, diagnostic version of that reset on Codespaces -attach only while port 3000 has no listener, and restores and verifies private visibility. This tunnel containment +attach only while port 3000 has no listener. If Codespaces removes the unbound registration between the visibility +commands, the next integrated-terminal listener creates a fresh private registration; every other incomplete or +exposed result fails. This tunnel containment does not retire the 2026-08-28 generated Rails host-admission finding or prove a state-changing POST; those remain target-owned correction and proof. diff --git a/script/check-codespaces-private-port.mjs b/script/check-codespaces-private-port.mjs index 1486e8a..7dddc3f 100755 --- a/script/check-codespaces-private-port.mjs +++ b/script/check-codespaces-private-port.mjs @@ -15,6 +15,10 @@ const mockBin = path.join(temporaryRoot, "bin"); const statePath = path.join(temporaryRoot, "visibility"); const logPath = path.join(temporaryRoot, "gh.log"); const privateAttemptsPath = path.join(temporaryRoot, "private-attempts"); +const listenerAttemptsPath = path.join(temporaryRoot, "listener-attempts"); +const codespacesEnvPath = path.join(temporaryRoot, "codespaces.env"); +const malformedEnvPath = path.join(temporaryRoot, "malformed.env"); +const missingNameEnvPath = path.join(temporaryRoot, "missing-name.env"); function writeExecutable(name, contents) { const destination = path.join(mockBin, name); @@ -32,8 +36,12 @@ function run(changes = {}, pathValue = `${mockBin}:/usr/bin:/bin`) { PORT_REFRESH_LOG: logPath, PORT_REFRESH_STATE: statePath, PORT_REFRESH_PRIVATE_ATTEMPTS: privateAttemptsPath, + PORT_REFRESH_LISTENER_ATTEMPTS: listenerAttemptsPath, CODESPACES: "true", CODESPACE_NAME: "drawing-board-test", + CODESPACES_ENV_FILE: codespacesEnvPath, + GH_TOKEN: "", + GITHUB_TOKEN: "", ...changes, }, }); @@ -51,6 +59,9 @@ try { `#!/usr/bin/env bash set -euo pipefail printf '%s\\n' "$*" >>"$PORT_REFRESH_LOG" +if [[ "\${MOCK_REQUIRE_GH_TOKEN:-false}" == "true" && "\${GH_TOKEN:-}" != "ghu_drawing_board_test" ]]; then + exit 41 +fi if [[ "$1 $2" == "codespace ports" && "\${3:-}" != "visibility" ]]; then state="$(<"$PORT_REFRESH_STATE")" [[ "$state" == "missing" ]] || printf '%s\\n' "$state" @@ -61,6 +72,13 @@ if [[ "$1 $2 $3" == "codespace ports visibility" ]]; then if [[ "$visibility" == "public" && "\${MOCK_PUBLIC_FAILURE:-false}" == "true" ]]; then exit 42 fi + if [[ "$visibility" == "public" && "\${MOCK_PUBLIC_REMOVES_PORT:-false}" == "true" ]]; then + printf '%s' missing >"$PORT_REFRESH_STATE" + exit 0 + fi + if [[ "$visibility" == "private" && "$(<"$PORT_REFRESH_STATE")" == "missing" ]]; then + exit 44 + fi if [[ "$visibility" == "private" && "\${MOCK_PRIVATE_FAILURES:-0}" != "0" ]]; then attempts=0 [[ ! -f "$PORT_REFRESH_PRIVATE_ATTEMPTS" ]] || attempts="$(<"$PORT_REFRESH_PRIVATE_ATTEMPTS")" @@ -79,6 +97,15 @@ exit 64 writeExecutable( "ss", `#!/usr/bin/env bash +if [[ -n "\${MOCK_LISTENER_ERROR_AFTER:-}" ]]; then + attempts=0 + [[ ! -f "$PORT_REFRESH_LISTENER_ATTEMPTS" ]] || attempts="$(<"$PORT_REFRESH_LISTENER_ATTEMPTS")" + attempts="$((attempts + 1))" + printf '%s' "$attempts" >"$PORT_REFRESH_LISTENER_ATTEMPTS" + if (( attempts > MOCK_LISTENER_ERROR_AFTER )); then + exit 2 + fi +fi case "\${MOCK_LISTENER:-false}" in true) printf '%s\\n' 'LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:*'; exit 0 ;; false) exit 0 ;; @@ -86,6 +113,7 @@ case "\${MOCK_LISTENER:-false}" in esac `, ); + fs.writeFileSync(codespacesEnvPath, "CODESPACE_NAME=drawing-board-test\nCODESPACE_NAME=drawing-board-test\nGITHUB_TOKEN=ghu_drawing_board_test\n", {mode: 0o600}); fs.writeFileSync(statePath, "private"); const first = run(); @@ -100,6 +128,38 @@ esac "codespace ports --codespace drawing-board-test --json sourcePort,visibility --jq .[] | select(.sourcePort == 3000) | .visibility", ]); + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + const protectedEnvironmentToken = run({MOCK_REQUIRE_GH_TOKEN: "true"}); + assert.equal(protectedEnvironmentToken.status, 0, protectedEnvironmentToken.stderr); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + const protectedEnvironmentName = run({CODESPACE_NAME: "", MOCK_REQUIRE_GH_TOKEN: "true"}); + assert.equal(protectedEnvironmentName.status, 0, protectedEnvironmentName.stderr); + assert.equal(fs.readFileSync(statePath, "utf8"), "private"); + + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + const unboundPortRemoval = run({MOCK_PUBLIC_REMOVES_PORT: "true"}); + assert.equal(unboundPortRemoval.status, 0, unboundPortRemoval.stderr); + assert.match(unboundPortRemoval.stdout, /cleared the unbound port 3000 registration/); + assert.equal(fs.readFileSync(statePath, "utf8"), "missing"); + assert.equal(logLines().filter((line) => line.includes(" visibility ")).length, 2); + + fs.writeFileSync(statePath, "private"); + fs.writeFileSync(logPath, ""); + fs.rmSync(listenerAttemptsPath, {force: true}); + const unboundPortUnknownListener = run({MOCK_PUBLIC_REMOVES_PORT: "true", MOCK_LISTENER_ERROR_AFTER: "1"}); + assert.notEqual(unboundPortUnknownListener.status, 0); + assert.match(unboundPortUnknownListener.stderr, /Could not determine whether port 3000 has a listener/); + assert.match(unboundPortUnknownListener.stderr, /Could not verify the no-listener condition/); + assert.doesNotMatch(unboundPortUnknownListener.stderr, /URGENT/); + assert.doesNotMatch(unboundPortUnknownListener.stdout, /cleared the unbound port/); + assert.equal(fs.readFileSync(statePath, "utf8"), "missing"); + + fs.writeFileSync(statePath, "private"); fs.writeFileSync(logPath, ""); const repeated = run(); assert.equal(repeated.status, 0, repeated.stderr); @@ -133,11 +193,10 @@ esac fs.writeFileSync(logPath, ""); const failedPublic = run({MOCK_PUBLIC_FAILURE: "true"}); assert.notEqual(failedPublic.status, 0); - assert.match(failedPublic.stderr, /Port refresh was interrupted; restoring private visibility/); + assert.doesNotMatch(failedPublic.stderr, /URGENT/); assert.equal(fs.readFileSync(statePath, "utf8"), "private"); assert.deepEqual(logLines().filter((line) => line.includes(" visibility ")), [ "codespace ports visibility 3000:public --codespace drawing-board-test", - "codespace ports visibility 3000:private --codespace drawing-board-test", ]); fs.writeFileSync(statePath, "private"); @@ -161,19 +220,37 @@ esac fs.writeFileSync(statePath, "missing"); fs.writeFileSync(logPath, ""); const missing = run(); - assert.notEqual(missing.status, 0); - assert.match(missing.stderr, /did not register forwarded port 3000/); + assert.equal(missing.status, 0, missing.stderr); + assert.match(missing.stdout, /has no stale registration/); assert.equal(logLines().some((line) => line.includes(" visibility ")), false); + const missingWithListener = run({MOCK_LISTENER: "true"}); + assert.notEqual(missingWithListener.status, 0); + assert.match(missingWithListener.stderr, /active listener but no forwarded-port registration/); + + const missingWithUnknownListener = run({MOCK_LISTENER: "error"}); + assert.notEqual(missingWithUnknownListener.status, 0); + assert.match(missingWithUnknownListener.stderr, /Could not determine whether port 3000 has a listener/); + + const missingTokenSource = run({CODESPACES_ENV_FILE: path.join(temporaryRoot, "missing.env")}); + assert.notEqual(missingTokenSource.status, 0); + assert.match(missingTokenSource.stderr, /did not export GITHUB_TOKEN/); + + fs.writeFileSync(malformedEnvPath, "not-a-codespaces-environment\n"); + const malformedTokenSource = run({CODESPACES_ENV_FILE: malformedEnvPath}); + assert.notEqual(malformedTokenSource.status, 0); + assert.match(malformedTokenSource.stderr, /did not provide one usable GITHUB_TOKEN/); + fs.writeFileSync(logPath, ""); const outside = run({CODESPACES: "false", CODESPACE_NAME: ""}); assert.equal(outside.status, 0, outside.stderr); assert.match(outside.stdout, /skipped outside GitHub Codespaces/); assert.deepEqual(logLines(), []); - const missingName = run({CODESPACE_NAME: ""}); + fs.writeFileSync(missingNameEnvPath, "GITHUB_TOKEN=ghu_drawing_board_test\n"); + const missingName = run({CODESPACE_NAME: "", CODESPACES_ENV_FILE: missingNameEnvPath}); assert.notEqual(missingName.status, 0); - assert.match(missingName.stderr, /CODESPACE_NAME is required/); + assert.match(missingName.stderr, /did not provide CODESPACE_NAME/); const realSs = spawnSync("ss", ["--version"], {encoding: "utf8"}); if (realSs.status === 0) { diff --git a/script/refresh-codespaces-private-port b/script/refresh-codespaces-private-port index 654e5c2..1bd499e 100755 --- a/script/refresh-codespaces-private-port +++ b/script/refresh-codespaces-private-port @@ -2,17 +2,14 @@ set -Eeuo pipefail port=3000 +codespaces_env_file="${CODESPACES_ENV_FILE:-/workspaces/.codespaces/shared/.env}" +codespace_name="${CODESPACE_NAME:-}" if [[ "${CODESPACES:-}" != "true" ]]; then echo "Codespaces port refresh skipped outside GitHub Codespaces." exit 0 fi -if [[ -z "${CODESPACE_NAME:-}" ]]; then - echo "CODESPACE_NAME is required when CODESPACES=true." >&2 - exit 1 -fi - for command in gh ss; do if ! command -v "${command}" >/dev/null 2>&1; then echo "Codespaces port refresh requires ${command} on PATH." >&2 @@ -20,18 +17,70 @@ for command in gh ss; do fi done -port_visibility() { +if [[ -z "${codespace_name}" ]]; then + if [[ ! -r "${codespaces_env_file}" ]]; then + echo "Codespaces did not export CODESPACE_NAME and ${codespaces_env_file} is not readable." >&2 + exit 1 + fi + + while IFS= read -r line; do + if [[ "${line}" == CODESPACE_NAME=* ]]; then + candidate_name="${line#CODESPACE_NAME=}" + if [[ -z "${candidate_name}" || ( -n "${codespace_name}" && "${codespace_name}" != "${candidate_name}" ) ]]; then + echo "Codespaces did not provide one consistent CODESPACE_NAME in ${codespaces_env_file}." >&2 + exit 1 + fi + codespace_name="${candidate_name}" + fi + done <"${codespaces_env_file}" + + if [[ -z "${codespace_name}" ]]; then + echo "Codespaces did not provide CODESPACE_NAME in ${codespaces_env_file}." >&2 + exit 1 + fi +fi + +github_cli() { + if [[ -n "${GH_TOKEN:-}" || -n "${GITHUB_TOKEN:-}" ]]; then + gh "$@" + return + fi + + if [[ ! -r "${codespaces_env_file}" ]]; then + echo "Codespaces did not export GITHUB_TOKEN and ${codespaces_env_file} is not readable." >&2 + return 1 + fi + + local line token="" token_count=0 + while IFS= read -r line; do + if [[ "${line}" == GITHUB_TOKEN=* ]]; then + token="${line#GITHUB_TOKEN=}" + token_count="$((token_count + 1))" + fi + done <"${codespaces_env_file}" + + if [[ "${token_count}" != "1" || "${token}" != ghu_* ]]; then + echo "Codespaces did not provide one usable GITHUB_TOKEN in ${codespaces_env_file}." >&2 + return 1 + fi + + GH_TOKEN="${token}" gh "$@" +} + +port_state() { local visibility - visibility="$( - gh codespace ports \ - --codespace "${CODESPACE_NAME}" \ + if ! visibility="$( + github_cli codespace ports \ + --codespace "${codespace_name}" \ --json sourcePort,visibility \ --jq ".[] | select(.sourcePort == ${port}) | .visibility" - )" + )"; then + return 1 + fi if [[ -z "${visibility}" ]]; then - echo "Codespaces did not register forwarded port ${port}; inspect the Ports panel and Dev Container logs." >&2 - return 1 + printf 'absent' + return fi if [[ "${visibility}" == *$'\n'* ]]; then echo "Codespaces reported more than one forwarded-port record for ${port}." >&2 @@ -43,15 +92,47 @@ port_visibility() { set_visibility() { local visibility="$1" - gh codespace ports visibility "${port}:${visibility}" --codespace "${CODESPACE_NAME}" + github_cli codespace ports visibility "${port}:${visibility}" --codespace "${codespace_name}" +} + +listener_state_result="unknown" +read_listener_state() { + local rows + listener_state_result="unknown" + if ! rows="$(ss -H -ltn "sport = :${port}")"; then + echo "Could not determine whether port ${port} has a listener." >&2 + return 1 + fi + + if [[ -n "${rows}" ]]; then + listener_state_result="active" + else + listener_state_result="absent" + fi } public_transition_started=0 +port_registration_removed=0 restore_private_on_exit() { local status="$?" if [[ "${public_transition_started}" == "1" ]]; then + if [[ "${port_registration_removed}" == "1" ]]; then + echo "Port refresh left no forwarded port; the next terminal listener will be forwarded privately." >&2 + return "${status}" + fi + + local state="unknown" + state="$(port_state 2>/dev/null || printf 'unknown')" + if [[ "${state}" == "private" ]]; then + return "${status}" + fi + if [[ "${state}" == "absent" ]]; then + echo "Port refresh left no forwarded port; the next terminal listener will be forwarded privately." >&2 + return "${status}" + fi + echo "Port refresh was interrupted; restoring private visibility for port ${port}." >&2 - if ! set_visibility private; then + if ! set_visibility private || [[ "$(port_state 2>/dev/null || printf 'unknown')" != "private" ]]; then echo "URGENT: Codespaces did not restore private visibility for port ${port}; change it to Private in the Ports panel." >&2 fi fi @@ -59,23 +140,35 @@ restore_private_on_exit() { } trap restore_private_on_exit EXIT -current_visibility="$(port_visibility)" +current_visibility="$(port_state)" +if [[ "${current_visibility}" == "absent" ]]; then + if ! read_listener_state; then + exit 1 + fi + current_listener_state="${listener_state_result}" + if [[ "${current_listener_state}" != "absent" ]]; then + echo "Port ${port} has an active listener but no forwarded-port registration; stop the server before refreshing it." >&2 + exit 1 + fi + echo "Codespaces port ${port} has no stale registration; its next terminal listener will be forwarded privately." + exit 0 +fi if [[ "${current_visibility}" != "private" ]]; then echo "Restoring forwarded port ${port} from ${current_visibility} to private before refresh." >&2 public_transition_started=1 set_visibility private - if [[ "$(port_visibility)" != "private" ]]; then + if [[ "$(port_state)" != "private" ]]; then echo "Codespaces did not confirm private visibility for port ${port}; stopping before refresh." >&2 exit 1 fi public_transition_started=0 fi -if ! listener_rows="$(ss -H -ltn "sport = :${port}")"; then - echo "Could not determine whether port ${port} has a listener; stopping before any visibility change." >&2 +if ! read_listener_state; then exit 1 fi -if [[ -n "${listener_rows}" ]]; then +current_listener_state="${listener_state_result}" +if [[ "${current_listener_state}" == "active" ]]; then echo "Refusing to re-register port ${port} while a listener is active; stop the server and rerun script/refresh-codespaces-private-port." >&2 exit 1 fi @@ -83,8 +176,34 @@ fi echo "Refreshing the unbound Codespaces port ${port} registration; final visibility will remain private." public_transition_started=1 set_visibility public -set_visibility private -if [[ "$(port_visibility)" != "private" ]]; then +private_error="" +if private_error="$(set_visibility private 2>&1)"; then + final_state="$(port_state)" +else + final_port_state="$(port_state)" + if [[ "${final_port_state}" == "absent" ]]; then + port_registration_removed=1 + fi + if ! read_listener_state; then + echo "Could not verify the no-listener condition after Codespaces removed the port ${port} registration." >&2 + exit 1 + fi + final_listener_state="${listener_state_result}" + if [[ "${final_port_state}" == "absent" && "${final_listener_state}" == "absent" ]]; then + final_state="absent" + else + printf '%s\n' "${private_error}" >&2 + exit 1 + fi +fi + +if [[ "${final_state}" == "absent" ]]; then + public_transition_started=0 + trap - EXIT + echo "Codespaces cleared the unbound port ${port} registration; its next terminal listener will be forwarded privately." + exit 0 +fi +if [[ "${final_state}" != "private" ]]; then echo "Codespaces did not confirm restored private visibility for port ${port}." >&2 exit 1 fi