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/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/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/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/.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/image/receipt.json b/.devcontainer/image/receipt.json index 178bb33..27f652a 100644 --- a/.devcontainer/image/receipt.json +++ b/.devcontainer/image/receipt.json @@ -2,56 +2,60 @@ "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": "private", - "anonymous_pull": "not_yet_observed", + "visibility": "public", + "anonymous_pull": "passed", "comparison_codespace": "not_yet_observed" }, "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." } } 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/.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/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..d33e4a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,9 @@ 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` | 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` | @@ -80,29 +81,75 @@ 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 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. 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, 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. + +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. 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); +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 diff --git a/DIRECT_COMPILATION_PLAN.md b/DIRECT_COMPILATION_PLAN.md index f0c6141..b4f4acc 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,79 @@ 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. 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. + +### 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 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. + +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 +288,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 +298,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..ab06f4d 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 @@ -137,15 +138,19 @@ 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 === "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."); @@ -153,9 +158,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."); @@ -164,16 +172,18 @@ 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."); 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..7dddc3f --- /dev/null +++ b/script/check-codespaces-private-port.mjs @@ -0,0 +1,282 @@ +#!/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"); +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); + 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, + PORT_REFRESH_LISTENER_ATTEMPTS: listenerAttemptsPath, + CODESPACES: "true", + CODESPACE_NAME: "drawing-board-test", + CODESPACES_ENV_FILE: codespacesEnvPath, + GH_TOKEN: "", + GITHUB_TOKEN: "", + ...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 [[ "\${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" + 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" == "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")" + 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 +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 ;; + error) exit 2 ;; +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(); + 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(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); + 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.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", + ]); + + 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.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(), []); + + 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, /did not provide CODESPACE_NAME/); + + 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/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/check-image-receipt.mjs b/script/check-image-receipt.mjs index 6da8380..529ab52 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,22 +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(platforms["linux/amd64"]?.layers_contain_no_ssh_host_keys === true, "The amd64 layer census must be retained."); +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"]?.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(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"]?.layers_contain_no_ssh_host_keys === true, "The arm64 layer census must be retained."); +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 4446deb..3a64025 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]*) ;; *) @@ -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,111 @@ 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." +if [[ "${metadata_only}" == true ]]; then + 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" +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 } -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 - 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 + echo "The development image exited while starting the maintained SSH listener." >&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 --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 +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 } -[[ "$(key_identity "$(<"${root}/duplicate-key-with-new-comment.pub")")" == "${key_identities[0]}" ]] || { - echo "The exact duplicate-key negative did not preserve key material." >&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 = ""; +let complete = false; +const fail = (message) => { + if (complete) return; + complete = true; + console.error(message); + process.exitCode = 1; + socket.destroy(); +}; +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(); +}); +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 } -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 +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 layers, PostgreSQL ${postgresql_clients[0]} clients, and per-container SSH host keys passed." +echo "Development image default command, maintained-SSH lifecycle, and PostgreSQL ${postgresql_clients[0]} clients passed." diff --git a/script/devcontainer-smoke b/script/devcontainer-smoke index 587baae..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 ' @@ -74,7 +84,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 +93,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" diff --git a/script/refresh-codespaces-private-port b/script/refresh-codespaces-private-port new file mode 100755 index 0000000..1bd499e --- /dev/null +++ b/script/refresh-codespaces-private-port @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +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 + +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 + +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 + 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 + printf 'absent' + return + 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" + 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 || [[ "$(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 + return "${status}" +} +trap restore_private_on_exit EXIT + +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_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 ! read_listener_state; then + exit 1 +fi +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 + +echo "Refreshing the unbound Codespaces port ${port} registration; final visibility will remain private." +public_transition_started=1 +set_visibility public +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 + +public_transition_started=0 +trap - EXIT +echo "Codespaces port ${port} registration refreshed; private visibility confirmed."