diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index b305d34..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.5 -FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION@sha256:e1bd336b0f49207a2a235299f7163bf00687b24582b911be21a58f0e5c1198cd - -LABEL org.opencontainers.image.source="https://github.com/firstdraft/drawing-board" -LABEL org.opencontainers.image.description="First Draft Drawing Board development environment" - -USER root -RUN install -d -m 0755 /etc/ssh/sshd_config.d && \ - printf '%s\n' \ - 'AuthenticationMethods publickey' \ - 'PermitRootLogin no' \ - 'PasswordAuthentication no' \ - 'KbdInteractiveAuthentication no' \ - 'PubkeyAuthentication yes' \ - > /etc/ssh/sshd_config.d/99-foundation.conf - -USER vscode - -CMD ["sleep", "infinity"] - -# The Rails image installs Ruby through mise, whose activation normally happens -# only in interactive shells. Lifecycle commands are noninteractive, so expose -# both mise and its shims at the image level; the Node feature prepends its own -# pinned bin directory while preserving this path. -ENV PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/shims:${PATH}" - -# Ensure binding is always 0.0.0.0 -# Binds the server to all IP addresses of the container, so it can be accessed from outside the container. -ENV BINDING="0.0.0.0" diff --git a/.devcontainer/image/devcontainer-lock.json b/.devcontainer/image/devcontainer-lock.json deleted file mode 100644 index 881b032..0000000 --- a/.devcontainer/image/devcontainer-lock.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "features": { - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { - "version": "1.10.0", - "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e", - "integrity": "sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e" - }, - "ghcr.io/devcontainers/features/github-cli:1": { - "version": "1.1.1", - "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:94879eebb6a0e4e2f197de9f12db7427cb4a25b82d93c55239ce8c8fc394a1b4", - "integrity": "sha256:94879eebb6a0e4e2f197de9f12db7427cb4a25b82d93c55239ce8c8fc394a1b4" - }, - "ghcr.io/devcontainers/features/node:1": { - "version": "1.7.1", - "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", - "integrity": "sha256:7fa8fff898ac33076ebf65631d3c6c902dc9bad87de3e5dfa645f3e2d7a35c07" - }, - "ghcr.io/rails/devcontainer/features/postgres-client:1": { - "version": "1.2.0", - "resolved": "ghcr.io/rails/devcontainer/features/postgres-client@sha256:7e6b118646d6e0d82a9ec06e81ad1b5406f7042f0279d35fff3a7a612be7a050", - "integrity": "sha256:7e6b118646d6e0d82a9ec06e81ad1b5406f7042f0279d35fff3a7a612be7a050" - } - } -} diff --git a/.devcontainer/image/devcontainer.json b/.devcontainer/image/devcontainer.json deleted file mode 100644 index 6d58c24..0000000 --- a/.devcontainer/image/devcontainer.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "First Draft Drawing Board development image", - "build": { - "dockerfile": "../Dockerfile", - "context": "../.." - }, - "features": { - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "moby": false }, - "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 deleted file mode 100644 index 82e5165..0000000 --- a/.devcontainer/image/receipt.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "format": "firstdraft.drawing-board-development-image/1", - "source": { - "repository": "firstdraft/drawing-board", - "commit": "1488d6337847a0dfd7da9b7c11de895e81160421", - "tree": "4af88c915789dda2fa6d5e366e35f672981a5030", - "tag": "devcontainer-image-candidate-safe-1488d63" - }, - "inputs": { - ".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": 33320822128, - "build_job": 99282366552, - "verify_job": 99284135796, - "package": "ghcr.io/firstdraft/drawing-board-workspace", - "manifest": "sha256:06602be5cc829d5142c12b06c505dbf8353a3ade6751ca4bf01a785ea2c3e6e3", - "platforms": { - "linux/amd64": "sha256:1c8a08347080623ce27daf13651327142eee2ac163907430614869435db2fdae", - "linux/arm64": "sha256:0d09a17e7618d69bc2fa1c57754cbb0453363a19f061686f517efceee59bde10" - }, - "visibility": "public", - "anonymous_pull": "passed", - "comparison_codespace": "passed" - }, - "verification": { - "platforms": { - "linux/amd64": { - "locked_feature_ids_present_once_in_metadata": true, - "no_command_stays_running": true, - "official_sshd_feature_starts_key_only_listener": true, - "postgresql_client": "18.6", - "psql_major": 18, - "pg_dump_major": 18 - }, - "linux/arm64": { - "locked_feature_ids_present_once_in_metadata": true, - "runtime": "not_observed" - } - }, - "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": "Quarantined under the superseded per-container-host-key policy; it remains unapproved for consumption." - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63b5f4a..5c82807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,4 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 - with: - push: never - runCmd: FIRSTDRAFT_REQUIRE_IMAGE_SOURCE_COMMIT=1 script/check && script/check-depth-one && script/devcontainer-smoke && script/devcontainer-smoke + - uses: firstdraft/dockerfiles/drawing-board@84cf8ecbb310f0abf2ace754ab3cb720fa8afd09 diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml index 8cdda8f..47cb7e5 100644 --- a/.github/workflows/devcontainer-image.yml +++ b/.github/workflows/devcontainer-image.yml @@ -14,43 +14,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 - with: - configFile: .devcontainer/image/devcontainer.json - imageName: ghcr.io/firstdraft/drawing-board-workspace - imageTag: sha-${{ github.sha }} - platform: linux/amd64,linux/arm64 - push: always + - uses: firstdraft/dockerfiles/drawing-board/image@84cf8ecbb310f0abf2ace754ab3cb720fa8afd09 verify: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Verify published image - env: - IMAGE: ghcr.io/firstdraft/drawing-board-workspace:sha-${{ github.sha }} - run: | - set -Eeuo pipefail - index="$(docker buildx imagetools inspect --raw "$IMAGE")" - printf '%s\n' "$index" > /tmp/index.json - amd64="$(jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' /tmp/index.json)" - arm64="$(jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest' /tmp/index.json)" - [[ "$(jq '[.manifests[] | select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64"))] | length' /tmp/index.json)" == 2 ]] - docker pull --platform linux/amd64 "${IMAGE%:*}@$amd64" - script/devcontainer-image-smoke "${IMAGE%:*}@$amd64" - docker pull --platform linux/arm64 "${IMAGE%:*}@$arm64" - script/devcontainer-image-smoke --metadata-only "${IMAGE%:*}@$arm64" + - uses: firstdraft/dockerfiles/drawing-board/image/verify@84cf8ecbb310f0abf2ace754ab3cb720fa8afd09 diff --git a/AGENTS.md b/AGENTS.md index 45a3c0f..286ed6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,16 +24,17 @@ name the Skill or translate the request into a command. checking that no real credentials or private CLI state are staged. Reviewed public demo logins in the generated README, development seeds, and submitted Plan are expected; preserve them. Before setup or source edits, save it to the user's own **private** repository through VS Code's **Publish to GitHub** or the - [Codespaces publication API](CONTRIBUTING.md#publish-from-the-codespace-terminal). + [Codespaces publication API](README.md#publish-from-the-codespace-terminal). Obtain approval of the owner and repository name, then verify the baseline arrived. If a remote already exists, show it and push there with approval instead. Never infer publication permission from Compile approval. - Never run `script/initialize-application` or - `script/application-smoke`, including their copies under `.firstdraft/design/`: those helpers require a separate + Never run `script/initialize-application`, including its copy under `.firstdraft/design/`: it requires a separate nested app. Run ordinary Rails commands at the new root. For later First Draft authoring commands, enter `.firstdraft/design/` and use its `bin/firstdraft`; do not rely on the original container's bare `firstdraft` PATH after relocation. - If the user chooses the optional nested mode, run `bin/firstdraft plan compile --output ./application` into an - absent destination. Then run root `script/initialize-application application` before setup or edits, followed by - `script/application-smoke`. Only in this mode is `application/` an ignored, separate Git repository with no remote; + absent destination. Then run root `script/initialize-application application` before setup or edits. Continue with the generated + README, `bin/setup --skip-server`, and ordinary application tests. For browser tests, start Selenium with the + [Compose recipe](README.md#7-open-your-app) from `application/` and stop it afterward. + Only in this mode is `application/` an ignored, separate Git repository with no remote; continue with Rails commands inside it. Do not manufacture Git metadata or repair generated bytes by hand. - If `application/` already exists, preserve it. Never delete, overwrite, or move it merely to satisfy the absent-path precondition. Stop and ask the user whether to continue in the existing application, push its nested `main` to an @@ -108,4 +109,4 @@ or a prerequisite for the internal-alpha test. Inspect that application's produc guides; prefer Rails conventions over new First Draft deployment machinery. Never infer permission to spend money, publish source, or expose private data from an earlier Compile approval. -When changing the Drawing Board template itself, read `CONTRIBUTING.md` and run `script/check`. +Template maintenance lives in [firstdraft/dockerfiles](https://github.com/firstdraft/dockerfiles/tree/main/drawing-board). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 7b64417..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,401 +0,0 @@ -# Contributing to First Draft Drawing Board - -Drawing Board is the template repository for the First Draft tester journey. Its README is deliberately written for -someone trying the product for the first time. This document owns the maintainer workflow and implementation map. - -Agents changing the template should also read [AGENTS.md](AGENTS.md). - -## Repository contract - -A repository created from this template must provide one ready-to-use workspace for Claude or Codex: - -- the Dev Container installs the latest public Claude Code and Codex releases and the exact reviewed First Draft CLI; -- every Skill declared by one exact source revision is linked into both agents; -- `.env` supplies the shared staging origin and token without entering Git; -- bare `firstdraft` on the Codespace PATH resolves to `bin/firstdraft`, and AGENTS.md routes Skill-issued commands - through that wrapper; and -- the same container carries the current generated Foundation's Ruby and Node toolchain plus healthy PostgreSQL; - generated browser tests start Selenium on demand, so the generated application can be - developed without a second Codespace. - -PostgreSQL health checks use TCP so the entrypoint's temporary Unix-socket-only initialization server cannot -release the workspace dependency early. Keep the existing five-second cadence; the measured one-second alternative -saves about four seconds locally but adds sustained polling, and Codespaces rejected the startup-only interval. -See the [follow-up startup measurements](STARTUP_FOLLOWUP.md). - -The template itself does not contain generated application source. For the internal alpha, Drawing Board's -`AGENTS.md` selects explicit `--output .` approval: the application replaces the workspace layout, original material -moves under `.firstdraft/design/`, and the same Git repository holds both. The primary **Use this template → Open in a -codespace** route starts without a remote. Inspect and commit the staged baseline, then publish it from VS Code or -the [Codespaces publication API](#publish-from-the-codespace-terminal) to the user's own private repository before -setup or edits. Preserve and use an existing remote when the user chooses -repository-first creation. Do not run the nested initializer or application smoke after root adoption, including their -relocated copies. The optional `--output ./application` mode keeps an ignored, separate nested application; only that -mode uses the initializer and application smoke. **Compile and publish through First Draft** selects the separate -`--github` Publication mode; **Create GitHub repository** saves the existing workspace and does not Compile again. -The accepted cross-repository sequence and its safety boundaries live in -[DIRECT_COMPILATION_PLAN.md](DIRECT_COMPILATION_PLAN.md). - -## Repository map - -| Path | Responsibility | -|---|---| -| `.devcontainer/Dockerfile` | Source for the published development image shared with generated Foundations | -| `.devcontainer/image/` | Image-only Features, lockfile, and exact published-image receipt | -| `.devcontainer/compose.yaml` | Drawing Board, default PostgreSQL, and on-demand Selenium lifecycle | -| `.devcontainer/devcontainer.json` | Codespace services, lifecycle, volumes, ports, and workspace environment | -| `.devcontainer/agent-versions.env` | Agent release selectors and exact First Draft/toolchain pins | -| `.devcontainer/setup-agents` | Idempotent installation, Skill linking, and Codespaces defaults | -| `.devcontainer/configure-codex.mjs` | Codespaces-only initialization of a missing user config | -| `.devcontainer/agent-skills.mjs` | Pinned-manifest Skill inventory, shared linking, and discovery verification | -| `.env.example` | Non-secret staging configuration copied to ignored `.env` | -| `bin/firstdraft` | Shared credential-loading and origin-pinning CLI wrapper | -| `bin/agent-doctor` | Installation and credential diagnostics without token disclosure | -| `bin/review-plan-with-*` | Optional read-only review by the other installed agent | -| `script/check` | Fast source, pin, wrapper, and credential contracts | -| `script/check-agent-skills.mjs` | Offline one/multiple-Skill installation, upgrade/rollback, and discovery cases | -| `script/check-agent-setup.mjs` | Offline first-setup/rerun selectors and user-state preservation | -| `script/check-claude-discovery.mjs` | Unauthenticated Claude Skill catalog check in an isolated home | -| `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` | Default command, locked Feature-ID, maintained SSH lifecycle, and PostgreSQL checks | -| `script/devcontainer-smoke` | Runtime smoke executed inside the built Dev Container | -| `script/agent-smoke` | Agent versions, PATH, commands, configuration, and shared Skill installation/discovery | -| `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` | Selenium lifecycle for template and optional nested-application qualification | -| `script/application-smoke` | Setup, PostgreSQL, readiness, and full CI proof for a generated `./application` | - -The nested initializer follows the generated application's own ignore rules. The only artifact-owned paths allowed to -bypass those rules are `.firstdraft/submitted-foundation-plan.json` and `.firstdraft/gaps.json`. Any other ignored -path is preserved and stops initialization; a future generated ignored file must update this narrow allowlist and -its exact-byte fixture in the same coordinated release. Canonical `0644` and `0755` modes are part of the generated -artifact contract; a mismatch requires a fresh compile into an absent directory rather than local mode repair. -A mode mismatch aborts initialization before the nested repository exists. Preserve that directory under the -Drawing Board's ignored, bind-mounted `tmp/` before recompiling; never use `/tmp` or the container home, and never -delete or overwrite it to manufacture an absent destination. - -## Work on the template - -Create a branch from current `main`, make the smallest coherent change, and run: - -```sh -script/check -``` - -Run the check through the pinned toolchain or inside the Dev Container; it requires the pinned Ruby and Node on -`PATH`. - -Changes to Dev Container setup, image source, agent installation, pins, or lifecycle also require the smoke inside -the built container. The simplest manual route is to open a Codespace on the branch and run: - -```sh -script/devcontainer-smoke -``` - -The smoke checks the pinned Ruby version and its mise-selected runtime. Interactive -[mise activation](https://mise.jdx.dev/dev-tools/shims.html) places the installed executable ahead of its shim, -so the resolved command path legitimately differs between the VS Code terminal and noninteractive CI. - -GitHub Actions authenticates to GHCR, starts the pinned Dev Container, runs the source and depth-one contracts, and -runs the template-root runtime smoke twice for every pull request. The generated-application branch of that smoke is -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. - -### Agent installation and updates - -New Codespaces install the vendors' latest public agents using their native installers. Claude's no-argument -installer defaults to `latest` and preserves an existing user's channel choice on explicit setup reruns. Codex -selects `latest` explicitly. Temporary exact agent pins need a demonstrated regression or an explicitly frozen -experiment with its reason recorded; update the setup policy check and qualification receipt with that exception. -First Draft CLI/Skills/service compatibility and the language, database, and image pins follow separate policies. - -Native installers and vendor updates share the user-owned `~/.local/bin` launchers. A container-wide npm prefix -breaks the image's interactive nvm initialization, so only the pinned First Draft CLI uses a per-command npm prefix. -Claude's normal updater is enabled; users can also run `claude update`. Codex offers updates through its normal -update prompt or `codex update`. These are different vendor mechanisms; Drawing Board does not run an updater or -reinstall agents on attach/resume. See -[Anthropic's installation and updates](https://code.claude.com/docs/en/setup), -[OpenAI's native installation instructions](https://learn.chatgpt.com/docs/codex/cli#getting-started), and -[Codex's update command](https://learn.chatgpt.com/docs/developer-commands#codex-update). - -The existing named volumes retain `/home/vscode/.claude`, `/home/vscode/.codex`, and `/home/vscode/.cache` across -container rebuilds. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` select those config/conversation homes. Setup recreates -executables under `~/.local` and the shared Skill links, preserves unrelated Skills and user settings, and only -seeds Codex defaults when its config is absent. It never resets authentication or conversation directories. -Historical qualification receipts retain the versions they actually tested; current smoke output records the -installed versions instead of requiring a historical client number. `script/agent-smoke` can run without Rails or -PostgreSQL, and is also called by `script/devcontainer-smoke`. - -Keep `CLAUDE.md` as the minimal `@AGENTS.md` import. Claude's native discovery still has first-session and -feature-availability restrictions; upgrading alone does not qualify import removal. The shared instruction source -remains `AGENTS.md`. See [Anthropic's discovery limits](https://code.claude.com/docs/en/memory#agents-md) and the -[installation qualification boundary](DIRECT_COMPILATION_PLAN.md#agent-release-policy-and-qualification-2026-09-18). - -Skill linking reads the pinned checkout's `.claude-plugin/plugin.json` and links all declared canonical Skill -folders into Claude's configured `skills/` and Codex's `~/.agents/skills/`. Both clients therefore read the same -reference files as well as the same entrypoints. The installer preflights collisions, preserves unrelated files -and symlinks, and removes obsolete links only when they point into the managed First Draft revision cache. -`bin/agent-doctor` checks the complete inventory. The agent smoke verifies every namespaced Codex Skill in -model-visible context and Claude's catalog loading of the same installed sources in an isolated home. Claude's -`--init-only` probe disables hooks and MCP configuration and reads discovery diagnostics without a model turn. -Neither probe proves authenticated invocation. The optional npm plugin remains a separate installation path owned -by the Skills repo. - -The offline check covers one-Skill and three-Skill manifests without changing distribution pins. Linking changes -alone do not make unreleased Skills available. The UI infrastructure release distributes `create-full-stack-app` -only; selection and packaging of application UI Skills remain deferred. Qualify the exact declared inventory in -the built container and both agent adapters before changing its pin. - -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 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. The current receipt records both -anonymous access and the retained comparison Codespace as passed. That exact Codespace also proved that -`script/selenium` resolves the Compose project from its runtime container identity; no speculative fallback was -needed. The helper remains in use by `script/application-smoke` for the optional nested application and by -`script/devcontainer-smoke` to verify that workspace setup has not started Selenium. Root-adopted applications use -their generated `.devcontainer/compose.yaml` and the running container's Compose project, as shown in the -[browser-testing instructions](README.md#7-open-your-app). The generated health check uses Selenium's supplied -`/opt/bin/check-grid.sh`; Compose owns readiness for that command and fresh generated Dev Container startup. - -Selenium uses its upstream session-request queue deadline, currently 300 seconds. The generated app's Ruby client -retains its separate 60-second HTTP read timeout, so an unanswered session request can still fail sooner. -The former 30-second override rejected a slow first browser start in Codespaces; the observation and remaining -qualification are tracked in [Service #729](https://github.com/firstdraft/firstdraft/issues/729). - -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 planning workspace starts Selenium only when browser proof requests it. - -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 `postAttachCommand` runs the executable helper at `script/refresh-codespaces-private-port` first, or at -`.firstdraft/design/script/refresh-codespaces-private-port` when only the archived helper remains. If neither is -executable, it succeeds without changing port registration. Retained planning context is optional for application -work; no helper is copied into generated application source to replace it. This uses the standard -[Dev Container shell lifecycle](https://containers.dev/implementors/json_reference/#lifecycle-scripts), not a new -service: the [reference implementation](https://github.com/devcontainers/cli/blob/main/src/spec-common/injectHeadless.ts) -runs a string command in `/bin/sh` with the workspace as its working directory. An inline path selection survives -the move even when the already-running container retains its original lifecycle configuration. Helper errors still -propagate, including its active-listener refusal; no port policy changes with the path. The focused -`script/check-codespaces-private-port.mjs` exercises root precedence, archived execution, non-executable or removed -helpers, and the existing private-port and listener cases. Both helper locations preserve actual failures; the -guard does not turn a failed refresh into success. Existing containers can retain the lifecycle command recorded -when they were created; this source change does not rewrite their provider metadata. These shell checks do not -qualify fresh-template attachment or private preview after reattachment and stop/start. Those provider observations -remain under [Service #730](https://github.com/firstdraft/firstdraft/issues/730). - -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. The [successor qualification](DIRECT_COMPILATION_PLAN.md#observed-successor-qualification-on-2026-09-0102) -subsequently proved a private forwarded browser GET, valid-CSRF state-changing POST, missing-CSRF rejection, and -unrelated-Host rejection on its exact generated artifact. Preserve that dated proof; it is not a claim about every -future generated target revision. - -## Release handoff and periodic tool refresh - -Drawing Board is a post-publication follow-up in the -[coordinated release process](https://github.com/firstdraft/firstdraft/blob/main/RELEASE_COORDINATION.md#drawing-board-release-handoff). -After the service and packages are released, update `FIRSTDRAFT_CLI_VERSION` and `FIRSTDRAFT_SKILLS_REVISION` in -`.devcontainer/agent-versions.env` to the published compatible CLI and the released plugin's exact source revision. -Reconcile the wrapper, setup messages, guide, root-adoption paths, and affected fixtures. Run `script/check`, require -the pull request's built-container CI, and verify the merged revision's prebuild before declaring the template ready. -Record the selected pins and observed checks; installation and discovery do not prove authenticated Compilation. -This update and its prebuild do not block package publication. Pins install during workspace setup, so changing -them alone requires no workspace-image rebuild. Local development starts in an empty folder; this template serves -the Codespaces fallback. - -Review tools weekly as well as during releases. Fresh setup already selects the vendors' latest public Claude and -Codex releases; verify those installers still work with the template. Review the pinned First Draft CLI/Skills, -Ruby, Node, PostgreSQL, Dev Container Features, GitHub CLI, and Selenium/image dependencies against their official -releases. Prepare small compatible updates and run the checks for the affected surface. A runtime pin must continue -to match generated Foundations; record a concrete compatibility reason when retaining an older version. - -Use the existing vendor updaters for running workspaces, as described above. Do not reinstall tools on every attach, -change a user's selected channel, or reset authentication and conversation state. Image changes follow the existing -image publication and qualification procedure; an agent or First Draft package update alone needs no new image. - -## Codespaces prebuilds - -`setup-agents` calls `configure-codex.mjs` to initialize a missing `$CODEX_HOME/config.toml` only when `CODESPACES=true`, with -`sandbox_mode = "danger-full-access"` and `approval_policy = "on-request"`. The supported -[Codex configuration](https://learn.chatgpt.com/docs/config-file/config-basic) keeps plain `codex` and `codex resume` -usable after the [September 13 namespace failure](STARTUP_FOLLOWUP.md#codex-command-sandbox--september-13-2026). -The Codespace's disposable VM -provides isolation from the student's computer; Codex still has access to the workspace, credentials, network, -and mounted Docker socket inside it. On-request approvals let the agent ask; they are not a command-level sandbox. -Drawing Board's separate Compile and publication instructions still apply. - -The config is created during `postCreateCommand`, after the per-Codespace home volume is mounted. It survives root -adoption and container restarts intentionally, supporting normal application work in the same Codespace. After -Compile, the generated root `AGENTS.md` governs application work; Drawing Board's instructions move into -`.firstdraft/design/`. -This home setting is not scoped to those instructions or to First Draft commands. Setup never overwrites an existing -config or dotfile symlink and makes no change -in local devcontainers, where the mounted Docker socket can reach the developer's host. A user preserving older -settings can explicitly choose the same policy for a session with -`codex --sandbox danger-full-access --ask-for-approval on-request resume` inside their Codespace. - -`script/check-codex-configuration.mjs` checks fresh volumes, repeated setup, local exclusion, and preservation of -existing settings. The agent smoke checks the installed Codex binary's loaded sandbox and permission-request -instructions, including a `never` control that must disable requests, without sign-in or a model request. - -The primary template launch can reuse the prebuild on `firstdraft/drawing-board`; a new repository created with -**Create a new repository** does not inherit that configuration. Keep the README's **Use this template → Open in a -codespace** route and its private-repository checkpoint after Compile. - -Manage the existing configuration under **Settings → Codespaces**, for `main` and -`.devcontainer/devcontainer.json`. Keep prebuild optimization enabled so a usable older prebuild can serve a launch -while its successor runs. The observed configuration uses **Every push**, all five regions, two retained versions, -and failure notifications to the maintainer. Region coverage and retention are cost choices; choose them from the -actual audience rather than adding a separate configuration for each generated repository. - -Keep the agent/CLI/Skill install in `postCreateCommand` so tool updates need no workspace-image publication. The -[hosted experiment](PREBUILD_EXPERIMENT.md) moved preparation into `updateContentCommand`: two prepared launches -averaged 55.7 seconds to setup completion versus 60.3 seconds for two existing-prebuild baselines. First native CLI -execution still waited on file reads after snapshot restore. That roughly five-second saving did not justify the -extra installation paths. The [second round](STARTUP_FOLLOWUP.md) also found no useful improvement from file -read-ahead or concurrent warm-up. Its baked tool image made cold creation about 53 seconds slower in two matched -pairs. The shared image already contains the slower-changing Rails/system toolchain. - -Setup reads pins from the **checked-out source**, which can itself come from an older prebuild. In the experiment, -requesting the branch after a push restored the previous commit while its new prebuild was unavailable. Keep -**Every push**, wait for a successful prebuild of the intended revision before qualifying a new pin, and verify the -Codespace's actual tree. Direct-template creation starts a new Git history, so compare its tree rather than expecting -the template commit SHA. Post-create installation does not by itself guarantee the latest remote pins. - -To investigate a slow launch, record the exact template commit, region, machine, creation time, editor-ready time, -and `Drawing Board setup complete.` time. In that Codespace, check whether it actually used a prebuild: - -```sh -gh api "/user/codespaces/$CODESPACE_NAME" --jq '.prebuild' -git rev-parse HEAD 'HEAD^{tree}' -``` - -Use **Codespaces: View Creation Log** to separate provisioning/container work from lifecycle commands. A green -prebuild workflow alone does not prove a particular Codespace used it. Compare the same revision and region before -claiming a speedup. See [GitHub's prebuild semantics](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/about-github-codespaces-prebuilds), -[configuration options](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/configuring-prebuilds), and -the [startup investigation](STARTUP_INVESTIGATION.md) for measurements and proof boundaries. - -## Publish from the Codespace terminal - -An agent can publish an unpublished direct-template Codespace using its built-in `GITHUB_TOKEN`. Use GitHub's -[Codespaces publication endpoint](https://docs.github.com/en/rest/codespaces/codespaces#create-a-repository-from-an-unpublished-codespace); -the general `gh repo create` and `POST /user/repos` routes rejected that token in the live test. No additional login, -PAT, or First Draft API command is needed for this route. - -First inspect and commit the baseline without real credentials or private CLI state, retaining reviewed public -demo logins. Confirm that no remote exists and obtain approval of the personal owner, repository name, and private -publication. If a remote already exists, use that approved remote instead. Run this from the Codespace's integrated -terminal, substituting the approved name: - -```sh -gh api --method POST "/user/codespaces/$CODESPACE_NAME/publish" \ - -f name="my-app" -F private=true \ - --jq '.repository | {full_name, private, html_url}' -``` - -Verify the returned owner/name and `private: true`. This creates the repository, associates the Codespace with it, -and grants its token write access. It does not add local `origin` or push commits. From the generated application's -Git root, use the returned repository URL: - -```sh -git remote add origin https://github.com/OWNER/REPO.git && git push -u origin HEAD -``` - -Verify the remote baseline commit and retained `.firstdraft/design/` files before continuing. Later saves use -ordinary commits and `git push`. If creation succeeds but the push fails, keep the repository and repair the reported push failure; -do not create another repository. After an ambiguous API result, inspect the Codespace's repository association and -the approved repository read-only before any retry. The [live receipt](STARTUP_INVESTIGATION.md#publication-credentials) -proves private creation and two pushes using only the built-in token, with a small Git fixture. - -## Credentials and external systems - -Never commit a First Draft API token, GitHub token, agent credential, or generated `.env`. `script/check` scans the -repository for common credential shapes and verifies that `.env` remains ignored. - -The shared ignored `.env` is the credential path for both agents; do not add agent-specific token configuration. -The template wrapper intentionally selects staging. Its existing `.env` format keeps the staging token under -`FIRSTDRAFT_API_TOKEN`; the wrapper maps it to the CLI's `FIRSTDRAFT_STAGING_API_TOKEN`, removes the production token -and legacy plugin settings from the child environment, and overrides any inherited staging token. This applies to -the version probe as well as the requested command. A blank `.env` token never falls back to shell credentials. -The standalone CLI defaults to production and selects staging with `--staging`; Drawing Board's wrapper continues -to select staging through its required URL. Production defaults, GitHub Publication, and Service deployment -are owned by [firstdraft/firstdraft](https://github.com/firstdraft/firstdraft); Skill and plugin delivery are owned by -[firstdraft/skills](https://github.com/firstdraft/skills). - -## Documentation - -Keep [README.md](README.md) focused on the beginner journey. Put maintainer commands and implementation details here, -and keep agent-only guardrails in [AGENTS.md](AGENTS.md). If a workflow change affects what a tester must do, update -the README and verify the affected journey before landing it: root adoption for the internal-alpha path, nested -`application/` when selected, or the separate-repository journey for Publication. -[DIRECT_COMPILATION_PLAN.md](DIRECT_COMPILATION_PLAN.md) owns the -current direct-journey acceptance steps and every explicitly unfinished step; do not call that journey complete -until those steps are observed. - -The internal-alpha delivery scope is the editor-first loop in the README: Codespace, installed Skill, existing -agent, approved root Compile, private-repository publication, boot, source inspection, ordinary source iteration, and saving to the same repository. -Deployment is optional follow-on work, not a pre-send gate for that code-sharing test. A separate Plan web editor, -public plugin promotion, and completion of all realization gaps are not prerequisites. The existing web surface -supplies access and credentials; an explorable read-only Plan view can improve independently. - -The retained direct-journey receipts prove compile, boot, browser mutation, and same-agent iteration, not a hosted -application deployment. The README's Render/Neon route is provider-backed guidance, not an observed deployment -receipt. Before claiming that final leg qualified, exercise a saved generated application repository, a live Render -web service using Neon, persistent sample records across a redeploy, and one tested source edit reaching the live -URL. Record the actual source and provider configuration without secrets. Do not rerun unchanged Codespace image or -Compile qualification solely because this guide changes. diff --git a/DIRECT_COMPILATION_PLAN.md b/DIRECT_COMPILATION_PLAN.md deleted file mode 100644 index cc80b36..0000000 --- a/DIRECT_COMPILATION_PLAN.md +++ /dev/null @@ -1,647 +0,0 @@ -# Direct Compilation in the Drawing Board workspace - -## Goal - -Let an agent author a Foundation Plan in a Drawing Board Codespace, compile it into the workspace root, and continue -developing and testing the generated Rails Foundation in that same Codespace and Git repository. GitHub -remains the authentication provider, and the existing GitHub Publication flow remains available for callers that -want a separate repository. - -The internal-alpha guide selects `--output .` mode after explicit approval of the Plan, gaps, and -move of existing Drawing Board files into `.firstdraft/design/`. Git history and any existing remote remain in place. -The recommended direct-template Codespace starts without a remote; inspect and commit the staged baseline, then use -VS Code's **Publish to GitHub** or the [Codespaces publication API](CONTRIBUTING.md#publish-from-the-codespace-terminal) -to save it to the user's private repository before setup or edits. The [startup investigation](STARTUP_INVESTIGATION.md) -distinguishes local no-remote CLI proof and live publication of a small Git fixture from the still-pending complete -hosted Compile-to-publication journey. Never run the nested initializer or application smoke after that move. -The optional `./application` and Publication paths remain available. The packets and dated receipts below preserve -the earlier nested-first delivery sequence; they are not instructions to initialize a nested app after root Compile. - -## Package pins and prior release observations - -The exact released CLI, Skills source, and runtime pins live in -[`.devcontainer/agent-versions.env`](.devcontainer/agent-versions.env). The September 22 local release selected -Skills `5f544bff149173a249899d2b5dfd403057cc5a30` (plugin `0.4.0`) and CLI `0.4.0`, both observed on npm `latest`. -That pair uses API `0.4` and Foundation Plan `sketch/0.20`. Its -[container CI](https://github.com/firstdraft/drawing-board/actions/runs/35808456552) and merged -[prebuild](https://github.com/firstdraft/drawing-board/actions/runs/35808770746) passed at release tree `b173010a`. -The image and runtime pins did not change. No fresh Codespace consumed the prebuild in this release. - -CLI `0.4.0` defaults to local `--output .`, preserving planning material at `.firstdraft/design/`. GitHub Publication -requires `--github`. The earlier receipts below retain the versions and paths they exercised. Follow the -[release handoff](CONTRIBUTING.md#release-handoff-and-periodic-tool-refresh) when updating the pins. - -Earlier on September 22, Drawing Board `b4899909` selected Skills `384fb9422a0cdd2a4bd08b86d6ca677e20048225` -(plugin `0.3.0`) with CLI `0.3.0`. That earlier tuple also used `.firstdraft/design/`, but its zero-flag Compilation -selected GitHub Publication. - -The [September 15 release receipt](https://github.com/firstdraft/firstdraft/blob/c4ac120903d100622b6d625d650a8b26bd597eb8/docs/solutions/2026-09-15-account-settings-release.md) -records the preceding Skills `0.2.5` / CLI `0.2.2` tuple, Drawing Board `aed9635a` container CI, and prebuild success. -It did not exercise a fresh Codespace on that tuple. - -### Earlier Skills 0.2.2 qualification (2026-09-10) - -That update selected Skills source -[`7920d06717d0f70a1d7afe1405a8754109f7d388`](https://github.com/firstdraft/skills/commit/7920d06717d0f70a1d7afe1405a8754109f7d388), -tree `8cf3c0a78ba3b5392aea588ba84430db961d7d77`, the source of shared plugin `0.2.2`. -The canonical helper selects `./bin/firstdraft` before a bundled or PATH CLI. Before Compilation this is Drawing -Board's root wrapper. After root adoption, the Skill directs later First Draft commands to run from `design/`, so -`./bin/firstdraft` resolves to the moved wrapper and retains its credential setup. Ordinary Rails work stays at the -generated root; the Skill forbids the moved nested-only initializer and smoke helpers. **Create GitHub repository** -saves the current workspace; **Compile and publish through First Draft** creates a separate compiled repository. -CLI `0.2.2`, both agent versions, and language/runtime pins were unchanged by that Skill update. - -The [0.2.2 release receipt](https://github.com/firstdraft/skills/blob/fb6c8e63105f1f139e6ae59f3958f9c98b44cd69/evidence/2026-09-10-shared-plugin-0.2.2-release.md) -records exact-package tests against the real local service with Claude and a synthetic HTTP fixture with Codex. -That receipt records a byte-for-byte comparison of all nine canonical Skill files, including the helper and -references, between the registry package and this source revision. Drawing Board nevertheless fetches the Git revision and uses its own -wrapper and installed CLI; package tests do not establish that environment's complete workflow. -The [new-pin hosted container contract](https://github.com/firstdraft/drawing-board/actions/runs/34563183070) -passed at Drawing Board `26caab0e4cefd05236dedea1f9332307c43bef97`, running `script/devcontainer-smoke` twice against -this pin. It verifies exact versions and Skill discovery without sign-in or Compilation. The dated records below -retain the earlier Skills revisions they exercised. No fresh authenticated Codespace journey at this Skill -revision with either agent was part of that checkpoint; it did not establish a Codex hosted journey. - -## Agent release policy and qualification (2026-09-18) - -New Codespaces select the vendors' latest public agents through their official native installers. Claude's -no-argument bootstrap defaults to `latest` while preserving an existing user's chosen channel on setup reruns; -Codex explicitly selects `latest`. Agent versions are observations in test receipts, not permanent compatibility -pins. The [maintainer policy](CONTRIBUTING.md#agent-installation-and-updates) owns update behavior and the independent -First Draft/runtime pins. A normal attach or resume does not run setup or a custom updater. - -Primary-source inspection used [Claude's native bootstrap](https://claude.ai/install.sh), its public `2.1.278` -installer and shipped install/update code, and Codex -[`rust-v0.155.1`](https://github.com/openai/codex/tree/be2951ea34f0d295ed0becf97079f92fa5f6950e), including its -[native installer](https://github.com/openai/codex/blob/be2951ea34f0d295ed0becf97079f92fa5f6950e/scripts/install/install.sh). -Both installers own launchers in `~/.local/bin`; their ordinary vendor update commands use the same installation. -Claude's explicit `latest` install argument writes the user's channel setting, so setup deliberately omits it. -First Draft's pinned CLI alone retains a per-command npm prefix. - -The first npm-based candidate, `5c0f9e355445fec7238e8e08f6a3fa22c18fc7c3` / tree -`ec9cdaa555a6eb222972708d47ba5ccd6466356f`, passed local agent checks, -[hosted CI](https://github.com/firstdraft/drawing-board/actions/runs/35419895381), and two full smokes in the fresh -repository/branch Codespace `fd-board39-20260919-4xx75x45p3wwx`. That fixture was created at `2026-09-19T03:59:28Z` -on East US `basicLinux32gb`, with normal post-create setup installing Claude `2.1.278`, Codex `0.155.1`, CLI `0.2.2`, -and Skills `54294d6c`. Prebuild status was not reported, so no prebuild or timing comparison is inferred. -**That candidate was rejected:** a subsequent interactive shell reproduced nvm's refusal of `NPM_CONFIG_PREFIX`, -leaving Node/npm unavailable. Its green noninteractive checks do not qualify the corrected native installation. -The retained smoke now runs noninteractive, interactive non-login, and interactive login shells. A negative control -with the bad prefix reproduced missing Node in the non-login shell and exited `127` at the first command; the -login shell alone retained Node despite the nvm warning. That observation motivated retaining all three modes. - -Native installer checks used the existing immutable workspace image on **linux/arm64**, task-private containers, -no Docker socket, no database, and no real authentication files: - -- Claude's no-target bootstrap installed latest `2.1.278` for a fresh profile and retained it on rerun. An existing - user-selected `stable` channel stayed unchanged and selected `2.1.267`; explicitly passing `latest` overwrote - that setting, confirming why setup omits the argument. -- Native `claude update` advanced `2.1.277` to `2.1.278` at the same launcher. Credentials, history, conversation, - and settings fixtures retained exact bytes; custom global/project configuration keys survived vendor bookkeeping. -- The existing npm `2.1.226` installation migrated to native `2.1.278` without force or preliminary removal. - Its old npm package directory remained inert; `~/.local/bin/claude` selected the native binary. An unrelated - package sentinel and synthetic user state survived. - -- Combined setup installed Claude `2.1.278`, Codex `0.155.1`, pinned CLI `0.2.2`, and Skills `54294d6c`. - Both vendor update commands, complete setup rerun, and repeated `script/agent-smoke` passed. Noninteractive and - interactive login shells both retained Node `24.18.0`, npm `11.16.0`, and both agents. Eight synthetic state files - remained byte-identical and a custom global Claude setting survived vendor bookkeeping. -- The combined smoke retained executable/PATH, login/update/resume and review commands, effective Codex permissions, - exact First Draft CLI help/compatibility, and both shared Skill catalog probes. Claude's isolated `--init-only` - probe disabled hooks and MCP; Codex's prompt-input probe found the namespaced Skill. -- `script/check` passed under Ruby `4.0.5` and Node `24.18.0`; ShellCheck passed. The offline fixture exercises actual - setup with simulated native installers, enforces the approved selectors, and preserves an existing user's channel. - -The corrected source checkpoint is `6e6b34c5ffdc22678c09c0bf2bd9a191a7f05f2f`, tree -`fa16e0b654ee615d67b7aa48a0cd2ea86e241ee4`: - -- A separate full migration fixture started with npm Claude `2.1.226` and Codex `0.154.0`, then ran that exact setup - and unmodified agent smoke twice. Both launchers remained native at `2.1.278` / `0.155.1`. Seven synthetic - credentials/history/conversation/Codex-config files retained exact bytes. Every original Claude setting and global - configuration value survived; the vendor formatted settings and copied the existing global `theme: light` - preference into settings. Settings bytes then stayed identical across all four setup/smoke snapshots. -- Native `codex update` advanced `0.154.0` to `0.155.1`, preserving the synthetic state and passing agent smoke. -- [Hosted container CI](https://github.com/firstdraft/drawing-board/actions/runs/35420709793) passed on that exact - checkpoint, including source/depth-one contracts and both full container smokes on linux/amd64 with the same - native agent versions. -- The one retained Codespace was updated to that checkpoint and normally rebuilt. Docker reported a new container - created at `2026-09-19T04:14:49Z` from the unchanged `06602be5` image, with the same three named state volumes. - SSH timed out during banner exchange after the rebuild; one stop/start restored access at `04:19:25Z` without - configuration changes. The exported creation log records that resume; it replaced the earlier rebuild log. -- From `04:20:05Z` to `04:20:41Z`, the rebuilt linux/amd64 fixture passed full `script/devcontainer-smoke` twice, - both vendor updates, explicit setup rerun, and agent smoke. The normal environment had no global npm prefix; - both shell modes retained Node `24.18.0`, npm `11.16.0`, and both native clients. Claude doctor reported - auto-updates enabled on `latest`. All eight pre-rebuild synthetic/configuration/blank-env files stayed - byte-identical; vendor-owned global installation bookkeeping was excluded from that byte comparison. - -A final fresh local run at `a12ddae7d2f8a19bb5d6f9a59d64871fe47a33c8` / tree -`9d9f539bc9b772009580271dbe1b5c2cea905feb` used empty agent/cache volumes and `CODESPACES=true`. Real native setup -installed the same client/CLI versions and created the mode-0600 Codex config with the then-current -`danger-full-access` / `on-request` defaults. The loaded permission check and full agent smoke passed all three -shell modes. This exercises first-install configuration composition locally; it is not a GitHub Codespace creation. - -A fresh Codespace attempt on `2026-09-19` did not reach that corrected source. The assigned fixture, -created at `15:02:07Z` for `main`, reported `prebuild: true` and opened the clean pre-update checkout -`aed9635a4dc191eb1f384e500c10648a5cec2215`, tree `47cae4a0f88fd5503260d994a0bb5a4545cc1c0f`. -Its initial post-create log installed Claude `2.1.226` and Codex `0.154.0`, even though remote `main` was already -`d2f488f783dd98167c4ba65fb6a7937ddea92e53` / tree `d94c7e80aecefd549719ee70709475c191ffc62e`. -The source, provider log, unchanged image digest, and mounted volumes were retained; no setup rerun, smoke, or -synthetic-state mutation was performed on the mismatched checkout. The fixture was confirmed stopped by `15:09Z`. -The log completed setup without recording a source update phase or its failure; it does not establish why the -provider selected the old checkout. This repeats the [prebuild freshness boundary](CONTRIBUTING.md#codespaces-prebuilds) -and does not qualify or falsify the corrected native implementation. - -Read-only review of existing configuration `151299` found **Every push** for `main` and -`.devcontainer/devcontainer.json`, all five regions, two retained versions, and prebuild optimization enabled. -During the `15:02–15:09Z` attempt, its latest [prebuild run](https://github.com/firstdraft/drawing-board/actions/runs/35027951438) -was the successful September 15 run at `aed9635`; workflow history contained no newer run before the fixture stopped. -A failed-latest-run fallback was therefore not established. GitHub's -[older-prebuild fallback documentation](https://docs.github.com/en/codespaces/troubleshooting/troubleshooting-prebuilds#preventing-out-of-date-prebuilds-being-used) -explains the possibility of an older checkout, not why an automatic successor run was absent in this attempt. - -A manual refresh of unchanged configuration `151299` then produced a successful -[prebuild at merged `main`](https://github.com/firstdraft/drawing-board/actions/runs/35451319086). A new disposable -Codespace created at `15:38:08Z` reported `prebuild: true` and actually opened -`d2f488f783dd98167c4ba65fb6a7937ddea92e53` / tree `d94c7e80aecefd549719ee70709475c191ffc62e`. -Its initial post-create setup ran from `15:38:32.789Z` to `15:39:09.634Z`, installing native Claude `2.1.278`, -Codex `0.155.1`, CLI `0.2.2`, and Skills `54294d6c`. The source was clean and unchanged before and after qualification; -the image remained `06602be5`, with the three configured agent/cache volume mounts. - -- Before any synthetic seeding, `.env` matched the blank-token template and had mode `0600`. The checked agent - credential files and authentication environment variables were absent; neither standard conversation directory - contained files. - Codex's initial mode-`0600` config selected the then-current `danger-full-access` / `on-request` defaults. -- From `15:43:15Z` to `15:43:33Z`, two full `script/devcontainer-smoke` runs passed in that fresh linux/amd64 - Codespace. All three Bash modes (`-c`, `-ic`, `-lic`) retained Node `24.18.0`, npm `11.16.0`, and both agents. - Effective Codex permissions, required commands, pinned CLI/Skill compatibility, and both Skill catalogs passed. - Claude doctor reported native installation, auto-updates enabled, and the `latest` channel. -- Six synthetic files represented credentials, conversations, and unrelated Skills for both agents. Supported setup - rerun and all three modes of agent smoke passed by `15:46:42Z`. Those six files plus the initial Codex config and - blank `.env` retained bytes, modes, and ownership after setup and again after smoke. Existing settings were not - edited; no Claude `settings.json` existed before or after. Vendor global bookkeeping was recorded separately - from those eight files. The native client versions and Claude doctor's channel/update status stayed the same. - -The initial provider log, runtime logs, source/image/mount metadata, and hash receipts were exported before the -fixture was stopped; `Shutdown` was confirmed at `15:49:35Z`, before its `16:23:08Z` deadline. This completes the -fresh corrected-source installation observation for [Drawing Board #39](https://github.com/firstdraft/drawing-board/issues/39). -The successful manual prebuild refresh does not explain the earlier missing automatic successor run. - -For future qualification, use an assigned disposable fixture and verify its actual source head/tree matches the -frozen candidate before running checks. Retain its post-create log, installed versions, and mount configuration. -With the initial blank `.env`, run `script/devcontainer-smoke` twice. Then seed only synthetic agent state, rerun -setup, and verify preservation, distinguishing vendor-owned configuration migration from byte-preserved credentials -and conversations. Do not reuse or alter a signed-in Codespace without its assigned lease. - -These checks concern installation, update commands, shell availability, and Skill catalogs. They do not prove -direct-template creation/publication, credential validity or signed-in conversation restoration, an authenticated -Skill/model turn, first-session instruction loading, or a Plan-to-Compilation journey. No agent signed in, and no -First Draft service, Compilation, repository publication, or release operation ran. Requalify affected commands and -discovery when a future vendor release changes them; record actual versions instead of retaining observations as pins. - -The minimal `CLAUDE.md` import stays. [Anthropic's native AGENTS discovery](https://code.claude.com/docs/en/memory#agents-md) -has first-session and feature-availability limits; neither latest installation nor catalog diagnostics prove it can -replace the import. [Service #712](https://github.com/firstdraft/firstdraft/issues/712) owns that remaining qualification. - -## Earlier Codex qualification boundary (2026-09-10) - -That update selected [`@openai/codex@0.154.0`](https://github.com/openai/codex/releases/tag/rust-v0.154.0). -An actual local `gpt-6-astra` model turn with `0.147.0` returned HTTP 400 saying that the model required a newer -Codex version, before any Skill or First Draft service use. The same model starts successfully with `0.154.0`. -This compatibility failure, rather than a new First Draft CLI or Skill requirement, motivates the pin change. - -Local `0.154.0` command-help checks passed. Separate model tests exercised the shared packaged Skill candidate, -published CLI `0.2.2`, and authentication/approval continuity against a local HTTP fixture. The -[Skills receipt](https://github.com/firstdraft/skills/blob/7920d06717d0f70a1d7afe1405a8754109f7d388/evidence/2026-09-10-codex-onboarding.md) records exact -package identities and boundaries. The fixture tests do not prove real First Draft Analysis or Compilation. -The separate real-service package tests linked above used Claude; they do not establish a Codex Compiler journey. -`bin/agent-doctor --installation-only` checks login and resume command availability. Hosted -`script/devcontainer-smoke` additionally checked the pinned version and exact Skill inventory; neither check signs -in or proves the complete agent journey. - -The earlier [hosted container contract](https://github.com/firstdraft/drawing-board/actions/runs/34561285350) passed at -`aa7ec605ba3f57dc2b05f2bb65244e3d31aba74e`, including both runtime-smoke invocations with Codex `0.154.0` and the -updated alias-aware Skill-path check. That is container/installation evidence, without agent sign-in or Compilation. - -OpenAI's [Skill instructions](https://learn.chatgpt.com/docs/build-skills#how-chatgpt-and-codex-use-skills) document -`/skills` selection and `$` mentions; local discovery supplies the `firstdraft:create-full-stack-app` name. The -README uses those supported interfaces, without claiming an observed interactive picker or message-entry smoke. - -At that checkpoint, the retained hosted full-journey receipts exercised **Claude**, with Codex `0.147.0` installed. Fresh browser device -sign-in, Codex's Codespace permission prompts, a Codex-driven Plan-to-root-Compile journey, and resuming that -conversation after a Codespace stop/start were still unproved. Local model/CLI results and a green container contract -must not be reported as that hosted Codex journey. - -## Packet 1: direct artifact output in the CLI - -Exact implementation candidate before this document: - -- repository: `firstdraft/cli`; -- base: `a251df7870491d5b9bdc390c27373933563f99fd`; -- reviewed head: `89ca49b3046ae86e540886868887eb0e60970ad5`; -- integrated main: `d38ef3e54a6476b3a91f22a17fe7bd47aa6d6d68`; and -- tree-identical result: `e62ee3ff1fb6d188c5d2c5a6e5e0efd50b40245f`. - -`firstdraft plan compile --output ` is a distinct execution mode: - -1. Preflight the explicit output path before any network mutation. The target must be absent beneath an existing, - real parent directory. -2. Push the exact Plan and wait for the matching valid Analysis using the current Plan flow. -3. Re-read local state and exact Plan bytes before starting Compilation. -4. Send one conditional, bodyless `POST` to the existing Service Compilation endpoint. Do not retry an ambiguous - start. -5. Poll only the returned retained Compilation identity. -6. Fetch and verify the existing Compilation artifact contract. -7. Materialize exact files and modes into a sibling temporary directory, verify the tree, recheck that the target - remains absent, and atomically rename it into place. Remove the task-owned staging directory on every - pre-rename failure. - -Artifact extraction accepts only relative ASCII path components and regular files with canonical `0644` or `0755` -modes. It rejects `.`, `..`, `.git`, symlinks, special files, duplicate paths, digest mismatches, and any file not -declared by the verified manifest. Verification re-reads the staged tree before the rename. - -Once the Service has returned a validated Compilation identity, every later status, artifact, or local -materialization failure must retain that identity in its structured error. An ambiguous start (including a -validated timeout or server problem response) reports that the outcome is unknown and never sends a second start; -the current Service has no collection read or idempotency key that can recover a Compilation whose response was -lost before its identity reached the client. The first packet therefore stops explicitly and does not claim -automated recovery for that rare path. A future Service packet may add a client-generated idempotency key or an -equivalent exact lookup; until then, neither an agent nor the CLI may turn an unknown outcome into another Compile. - -This mode performs no GitHub Publication, formatter, repair, merge, or Git initialization. It creates no Service -API or artifact format. Without `--output`, `firstdraft plan compile` retains its current GitHub Publication -behavior and URL-only success output. CLI tests exercise that zero-flag route, and its exact-head hosted matrix owns -the regression proof; packet 3 does not create a throwaway Publication merely to repeat that unit of evidence. - -The direct mode is noninteractive. Structured failures tell an agent whether the path, Plan, Analysis, Compilation, -artifact, or materialization failed. - -## Packet 2: one Drawing Board Dev Container for both phases - -Exact implementation candidate before this document: - -- repository: `firstdraft/drawing-board`; -- base: `0434aa330c51e1771c24b61a22dd8096c614e1d7`; -- head: `5788de045d2f39842b7b3d692620aa00d2efe32b`; and -- tree: `9a0c52d2ed8bcbe5c13bc22245ffb85c0aca0f11`. - -Drawing Board uses Compose and reuses the generated Foundation runtime instead of maintaining a second Rails -environment: - -- the active Rails Ruby 4.0.5 image and Dockerfile shape; -- Node 24.18.0; -- PostgreSQL 18 with the generated parent-volume topology and health gate; -- Selenium and the generated DB/Capybara environment; -- ports 3000 and 5432; and -- the same noninteractive toolchain PATH. - -Drawing Board-specific state remains its workspace root, persistent agent homes, and agent installation. The parent -repository ignores `/application/`. - -The artifact intentionally contains no `.git`. A nested app would otherwise let Git-sensitive generated checks -discover the parent Drawing Board repository. Drawing Board therefore owns `script/initialize-application`, which -accepts a safe application path (default `application`), requires a directly materialized application, creates a -nested `main` repository and parentless initial commit, and proves that the committed path/blob/mode inventory equals -the on-disk application tree at initialization time. It does not re-verify that tree against a retained Service -manifest. Running it before application setup or user edits is a workflow rule; its ignored-state hard stop catches -setup byproducts but cannot detect an arbitrary edit to a tracked generated file. It honors the generated -`.gitignore` and force-stages only the two exact current artifact-owned files under `.firstdraft`. Any other ignored, -derived, or local path is a safe hard stop rather than an invitation to commit `.env`, keys, dependency trees, or an -unknown future artifact file. It does not lint or normalize the generated bytes. The CLI remains transport-pure. - -`script/application-smoke` verifies the real nested repository before dependency, database, or server work, then -runs generated setup, PostgreSQL readiness, and the complete generated `CI=1 bin/ci`. It does not patch generated -bytes. Drawing Board's ordinary hosted contract executes a hermetic initializer fixture, including ignored -`.firstdraft` bytes, executable modes, trailing whitespace, safe-path rejection, and hostile ambient Git state. - -## Packet 2.5: release and authoring bridge - -Packets 1 and 2 cannot form a fresh user journey merely as source branches. The coherent delivery tuple is: - -1. Integrate the direct-output CLI as backward-compatible `0.2.1`, retaining zero-flag Publication. -2. Update the authoring Skill to `0.2.1`, require exact CLI `0.2.1`, and teach two separate completion modes: - direct `firstdraft plan compile --output ./application` in a shared workspace, or zero-flag GitHub Publication. - For Drawing Board direct output, the Skill issues the relative path only from the physical workspace root. The - agent selects and states the mode before the final Plan/GapSet approval, so approval covers the intended local or - external effect; it never switches modes to recover from an ambiguous start. -3. After explicit release authorization, publish the exact reviewed CLI `0.2.1` package under the `next` dist-tag - and verify the immutable registry artifact and Git provenance. -4. Confirm that staging advertises the API contract required by the pinned CLI. Update Drawing Board's exact CLI - version and Skills revision together, then run its real Dev Container contract. In that same packet, update - Drawing Board's README, `AGENTS.md`, `CONTRIBUTING.md`, setup banner, and this plan's status lines. The README needs - two explicit completion branches, including `script/initialize-application` for direct output, rather than a - wording-only change; zero-flag Publication remains the separate-repository branch. - -The Skill preserves exact Plan and GapSet review, conditional state, credentials, retained-identity recovery, and -the explicit stop on an ambiguous start with no retained identity; it routes transport and container details to -their owning tools rather than copying them. Drawing Board continues to install Skills from an exact Git revision, -so plugin publication or catalog promotion is not required for this packet. CLI `latest`, plugin publication, and -catalog promotion remain separate release choices. - -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`; -- Skills `0.2.1` at `160d33a5a7d9f9b2282729ecfd3b2e24a1123143`, tree - `6f3db12c017e884d8b14c66f7d82e64229ec2073`, installed by Drawing Board from source; and -- staging advertising First Draft API contract `0.3.0`. - -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 installed Skills from that exact source revision at that boundary, so the unpublished plugin package -did not block the template. CLI 0.2.2 retains absent `./application` output and zero-flag Publication while adding -explicit current-root -adoption. At that successor-pin boundary, the beginner journey still selected `./application`; the current guide's -root-first choice is recorded above. - -## Packet 3: one real non-prebuilt Codespace journey - -After packets 1 and 2 are integrated into a coherent candidate tuple, confirm that staging serves the API contract -required by that tuple, then exercise a newly created Drawing Board Codespace without relying on a prebuild: - -1. Confirm the Drawing Board authoring/agent setup still works, the pinned Skill advertises both completion modes, - and the active agent can locate the retained design context without being retaught it in the test prompt. -2. Author or load one reviewed Foundation Plan and configure an approved staging API token. -3. From the physical Drawing Board workspace root, record the current directory and run - `firstdraft plan compile --output ./application`. The Skill must not issue that relative path from inside the - generated application or another directory. -4. Prove that no GitHub Publication or generated-repository creation occurred by retaining the CLI request sequence, - the Project's Publication route before and after, and the GitHub repository inventory before and after. -5. Run `script/initialize-application application`. -6. From the Drawing Board root, run `script/application-smoke` to prove nested-Git isolation, exact Ruby/Node pins, - PostgreSQL compatibility, readiness, and the complete generated CI. Then enter `application`, boot `bin/dev`, - and verify the app through the forwarded web port in a real browser. -7. Ask the same agent to explain one Plan decision from the retained design context and make one bounded application - change that follows it, then run a focused generated-app check. - -### Observed Packet 3 boundary on 2026-08-28 - -A fresh private repository and Codespace exercised the exact Drawing Board candidate without a prebuild: - -- Drawing Board source `f93d54a2a55ca7d06abe072424092b1dd0544117`, tree - `ade2a7079299d84cfd746c241aff905b3cd0115b`, was copied into one parentless test-repository commit - `fd73196251e341893f8e0496e4d1ba6765c89f49` with the same tree. -- One `basicLinux32gb` East US Codespace reached `Available` 404.5 seconds after its create request. Its runtime - reported CLI `0.2.1`, Skills `160d33a5a7d9f9b2282729ecfd3b2e24a1123143`, Claude Code `2.1.226`, and Codex - `0.147.0`; the Dev Container smoke and direct-output capability checks passed. -- The approved Neighborhood Guide Plan SHA-256 was - `1e88f919436d779176abd115f79f84293d4ea4579d21cc07bdc64db3b1a2a962`. Analysis - `01a04a08-e38a-7708-accb-d2980cfc0c7f` returned a valid zero-record GapSet with SHA-256 - `e1d40a25d442b18380882e644ff1e4d5a6191159eb3b0cdaff258f20f7ad3fc7` before the owner approved direct mode. -- Exactly one direct Compile started. Compilation `01a04a0d-3484-7e29-b743-0c77b96db063` succeeded with artifact - SHA-256 `0f26014b38d64816ce4b7934e969ce9a3db715a96f3789df609a45b81bf35188`, 479,770 artifact bytes, - 168 output files, and manifest SHA-256 - `08269fa09226d41d89894dbef1f0a26cbd51c7fc47b7ca81d5085f44c8480d1c`. The emitted submitted Plan and - GapSet bytes matched the approved inputs, and the new directory had no Git metadata. -- The Project's Publication route returned exact `404 publication_not_found` before and after Compile. The owner's - 551-repository GitHub inventory was byte-identical before and after, so the direct path created no Publication or - generated repository. -- `script/initialize-application application` produced parentless commit - `3f763a84ceab6d7f3564f382bc77cce267a528f1`, tree `11a52026505434bd3242c9cab94d49ed68638681`, on - nested `main`. `script/application-smoke` passed setup, PostgreSQL 18.6, readiness, 56 Rails tests with 209 - assertions, seven system tests with 34 assertions, and the complete clean generated CI in 69.22 seconds under - Ruby 4.0.5 and Node 24.18.0. -- A real browser rendered `It works. · Neighborhood Guide` and the empty `Places · Neighborhood Guide` index - through an authenticated localhost forward to the same Codespace process. The ordinary private Codespaces - `*.app.github.dev` URL instead reached Rails' blocked-host page. The generated Rails development configuration did - not admit that exact Codespaces host; broadening the Drawing Board container environment would not preserve Rails' - exact host boundary. This is a generated Foundation target defect, not a successful ordinary forwarded-port - observation, and requires a target-owned correction before the colleague Codespaces browser journey is complete. -- The single task token was revoked after proof and the exact credential then received `401 authentication_required`. - The revoked value was removed from the Codespace, and the Codespace stop was requested. - -This run proved agent and Skill installation/discovery, but neither installed agent was signed in inside the -Codespace. The active external agent drove the exact Skill sequence over SSH, loaded a previously reviewed Plan, -and obtained fresh owner approval of its exact Plan, GapSet, and direct effect. It did not perform the new -in-Codespace conversational authoring pass, boot the browser process through step 6's `bin/dev` wrapper, or perform -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. - -### Observed successor qualification on 2026-09-01/02 - -One fresh template-derived, non-prebuilt Codespace completed the successor journey: - -- Drawing Board `117a45e040ce579f84aa69dd8968a560301199bc`, tree - `af28087081d85fa93cd82969b057914e3a73d29a`, was copied byte-for-byte into parentless template-repository commit - `d228b0782122ce4f0625fd1cc08b3a73f40313c9`. The public workspace-image manifest - `sha256:06602be5cc829d5142c12b06c505dbf8353a3ade6751ca4bf01a785ea2c3e6e3` was retrievable without credentials. -- The sole `basicLinux32gb` East US Codespace `fd-successor-117a45e-0901-5wwqjwwxj27vrr` reported no prebuild. It - reached `Available` 197 seconds after creation and the first SSH probe completed in six seconds. Runtime setup - installed CLI `0.2.2`, Skills `0a765f88d1cd500168e18ce1adda03802773f35e`, Claude Code `2.1.226`, and Codex - `0.147.0`, with both Skill links targeting the exact source checkout. -- Staging advertised API contract `0.3.0` and its web and worker used Service - `cc72dad5b26b887f3f21496b568b80678ceac47f`, tree `4aea5019e2d9e43031b68a03d2129bfca4d0013e`. - The same signed-in Claude session `8615af73-f549-461c-8155-7818785d3c0d` explained the approved Neighborhood - Guide Plan, submitted its exact SHA-256 `1e88f919436d779176abd115f79f84293d4ea4579d21cc07bdc64db3b1a2a962`, - and retained Analysis `01a06000-5908-715f-936a-4d448a818705`. The reviewed GapSet was empty, with SHA-256 - `8126a9155702c201da5d06013366f1afb824e6bd7100c5866be5ff8b1282684e`. The observed releases were Analyzer - `foundation-plan-rails/application-2026-08-28-reviewed-realization` and Compiler - `foundation-plan-rails/compiler-application-2026-08-28-reviewed-realization`. -- After explicit approval of those exact bytes, gaps, and direct effect, the session invoked exactly one - `bin/firstdraft plan compile --output ./application`. Compilation - `01a06006-8c58-7206-b335-4d346ebfe8da` succeeded with artifact-source SHA-256 - `a9d7b0a67748073f8ae0d867daada532f48b357c4a04b3cb4dbbfbf523e00eba`, manifest SHA-256 - `f3b0175f5be0a587247af14a2ea29f0e61d79f2a8676e5fefd2ccce4fb036244`, 494,373 bytes, and 169 files. Service - inspection proved a zero-to-one Compilation count and no Publication. The owner's 556-repository GitHub - inventory was byte-identical before and after Compile, so the Service created no repository. -- `script/initialize-application application` produced parentless nested commit - `248c19fc76adccb47056aca1b3d6ac28e0e35d42`, tree `a025f870dc6cf8bfcf7e52e8d6d3f9c3690be5c5`, - on clean `main` with no remote. The unchanged generated app then passed root `script/application-smoke`; its - `script/selenium start` resolved the Compose project from the runtime container label without a fallback. The - smoke passed setup, PostgreSQL readiness, 60 Rails tests with 247 assertions, seven system tests with 34 - assertions, and complete CI in 83.41 seconds under Ruby 4.0.5 and Node 24.18.0. The first external noninteractive - SSH invocation lacked Codespaces' normally exported name/domain variables and stopped before database - preparation; the exact app passed when the SSH harness supplied those platform values. This is an external-harness - boundary, not an integrated-terminal source repair. -- A live terminal `bin/dev` served the app through its ordinary private - `fd-successor-117a45e-0901-5wwqjwwxj27vrr-3000.app.github.dev` URL. The remote extension host carried - `--supportGlobalNavigator` and logged no `PendingMigrationError`. An authenticated GET returned 200; a genuine - Place form POST with GitHub's rewritten `Origin: http://localhost:3000` returned 303 and committed exactly one - row; the same-session missing-CSRF negative returned 422 without another row; and an altered forwarded Host - returned 403 while the exact Host returned 200. After server shutdown, the guarded port refresh ended with no - listener and private visibility. -- The same agent explained the Plan's public Place CRUD decision, changed exactly the Places index lede to make that - decision visible, and passed YAML parsing, an exact translation check, and the focused scaffold integration test - at one run and one assertion. The edit remained uncommitted in the nested no-remote repository for inspection. -- The task-scoped staging token was revoked, the old credential received `401 authentication_required`, credential - material was removed from the Codespace, the agent logged out, and the sole Codespace reached `Shutdown` after - one stop request. - -This exact observation moves the workspace image's `comparison_codespace` result to `passed` and completes the -beginner `./application` qualification. It does not qualify `--output .`, arm64 image runtime, Publication, -deployment, or persistence of the unpushed nested application after the disposable Codespace. - -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. - -## Compile into an existing root - -For current root eligibility, archive paths, and later authoring commands, use the -[root-Compile instructions](README.md#5-describe-your-app). The CLI owns the transaction details. The following -contract records CLI 0.2.2 for the dated qualification below; it is historical. - -CLI 0.2.2 implemented POSIX current-root adoption in any eligible real directory rather than recognizing Drawing -Board specially: - -```sh -bin/firstdraft plan compile --output . -``` - -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 now selects root adoption for the internal-alpha handoff so the same conversation can move from Plan -to inspectable source and an ordinary feature commit in one repository. The older nested path retains its separate -initializer/smoke workflow. Root mode uses generated `bin/setup` and `bin/ci` at the root, starting Selenium through -the generated `.devcontainer/compose.yaml` in the running container's Compose project. Compose waits for Selenium's -health check; see the [current browser-testing instructions](README.md#7-open-your-app). The dated observations -below retain the helpers and source they exercised. Current fallback qualification remains under -[Service #729](https://github.com/firstdraft/firstdraft/issues/729) and -[#730](https://github.com/firstdraft/firstdraft/issues/730), including private preview and fresh-template attachment. - -### Observed current-root qualification on 2026-09-02 - -One fresh, non-prebuilt Codespace completed that separate observation; it did not change the beginner default at -that time: - -- Drawing Board main `6f36fa22901ff818b7d369fb92ce042ec62a6a6f`, tree - `4b15c5ade7465e16e7c922b996470b88b082a23e`, was copied byte-for-byte into parentless test-repository commit - `93f7b99777f1466c20548d9bacb3317f98cad4f1`. The public workspace image remained - `ghcr.io/firstdraft/drawing-board-workspace@sha256:06602be5cc829d5142c12b06c505dbf8353a3ade6751ca4bf01a785ea2c3e6e3`. - The East US Codespace reported no prebuild, reached the first retained `Available` snapshot 222 seconds after the - create request, and completed its first SSH probe eight seconds later. An earlier pre-authentication Codespace - expired after the configured one-hour - retention with no staging credential, Plan, Analysis, Compile, or Publication; it is not part of the qualified - external-operation sequence. -- Runtime setup installed CLI `0.2.2`, Skills `0a765f88d1cd500168e18ce1adda03802773f35e`, Claude Code `2.1.226`, - and Codex `0.147.0`. Staging advertised API contract `0.3.0`; its observed web and worker Service revision was - `cc72dad5b26b887f3f21496b568b80678ceac47f`, with the 2026-08-28 reviewed-realization Analyzer and Compiler - releases. One signed-in Claude session `5ecb8613-813c-4a08-b60a-7d7b37d4ffae` retained the design context through - Plan review, Compile, and the later source edit. -- The session explained the approved Neighborhood Guide Plan at SHA-256 - `1e88f919436d779176abd115f79f84293d4ea4579d21cc07bdc64db3b1a2a962`, retained valid Analysis - `01a063fc-9e09-7bd7-b097-441f2afbd68d`, and presented its empty GapSet at SHA-256 - `45bad750de4a3674ab7c5a2bb578cbd7bcc647a91ee3e7df5f514b15df32bd08`. After explicit approval of those - exact bytes, the empty gap result, and the relocation effect, it invoked exactly one - `bin/firstdraft plan compile --output .` from the workspace root. Compilation - `01a06400-b750-7753-beb9-5f7aa86e4e49` succeeded with artifact-source SHA-256 - `77caf405d9fa104a5c301722b35cf543e5c293a4e6d591e86895b0085a874630`, manifest SHA-256 - `51ec1026e1adf41cbf857b34bcd20d55cff640aa91b36176e9bdc1e312cbd7e9`, 494,373 bytes, and 169 files. - Service inspection proved a zero-to-one Compilation count and no Publication. The owner's 557-repository GitHub - inventory was byte-identical before and after Compile, so the Service created no repository. -- Root adoption moved all 15 preexisting non-Git top-level entries under `design/`, preserved `.git`, its original - commit and remote, staged the 37 tracked moves plus 169 generated paths, left ignored `.env`, `.firstdraft`, and - `tmp` material relocated under `design/` unstaged, and left no nested `.git`, `application/`, or recovery journal. - Committing the staged - result produced `fba5ce7daa55c2c5013bc1910303e53283817fc1`, tree - `0a646f8162bf608476847ab4041a92caae0af935`, as the child of the original template commit. This successful run - rechecked the transaction's clean-root, absent-`design`, absent-journal, exact-index, and Git-preservation fences; - it did not induce a failed transaction to repeat the CLI's separate rollback tests. -- Root `bin/setup` passed in 115.21 seconds. The first unchanged `CI=1 bin/ci` made one current-container boundary - visible: all non-system gates passed, but seven system tests could not resolve `selenium` because the already-live - Drawing Board container had started only its original `rails-app` and PostgreSQL services before root relocation. - Starting the already-declared sibling through relocated `design/script/selenium start` took 121.68 seconds on a - cold image pull. The unchanged CI then passed in 66.71 seconds internally and 76.81 seconds including wrapper - cleanup: 60 Rails tests with 247 assertions and seven system tests with 34 assertions. The helper stopped Selenium. - This required no generated-source patch, custom browser service, or Codespace rebuild, but root adoption does not - yet have the nested path's one-command `script/application-smoke` orchestration inside the still-running container. -- Root `bin/dev` reached readiness through the ordinary private forwarded URL. A genuine Place form POST returned - 303 and committed exactly one row; a missing-CSRF-token POST returned 422 with state unchanged; the exact forwarded - Host returned 200 and an altered Host returned 403. After shutdown, the guarded port refresh completed in ten - seconds with no listener and private visibility. -- In the same Claude session, the agent recovered the Plan's public Place CRUD decision, changed only the Places - index lede to make that decision visible, and passed safe YAML loading, exact I18n lookup, and the focused scaffold - integration test at one run and one assertion. The clean commit - `107b2b338b56b90c6330d565a71fecb8e42430f6`, tree - `f71f86ac968448c213a19fdfbccd21e5b30f32c4`, retained the root-adoption commit, original history, remote, submitted - Plan, and GapSet. -- The task-scoped staging token was revoked and then received `401 authentication_required`; credential and transient - files were removed; Claude reported `loggedIn: false`; Selenium and the Rails listener were absent; port 3000 was - private; and the exact Codespace reached `Shutdown` after one stop request. No Publication, package release, - deployment, or application-repository push occurred. - -The comparison supported the mode split. At that checkpoint, root adoption preserved one Git history and let one -agent carry the reviewed design into ordinary Rails work without a nested repository or second workspace. It moved -First Draft commands under `design/`, required an immediate inspection and commit, and used the relocated Selenium -helper inside the container that predated the move. The nested path used its dedicated initializer and smoke. -Those observed paths remain historical. Use the current [root-Compile instructions](README.md#5-describe-your-app) -and [browser-testing instructions](README.md#7-open-your-app). - -## Ownership and sequencing - -- Service owns Compilation lifecycle and artifact bytes; no Service change is needed for packets 1 or 2. -- 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 direct mode, output-path validation, polling, artifact verification, exact materialization, and the - current-root relocation transaction. Drawing Board selects approved root adoption for the internal alpha 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. -- GitHub authentication and the existing Publication path stay intact. -- 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. The dated nested and root observations above remain separate proof; -changing the guide's preferred mode does not expand either observation to a new candidate or a container rebuild. - -## Review questions - -1. Does the mode split preserve the no-flag Publication contract while making direct Compilation genuinely - publication-free? -2. Is Drawing Board the correct owner for nested Git initialization, or should another integration layer own it? -3. Does reusing the generated runtime create any hidden coupling or omit a requirement needed by either authoring - or generated development? -4. Are the absent-directory and later root-relocation boundaries safe, understandable, and proportional for an - agent-first pre-alpha workflow? -5. Is the three-packet landing order sufficient to prevent a false end-to-end claim or incompatible candidate - tuple? diff --git a/PREBUILD_EXPERIMENT.md b/PREBUILD_EXPERIMENT.md deleted file mode 100644 index 6966b35..0000000 --- a/PREBUILD_EXPERIMENT.md +++ /dev/null @@ -1,135 +0,0 @@ -# Tool preparation in Codespaces prebuilds - -Measured September 8, 2026 Central time (September 9 UTC). This records the first hosted prebuild comparison; -the [second round](STARTUP_FOLLOWUP.md) tests the remaining avenues and owns the later recommendation. -Keep the existing template prebuild and installer. -Moving tools into the prebuild saved **4.6 seconds, about 7.7%**, across two comparable launches per variant. That -small sample does not establish a reliable five-second improvement, and does not justify maintaining the extra -installation paths. No container, image, pin, or CI change was retained from this first experiment. - -## What was tested - -- Baseline: Drawing Board [`69020938`](https://github.com/firstdraft/drawing-board/tree/69020938f08cc9731c84701646f9d1847643b8e7), - tree `f5346596a628d8ad32bcfd3a060040cd4f646a47`, with the existing main prebuild. -- Prototype: [`12c667b9`](https://github.com/firstdraft/drawing-board/commit/12c667b9cb36629dd2c8b76b7deb39f2eaf0f387), - tree `ed4add77c68816a05f835bf2b6d22a257e2d17a9`, retained on - `codex/codespaces-prebuild-prototype-20260908` for inspection. -- Same image: `ghcr.io/firstdraft/drawing-board-workspace@sha256:06602be5cc829d5142c12b06c505dbf8353a3ade6751ca4bf01a785ea2c3e6e3`. -- Same pins: Claude Code `2.1.226`, Codex `0.147.0`, First Draft CLI `0.2.2`, Skill - `8ae02160b44b40d21ec432cf2d1ab2772f9aae6b`, Ruby `4.0.5`, Node `24.18.0`, PostgreSQL `18`. -- Every sample used `basicLinux32gb` (2 CPUs, 8 GB RAM) in `EastUs`. No agent or First Draft credentials were supplied. - -The prototype split installation into `.devcontainer/prepare-agents`, called by `updateContentCommand`. It installed -the exact npm pins under `~/.local` and fetched the exact Skill under `~/.local/share/firstdraft/skills/`, outside -the mounted cache and agent configuration directories. Post-create configured the user environment and Skill links, -reusing matching tools or installing missing/mismatched pins. Both phases executed CLI version checks. - -This follows GitHub's documented boundary: prebuilds include `onCreateCommand` and `updateContentCommand`, take a -snapshot, then run remaining lifecycle commands after restoring it on a fresh VM. `postCreateCommand` is excluded -from prebuild creation. See [GitHub's prebuild documentation](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/about-github-codespaces-prebuilds). - -## Hosted timings - -Start is the Codespaces API `created_at`; finish is the timestamped `Drawing Board setup complete.` marker in the -creation log. The API's start timestamp has one-second resolution. “Before setup” includes provisioning and -container work; “Setup” is post-create. The endpoint is installed tools, not browser-editor readiness, sign-in, -first model response, or Compilation. Every included launch passed `script/devcontainer-smoke` after setup. - -| Sample | Prebuild | Created UTC | Before setup | Setup | Total | -|---|---|---|---:|---:|---:| -| Baseline A | yes | 00:30:26 | 24.588 s | 35.784 s | 60.372 s | -| Baseline B | yes | 00:34:14 | 24.314 s | 35.923 s | 60.237 s | -| Prepared A | yes | 00:49:22 | 25.958 s | 28.500 s | 54.458 s | -| Prepared B | yes | 00:50:52 | 27.276 s | 29.598 s | 56.874 s | -| Prepared, no prebuild | no | 00:37:43 | 236.388 s | 1.468 s | 237.856 s | -| Direct template, baseline | yes | 01:05:45 | 22.724 s | 62.177 s | 84.901 s | - -The first four launches used the repository Codespaces API with the corresponding branch. Baseline mean was -60.3045 seconds; prepared mean was 55.666 seconds. Baselines ran before the prepared pair because its prebuild -needed to finish first; this was not a randomized trial. npm reported 29 seconds in each baseline. Prepared -post-create did no npm installation, yet still took 28–30 seconds. - -The no-prebuild sample used the same prototype. About 204 seconds elapsed before `onCreateCommand`; tool preparation -then took 29.477 seconds in `updateContentCommand`, including npm's reported 25 seconds. Its 237.856-second total -supports the value of a ready prebuild, but is one cold observation, not a matched cold baseline for unmodified main. - -The last sample used the actual **Use this template → Open in a codespace** UI on main. npm reported 41 seconds. -GitHub created parentless commit `3d3bdb4f6131b06a942c8eb126ebbeee39607a59`, with the exact baseline tree and no -remotes. Smoke passed twice. From the menu click at 01:05:43.588 UTC to setup completion was 86.313 seconds. -Together with the [earlier 88.1-second template observation](STARTUP_INVESTIGATION.md#actual-startup-costs), this -shows why neither the initial ten-second local install nor the paired one-minute hosted launches is a startup SLA. - -## Why moving installation did not remove its whole cost - -A separate diagnostic launch of the prepared snapshot completed in 56.055 seconds. A read-only process sampler -observed Claude's first `--version` invocation waiting on file reads: - -| Observation | First sample | Last waiting sample | -|---|---|---| -| UTC | 01:02:06.939 | 01:02:20.471 | -| Process elapsed time | 5 s | 19 s | -| Process state | `Dl+` | `Dl+` | -| Wait channel | `folio_wait_bit_common` | `folio_wait_bit_common` | -| `/proc/PID/io` `read_bytes` | 28,246,016 | 207,503,360 | -| Displayed CPU time | 00:00:00 | 00:00:00 | - -Claude finished at about 20 seconds elapsed. Codex subsequently showed a smaller similar file-read wait. These -observations support cold filesystem reads after snapshot restore as the remaining delay; they do not establish -GitHub's internal storage implementation. No hidden installer was observed. Removing version checks would leave -the first CLI launch unmeasured, shifting that wait to the user rather than demonstrating faster tool readiness. - -The earlier [local image-baking experiment](STARTUP_INVESTIGATION.md#rails-comparison-and-rejected-image-experiment) -reduced setup from 10.153 to 0.655 seconds, including an offline run, but added roughly 230 MB of compressed arm64 -layers and coupled pin changes to image publication. No new baked image was published or benchmarked in hosted -Codespaces in this first round. The [second round](STARTUP_FOLLOWUP.md#published-tool-baked-image) subsequently -published and measured an image that extends the immutable base. These first-round results alone do not prove -that a different image cannot improve startup. - -## Freshness and mounted volumes - -The existing prebuild can carry an older source checkout. After pushing diagnostic commit -`3d8c5b5811a452ef06ea0885620be6de15bf4f88` to the test branch, a new `prebuild: true` Codespace actually checked out -`12c667b9cb36629dd2c8b76b7deb39f2eaf0f387`; its files and tree lacked the diagnostic change. The new prebuild was -unavailable. Thus post-create reconciles pins from the checked-out source, not necessarily the latest remote head. -For new-pin qualification, wait for a successful prebuild of the intended revision and inspect the actual tree. -Do not add automatic Git pulls to unpublished template workspaces, which start without an `origin`. - -The prototype's hosted prebuilds retained approximately 585 MB of global npm modules, 2 MB of Skill source, and -221 MB of npm cache. Mounted agent/cache directories had usable ownership; normal hosted smoke passed. Separately, -restoring the local prototype with fresh mounted home volumes but skipping `onCreateCommand`'s ownership repair -failed when `.claude` was root-owned. Offline fresh-volume tests passed only after that repair. This is a limitation -of the experimental setup, not an observed failure of the current main Codespace. It would need resolution before -adopting the split for that reconstruction path. - -Local checks also passed: source contracts, full devcontainer lifecycle, repeated runtime smoke, two offline -post-create runs after ownership repair (0.601 and 0.552 seconds), missing-CLI recovery (8.836 seconds), and stale -CLI `0.2.1` recovery to pinned `0.2.2` (8.001 seconds). Hosted smoke verified all three CLI versions, both Skill links -and the pinned SHA, wrapper resolution, Ruby/Node/PostgreSQL, SSH policy, and Selenium remaining stopped. - -## Prebuild refresh and retained evidence - -The [prototype prebuild](https://github.com/firstdraft/drawing-board/actions/runs/34295552132) passed for `12c667b9`; -its job took 15 minutes 11 seconds. Tool preparation itself took about 10.2 seconds, including npm's reported eight -seconds. The existing [main prebuild](https://github.com/firstdraft/drawing-board/actions/runs/34003313712) had taken -19 minutes 32 seconds. These were different runs with different region coverage, not a prebuild-build-speed -comparison. Refresh latency matters when iterating on pins even though users can start from an older snapshot. -The [prototype CI run](https://github.com/firstdraft/drawing-board/actions/runs/34295932348) also passed. - -Main's configuration was preserved: **Every push**, all five regions, two retained versions, failure notifications, -and prebuild optimization enabled. The experiment temporarily used one region and one retained version. That -configuration and all experiment Codespaces were deleted; the prototype branch and measurements remain. No image -publication, main merge, new user repository, or live Compile was performed in this follow-up. - -Raw non-secret metadata, creation logs, smoke logs, and process samples are retained in the task worktree's ignored -`tmp/prebuild-test/`. The sample identities are: - -| Sample | Codespace | -|---|---| -| Baseline A | `fd-startup-baseline-0908-a-vxxv9x4r5cxqxg` | -| Baseline B | `fd-startup-baseline-0908-b-www7pw6992pw5` | -| Prepared A | `fd-startup-candidate-0908-a-xjjw7j6p6269w6` | -| Prepared B | `fd-startup-candidate-0908-b-7gg96g64pcxv5g` | -| Prepared, no prebuild | `fd-startup-candidate-0908-cold-g4w9qrqvc55g` | -| Direct template | `effective-rotary-phone-pxxr4xvggc6qxp` | -| File-read diagnostic | `fd-startup-process-0908-g45j6597rhvq66` | -| Older-source diagnostic | `fd-startup-trace-ready-0908-6jjgqjqjjcxrgw` | diff --git a/README.md b/README.md index 3412611..0a7037d 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Publish **inside VS Code** so it adds the new remote and pushes your commits. Pu [VS Code's publishing guide](https://code.visualstudio.com/docs/sourcecontrol/repos-remotes#publish-to-github) and [GitHub's template publishing instructions](https://docs.github.com/en/codespaces/developing-in-a-codespace/creating-a-codespace-from-a-template#publishing-to-a-repository-on-github). -Your agent can also [publish from the Codespace terminal](CONTRIBUTING.md#publish-from-the-codespace-terminal) +Your agent can also [publish from the Codespace terminal](#publish-from-the-codespace-terminal) after you approve the private repository's name. Ask it to **Create GitHub repository** to save the existing workspace privately; this uses the Codespace's existing GitHub credential and does not Compile again. @@ -245,8 +245,8 @@ An explicit empty start needs no sample-loading step. Report data omitted by rev inventing replacements. Do not reset the database to fill the preview. Reseeding uses the generated lookup values; after you edit a sample, it can recreate the original record instead of updating your edit. -Do **not** run `script/initialize-application` or `script/application-smoke` after root Compile, including their -copies under `.firstdraft/design/`. They are only for the optional nested application. You can ask: +Do **not** run `script/initialize-application` after root Compile, including its copy under `.firstdraft/design/`. +It is only for the optional nested application. You can ask: > Follow the generated README to set up the app. Load our reviewed samples only if needed, then show me their > related records and states in the browser. If we chose an empty start, show me that empty state instead. If an @@ -367,6 +367,38 @@ Stopping a Codespace preserves its files; deleting it does not. Your source is yours to work on with another editor, agent, or developer. +## Publish from the Codespace terminal + +An agent can publish an unpublished direct-template Codespace using its built-in `GITHUB_TOKEN`. Use GitHub's +[Codespaces publication endpoint](https://docs.github.com/en/rest/codespaces/codespaces#create-a-repository-from-an-unpublished-codespace); +the general `gh repo create` and `POST /user/repos` routes rejected that token in the live test. No additional login, +PAT, or First Draft API command is needed for this route. + +First inspect and commit the baseline without real credentials or private CLI state, retaining reviewed public +demo logins. Confirm that no remote exists and obtain approval of the personal owner, repository name, and private +publication. If a remote already exists, use that approved remote instead. Run this from the Codespace's integrated +terminal, substituting the approved name: + +```sh +gh api --method POST "/user/codespaces/$CODESPACE_NAME/publish" \ + -f name="my-app" -F private=true \ + --jq '.repository | {full_name, private, html_url}' +``` + +Verify the returned owner/name and `private: true`. This creates the repository, associates the Codespace with it, +and grants its token write access. It does not add local `origin` or push commits. From the generated application's +Git root, use the returned repository URL: + +```sh +git remote add origin https://github.com/OWNER/REPO.git && git push -u origin HEAD +``` + +Verify the remote baseline commit and retained `.firstdraft/design/` files before continuing. Later saves use +ordinary commits and `git push`. If creation succeeds but the push fails, keep the repository and repair the reported push failure; +do not create another repository. After an ambiguous API result, inspect the Codespace's repository association and +the approved repository read-only before any retry. The [live receipt](https://github.com/firstdraft/dockerfiles/blob/main/drawing-board/docs/STARTUP_INVESTIGATION.md#publication-credentials) +records private creation and two pushes using only the built-in token, with a small Git fixture. + ## Optional: keep the app in `application/` Choose this mode **before** Compile if you want the Drawing Board to remain at the root. Approve @@ -375,14 +407,16 @@ from the Drawing Board root before setup or source edits: ```sh script/initialize-application application -script/application-smoke +cd application +bin/setup --skip-server ``` The initializer gives `application/` its own initial Git commit. Continue inside that directory with `bin/dev` and normal Rails commands. The parent Drawing Board ignores it, so pushing the Drawing Board does **not** save the app. Ask the agent to create and push an approved private application repository before deleting the Codespace. If `application/` already exists, preserve it and ask the agent how to continue; do not overwrite or delete it to -make room for another Compile. Do not run these nested helpers after root adoption. +make room for another Compile. Do not run the nested initializer after root adoption. Run this app's ordinary tests using its README and the +[generated Compose browser-testing recipe](#7-open-your-app). ## Optional: deploy later @@ -448,9 +482,9 @@ reflects its pre-Compile layout. If a reconnect says the private-port refresh fo rerunning `.firstdraft/design/script/refresh-codespaces-private-port` if you retained that helper; do not weaken its listener guard. The current template skips this refresh when neither the original nor archived helper is executable, so removing the optional planning archive does not require recreating its tools. An older Codespace can retain its -earlier attach command; see the [lifecycle details](CONTRIBUTING.md#work-on-the-template). +earlier attach command; see the [lifecycle details](https://github.com/firstdraft/dockerfiles/blob/main/drawing-board/README.md#work-on-the-template). If a Codespaces forwarded-port URL reaches Rails' **Blocked hosts** page, stop and tell your agent. Do not disable Rails host checks; the generated target must own that correction. -Maintaining this template? Read [CONTRIBUTING.md](CONTRIBUTING.md). +Maintaining this template? Read the [maintainer guide](https://github.com/firstdraft/dockerfiles/blob/main/drawing-board/README.md). diff --git a/STARTUP_FOLLOWUP.md b/STARTUP_FOLLOWUP.md deleted file mode 100644 index 7e0a3aa..0000000 --- a/STARTUP_FOLLOWUP.md +++ /dev/null @@ -1,261 +0,0 @@ -# Remaining Codespaces startup avenues - -Follow-up measurements on September 8, 2026 Central time (September 9 UTC). This extends -[the first prebuild experiment](PREBUILD_EXPERIMENT.md) with browser-attached launches, actual unpublished template -launches, cold binary reads, PostgreSQL readiness, and a published tool-baked image. - -Keep the existing workspace image, tool installer, main-branch prebuild, and overlapping editor startup. Across -25 fresh hosted samples, the alternatives did not demonstrate a substantial, repeatable improvement to the usable -template experience. The retained container change makes PostgreSQL's existing five-second health check use TCP. -This fixes an initialization race; it is not a claimed startup speedup. - -## Measurement boundaries - -All hosted samples use `basicLinux32gb` (2 CPUs, 8 GB RAM) in `EastUs`. Start is the Codespaces API's `created_at` -(one-second resolution); tool readiness is the timestamped `Drawing Board setup complete.` creation-log marker. -Every included sample passed the template runtime smoke twice. Actual Git trees and CLI pins were checked; -unpublished-template samples also had no remote. No agent or First Draft credentials were supplied. - -These are small operational comparisons, not randomized statistical trials or startup guarantees. Browser connection, -workspace trust, installed tools, and first model response are distinct endpoints. The API sometimes continued to -report `Queued` while the creation log showed the container already building; API state is not a reliable way to -divide provisioning from build time. - -The ordinary template's default branch and prebuild configuration were preserved. A disposable private template -provided matching `baseline`, `prepared`, `optimized`, `baked`, and `waitfor` branches. Changing only that fixture's default -branch allowed testing **Use this template → Open in a codespace** against different source trees. The actual menu -used the default branch even when opened from a different branch's repository page. Ordinary repository Codespaces -created with a branch argument are a separate workflow, not an unpublished-template substitute. See -[GitHub's template workflow](https://docs.github.com/en/codespaces/developing-in-a-codespace/creating-a-codespace-from-a-template). - -## Browser and unpublished-template comparisons - -On unchanged Drawing Board main, one new headless repository launch took 59.611 seconds. Two launches with the -browser attached immediately took 74.391 and 91.573 seconds. A direct-template launch took 84.446 seconds. A -prepared-tool repository launch with the browser attached took 99.876 seconds; a process sample observed Claude's -version process still waiting in `folio_wait_bit_common` after 51 seconds, with little CPU use. - -The private fixture supplied a closer comparison of actual unpublished template launches: - -| Template setup | Before post-create | Post-create | Total | -|---|---:|---:|---:| -| Original A | 30.077 s | 70.559 s | 100.636 s | -| Original B | 28.713 s | 75.870 s | 104.583 s | -| Tools prepared in prebuild A | 28.429 s | 68.449 s | 96.878 s | -| Tools prepared in prebuild B | 28.920 s | 65.876 s | 94.796 s | - -Both pairs use the same fixture, existing image, original five-second PostgreSQL cadence, and default browser -connection behavior. Mean totals were 102.610 versus 95.837 seconds, a 6.773-second difference. Preparing tools -removes npm installation from post-create but leaves substantial first-use file reads. Browser-attached and headless -results must not be mixed to claim a template-specific penalty or a guaranteed installation saving. - -## Sequential reads and concurrent warm-up - -The optimized prebuild uses the original image, prepared tools, a cleared npm cache, and the compatible one-second -TCP health check. Its observed npm cache was 28 KB, versus approximately 221 MB in the earlier prototype. Codespaces -reported Docker server `24.0.9-2`, API `1.43`, and the expected TCP health configuration. Source/tree were identical -across all read experiments. - -The sequential-read variant runs `cat` on the resolved Claude executable into `/dev/null`, then runs unchanged -setup. This explicitly reads all 297,831,432 file bytes, allowing the new VM to cache them; it does not contact -Claude's service. This diagnostic is distinct from the ordinary `claude --version` process, whose complete read -pattern was not traced. The two explicit reads took 26.616 and 23.747 seconds. - -Installing and verifying the executable in an image or prebuild saves that installation work. It does not preserve -the builder's warm filesystem cache in the new VM's RAM. GitHub describes restoring the saved container onto a -[fresh virtual machine](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/about-github-codespaces-prebuilds). -The observed first-use delay survived both prebuild preparation and image baking. Reading the entire file at -Codespace startup moved the wait earlier without reducing total readiness time. - -The concurrent variant starts the three CLIs' version checks together, waits for all to succeed, then runs unchanged -setup. It therefore adds one warm version-check pass relative to the sequential control; this measures concurrent -prewarming plus normal setup, not a pure reordering of equal work. Each mode still performs the original pin checks -and completes all user configuration before its final readiness marker. - -| Mode | Post-create A | Post-create B | Mean post-create | Mean total | -|---|---:|---:|---:|---:| -| Ordinary setup | 30.678 s | 27.799 s | 29.239 s | 55.008 s | -| Sequential whole-file read, then setup | 31.796 s | 29.346 s | 30.571 s | 55.243 s | -| Concurrent version warm-up, then setup | 31.101 s | 27.284 s | 29.193 s | 52.794 s | - -Concurrent warm-up did not reduce the setup phase. Its lower total came from a shorter provisioning/container phase -in one sample, before the experiment command ran. Neither warm-up approach justifies extra startup machinery. - -## Published tool-baked image - -The candidate extends the exact existing workspace image; it does not rebuild or re-resolve Features. All 25 existing -amd64 layer descriptors remain identical. Its added layers total **216,618,216 compressed bytes**, making the total -1,330,274,135 bytes versus 1,113,655,919 for the existing image. - -- Image source: [`1c34b62c`](https://github.com/firstdraft/drawing-board/commit/1c34b62cbea9240c7d15dddcc0fc2cde2a0b6287). -- [Candidate build and image verification](https://github.com/firstdraft/drawing-board/actions/runs/34300291665) passed. -- Experimental index: `sha256:39999b32dfc1e02f67044b875bf03ffb2efc4066cb46418657516e50bba5ba4d` in - `ghcr.io/firstdraft/drawing-board-workspace`. -- Runtime control: [`85d916c2`](https://github.com/firstdraft/drawing-board/commit/85d916c2d903018e7c64152dba48e2798ec07d74), - tree `c6e5ba4b2b272c2410c7988be3265fbf3ef989e1`. -- Baked runtime differs only in the image digest: - [`bfcc8947`](https://github.com/firstdraft/drawing-board/commit/bfcc894756dad5eb2c82869ad6e3df8eecbf744a), - tree `290dc0e5e13d19a951731df867070a7d78df026f`. - -Both runtimes prepare or reuse matching tools before post-create, clear npm's preparation cache, and use the same -TCP PostgreSQL health check. The baked image places tools and Skill source under unmounted `~/.local`; its temporary -npm build cache is removed in the same image layer. No stable image tag or production image receipt was changed. -The baked runtime is an experimental consumer, not a qualified production receipt; production receipt checks were -not weakened to admit it. - -An anonymous request reproduced the exact published index. Separate network-disabled runs of both published -platform digests verified Claude `2.1.226`, Codex `0.147.0`, First Draft CLI `0.2.2`, and Skill -`8ae02160b44b40d21ec432cf2d1ab2772f9aae6b`. The amd64 offline check used local emulation; hosted Codespaces supplied -native amd64 CLI/runtime smoke. The workflow's existing image smoke checks Features, SSH, and PostgreSQL rather -than CLI payloads, so the separate offline checks are required evidence for this experiment. - -| Cold sample, no prebuild | Before post-create | Post-create | Total | -|---|---:|---:|---:| -| Existing image A | 211.568 s | 0.856 s | 212.424 s | -| Existing image B | 219.865 s | 0.821 s | 220.686 s | -| Baked image A | 265.172 s | 0.803 s | 265.975 s | -| Baked image B | 272.592 s | 0.789 s | 273.381 s | - -Mean cold creation was **216.555 seconds with the existing image, 269.678 seconds with the baked image**: 53.123 -seconds slower. Container creation through the start of `onCreateCommand` grew from 168.369/174.898 seconds to -246.886/253.317 seconds. Baking reduced the following preparation phase from roughly 28–29 seconds to three, -but did not compensate for that earlier growth. These measurements combine image transfer, extraction, and other -container work; they do not isolate network throughput. They do not support adopting this larger image for cold -creation. - -Prebuilt repository launches took 57.851 and 52.642 seconds with the baked image: a mean of 55.247 seconds, -essentially identical to the existing-image sequential controls' 55.008 seconds. In actual unpublished-template -launches, the baked image took 90.254 seconds and the matched optimized existing image took 81.146 seconds. -That last comparison has only one sample per variant; it supplies no evidence of a win, not a reliable penalty -estimate. Image baking adds publication work to each tool/Skill pin change without a demonstrated startup benefit. - -## Waiting for setup before connecting the editor - -The [`waitfor` variant](https://github.com/firstdraft/drawing-board/commit/911cea7eabffc85d81772e40fab66cabf6f5b8c1) -differs from original main only by `"waitFor": "postCreateCommand"`, with tree -`c95b53f53094465ad24e94c316a5209f8d3cca96`. This delays editor connection until tool setup finishes; the -[Dev Container specification](https://github.com/devcontainers/spec/blob/main/docs/specs/devcontainerjson-reference.md) -defaults to waiting for `updateContentCommand`, allowing post-create work to overlap the editor. - -| Actual template launch | Before post-create | Post-create | Tool-ready marker | Editor observation | -|---|---:|---:|---:|---| -| Wait for setup A | 29.470 s | 36.145 s | 65.615 s | Trust prompt visible by 97.823 s | -| Wait for setup B | 39.481 s | 35.635 s | 75.116 s | Files visible at 95.828 s; trust prompt by 98.386 s | - -Tools finished earlier than in the original template samples, but the editor still had to connect afterward. -In A, the last observation without the trust prompt was at 77.796 seconds; the later observation is an upper -bound, not an exact readiness measurement. In B, the trust prompt appeared between 95.828 and 98.386 seconds. -Manual time spent accepting trust is excluded. The ordinary overlapping baseline already showed the trust prompt -before its 100.636/104.583-second tool-ready markers. Comparing only the earlier setup markers would overstate the -benefit. Keep the existing overlap: these observations do not establish a large end-to-end improvement. - -## PostgreSQL readiness - -The original `pg_isready -U postgres` can succeed against the entrypoint's temporary Unix-socket-only server before -initialization finishes. A local eight-second initialization script made it report healthy about four seconds before -TCP was available. Checking `pg_isready -h 127.0.0.1 -U postgres` instead waits for the final server. - -A startup-only one-second polling interval passed on local Docker 29 but failed in a hosted prebuild with -`can't set healthcheck.start_interval as feature require Docker Engine v25 or later`. The compatible experiment -therefore uses `interval: 1s`, `timeout: 5s`, and `retries: 25`, without `start_interval`. It preserves approximately -the original retry window for prompt failures, not an identical worst-case timeout. Docker documents these settings -in its [healthcheck reference](https://docs.docker.com/reference/compose-file/services/#healthcheck). - -Local fresh and restarted containers became healthy in 1.1–1.2 seconds, versus about 5.1 seconds for the original -cadence. With delayed initialization, TCP appeared at 8.881 seconds and health succeeded at 9.570 seconds. The -compatible option keeps polling every second after startup. Over one 60.05-second idle test, it ran 56 checks and -used 1.880 container CPU-seconds, versus 11 checks and 0.427 CPU-seconds for five-second polling: approximately -0.024 additional CPU cores. This is one local arm64 observation and excludes some Docker daemon/containerd overhead. - -Retain only the TCP correction with the original `interval: 5s`, `timeout: 5s`, and `retries: 5`. The four-second -local saving does not justify sustained faster polling, and the hosted Docker version cannot use the startup-only -alternative. Both the ordinary TCP check and delayed-initialization behavior were exercised locally; the final PR's -existing hosted container smoke validates the retained configuration. - -## Smaller upstream image - -Inspection and package-removal simulation identified approximately **148 MB of compressed selected content** in -Rust compiler packages and Node/npm download caches. This is an opportunity estimate, not an observed rebuilt-image -reduction or a Codespaces speedup. - -The [Rails Ruby Feature](https://github.com/rails/devcontainer/blob/2a4baafe0236449bfc75c63ea07b96acd59b6ee7/features/src/ruby/install.sh) -installs Rust even with the precompiled Ruby path selected. Removing exactly `rustc`, `libstd-rust-dev`, and -`libstd-rust-1.85` in simulations preserved other packages on both inspected architectures. Their selected amd64 -files compressed to 94.6 MB; Node and root npm caches contributed approximately 31.5 and 21.9 MB. Future source -builds of Ruby/YJIT or Rust-based gems would need Rust installed explicitly. - -Keep LLVM: removing it also removed FFmpeg, Mesa and related runtime libraries in the simulation. Keep the ordinary -C toolchain, Ruby headers and native-gem support. Ruby documentation occupied considerable disk space but compressed -to only 2.7 MB. Repeated upstream Git/common-utils layers totaled another 83 MB, but also carry package/version -changes; those entire layers are not proven removable. - -Useful size reductions must happen in the producing image/Feature layer. Deleting inherited files in another -Drawing Board `RUN` only hides them; the original bytes are still transferred. See -[Docker's build guidance](https://docs.docker.com/build/building/best-practices/). A narrower upstream change is -preferable to replacing the Rails image or removing development capabilities merely to reduce `du` output. - -## Evidence and retained scope - -The 25 completed samples are grouped below. Every sample passed `script/devcontainer-smoke` twice, including exact -Claude, Codex, First Draft CLI, and Skill checks. The nine unpublished-template samples also verified the expected -source tree and absence of remotes; their new initial commits correctly differ from the template's commit. - -| Sample labels | Count | Source variant | -|---|---:|---| -| `api-control-a`, `browser-live-a/b`, `template-control-a`, `fixture-template-baseline-a/b` | 6 | Original main `69020938` | -| `browser-prepared-a`, `fixture-template-prepared-a/b` | 3 | Prepared tools `12c667b9` | -| `cold-prepared-a/b`, `read-seq-a/b`, `read-stream-a/b`, `read-par-a/b`, `fixture-template-optimized-a` | 9 | Optimized existing image `85d916c2` | -| `cold-baked-a/b`, `baked-prebuilt-a/b`, `fixture-template-baked-a` | 5 | Baked image `bfcc8947` | -| `fixture-template-waitfor-a/b` | 2 | Editor wait `911cea7e` | - -Raw creation logs, smoke outputs, API timestamps, verified-tree inventories, browser observations, prebuild logs, -image manifests, offline checks, and local PostgreSQL/image-audit evidence are retained under -`tmp/startup-round2/` in the isolated `drawing-board-startup-round2-20260908` experiment checkout. That ignored local -directory is evidence for these measurements, not a dependency of the template. The experimental public branches -and candidate image digest remain available for source reproduction; they are not production recommendations. - -All 25 test Codespaces and all temporary prebuild configurations were removed. The disposable private fixture -repository was archived after its prebuild configurations were removed; deletion required additional GitHub -authentication. The original template's main prebuild configuration and pre-existing Codespaces were preserved. - -Keep the direct-template onboarding and early private-repository checkpoint established in the first investigation. -Retain the TCP readiness correction and this report. Do not adopt the prepared-tool split, baked image, read-ahead, -concurrent warm-up, faster steady-state polling, or editor wait. Tool/Skill pins, image receipt, installation behavior, -and CI configuration remain unchanged. The setup banner uses the same publication terms as the guide. -No live Compilation, model response, or generated-application qualification -was part of this round. A rebuilt upstream image that omits unnecessary Rust/cache content remains an unmeasured -opportunity; this report does not claim that Codespaces has reached a universal speed limit. -## Codex command sandbox — September 13, 2026 - -An assisted student trial used template `557921f0debc3d9c1d6178f31aeb867740f571dd` in Codespace -`cuddly-enigma-v6g9wg4g5fw5w6`, with two cores, 8 GB, East US, and a GitHub-reported prebuild. Installed Codex was -`0.154.0`, CLI `0.2.2`, and both Skills resolved to `e84a6ecddfa6a4170774768f24ddc798c0f13331`. -Plain `codex` failed even the shell command `pwd` with: - -```text -bwrap: No permissions to create new namespace -``` - -Repeated individual command approvals interrupted the workflow. After the user approved using the Codespace as -the sandbox, the same conversation resumed with: - -```sh -codex --sandbox danger-full-access --ask-for-approval on-request resume -``` - -Plan authoring, root Compile, private VS Code publication, generated setup/CI, web and local Android previews, -and a normal source edit then passed. The same conversation resumed after stopping and restarting the Codespace. -It ended in `Shutdown`. The private [trial repository](https://github.com/raghubetina/fd-student-android-20260913) -retains baseline `88779d43c70e45615f778d415b951a30d8e3c6ed` and source edit -`c0e7b8da511a85b2b4d6c266be4257400750b2af`. First Draft authentication was reused; this was not an unaided student. - -This failure happens before the command can run, including commands needing no network, so granting network -access alone would not address it. Changing container privileges/seccomp to enable nested namespaces was not -tested. The chosen supported Codex setting uses the existing Codespace VM boundary and leaves the local -devcontainer policy intact. It allows ordinary commands without per-command prompts, including after root Compile; -the agent's on-request and conversation instructions are not a technical approval fence. - -The follow-up initializes a missing config after the Codex home volume is mounted. Existing settings, including -dotfile symlinks, are preserved. Source checks cover those cases. The pinned binary smoke checks loaded full access -and request instructions against a `never` control. These configuration checks are separate from the complete -student workflow above, which selected the same policy through explicit CLI flags. diff --git a/STARTUP_INVESTIGATION.md b/STARTUP_INVESTIGATION.md deleted file mode 100644 index 1202765..0000000 --- a/STARTUP_INVESTIGATION.md +++ /dev/null @@ -1,164 +0,0 @@ -# Drawing Board startup investigation - -Investigated September 7–8, 2026, including September 9 UTC, from Drawing Board main -`69020938f08cc9731c84701646f9d1847643b8e7`, tree `f5346596a628d8ad32bcfd3a060040cd4f646a47`. - -Use the template's existing prebuild through **Use this template → Open in a codespace**, then publish the useful -compiled baseline to the user's private repository from VS Code or the Codespaces publication API. Keep tool and -Skill pins independent of image publication. The first local tool-baking experiments were withdrawn. The -[later follow-up](STARTUP_FOLLOWUP.md) tested a published successor, cold reads, browser attachment, and database -readiness. The retained container correction checks PostgreSQL over TCP; installation, versions, image receipt, -and CI remain unchanged. - -The [follow-up hosted comparison](PREBUILD_EXPERIMENT.md) tested tool preparation during a real prebuild. Two -prepared launches averaged **55.7 seconds**, versus **60.3 seconds** for two existing-prebuild baselines. A fresh -direct-template launch also passed at **84.9 seconds**, illustrating timing variation. The roughly five-second -measured saving does not justify the extra setup paths. The important optimization is using the existing template -prebuild; moving tool installation into it did not remove the first-use filesystem wait. - -## Actual startup costs - -The existing image already includes Ruby, Node, GitHub CLI, PostgreSQL client, Active Storage dependencies, SSH, -and Docker tooling. PostgreSQL starts with the workspace; Selenium is already deferred until browser tests. -Post-create setup installs three pinned npm CLIs and shallow-fetches the pinned Skill when its cache is empty. - -| Observation | Before agent setup | Agent setup | Boundary | -|---|---:|---:|---| -| [Current-main CI, September 6](https://github.com/firstdraft/drawing-board/actions/runs/34003314237) | 92.3 s | 6.6 s | Dev Container command start through setup complete; not Codespaces | -| Local existing image, September 7 | excluded | 10.153 s | Setup only, cached image on Apple Silicon | -| Direct-template prebuild, September 8 UTC | 27.6 s | 60.5 s | Fresh hosted creation through setup complete; 88.1 s total | - -The hosted prebuild run was `studious-funicular-www7pwp4w354wj`, created through the actual template menu at -01:29:10 UTC, using `basicLinux32gb` in `EastUs`. The API returned `prebuild: true`. Its source tree exactly matched -current main; GitHub initialized a new local commit `7189d380bd7d80eaf2f52a039ca89fd0c3c505bd` with no remote. -The creation log retained the earlier prebuild phase and showed the new workspace reusing its containers with -`up -d --no-recreate`. - -The API was first observed Available at 01:29:39 UTC, but this was not tool readiness. `postCreateCommand` ran -from 01:29:37.633 to the setup-complete marker at 01:30:38.100. npm reported 43 seconds. An SSH probe during that -interval correctly found tools not yet installed. The web editor connected and presented its workspace-trust -prompt; the setup log finished before that prompt was accepted, so the trust pause did not cause the install time. -Installation diagnostics and the full template runtime smoke passed afterward. From the menu click at -01:29:08.824 to setup completion was 89.3 seconds. - -The prior September 2 root-adoption Codespace first showed Available after 222 seconds, plus an eight-second SSH -probe. Its [dated receipt](DIRECT_COMPILATION_PLAN.md#observed-current-root-qualification-on-2026-09-02) used an earlier -revision and does not isolate setup time. These initial observations are not a matched cold/prebuilt benchmark or -a guaranteed time saving. The follow-up report separates comparable samples, a cold candidate, and direct-template -observations. Tool installation is more variable than the initial local sample suggested. - -Anonymous registry metadata showed 1,113,655,919 compressed layer bytes for the existing Drawing Board amd64 image, -versus 659,600,910 for the representative Rails image. Transfer size is a cost input, not elapsed-time proof. - -## Prebuild configuration and iteration - -The template already had a successful [prebuild for current main](https://github.com/firstdraft/drawing-board/actions/runs/34003313712). -Its repository settings were inspected directly: `main`, `.devcontainer/devcontainer.json`, **Every push**, all five -regions, two retained versions, maintainer failure notifications, and **Disable prebuild optimization** unchecked. -The main configuration was left unchanged. The follow-up experiment added and then deleted a temporary one-region -configuration for its branch. Fresh launches prove the template route can use the main configuration. - -GitHub scopes prebuilds to a repository, branch, devcontainer configuration, and region. A repository generated from -a template does not inherit its prebuild configuration. Direct-template launch therefore makes the existing -prebuild useful to the primary onboarding path. Prebuilds run `onCreateCommand` and `updateContentCommand`, and -exclude `postCreateCommand`. See [GitHub's prebuild documentation](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/about-github-codespaces-prebuilds) -and [configuration semantics](https://docs.github.com/en/codespaces/prebuilding-your-codespaces/configuring-prebuilds). - -Keeping setup in post-create allows a tool or Skill update without an image build, publication, digest change, or -image receipt. It reads the pins in the checked-out source. The follow-up experiment found that an older prebuild -could restore the older **source revision** too; post-create does not guarantee the latest remote pins. Keep -**Every push** and verify the actual tree after an exact-revision prebuild succeeds when qualifying new pins. -The existing optimization setting allows reuse while a new prebuild runs. Configuration-only or scheduled triggers -reduce Actions work at the cost of freshness; no trigger change was made. Region/retention reductions remain -audience and storage-cost decisions. - -Preparing tools during the prebuild was implemented and tested, then withdrawn after the hosted comparison. -It preserved image independence and passed normal runtime checks, but yielded only a small measured improvement. -The retained [experiment](PREBUILD_EXPERIMENT.md) includes the prototype revision, prebuild run, volume behavior, -first-use I/O observations, and fallback tests. The original installer still serves repository-first creation too. - -## Rails comparison and rejected image experiment - -The shared Rails image owner is [firstdraft/project-syncing](https://github.com/firstdraft/project-syncing/tree/6e49f2bee75ad5ac752ca75e97b5ebf92a4849f2). -Its Rails 8 phase-one Dockerfile installs the toolchain and a superset Gemfile bundle before publication. - -| Representative repository and exact revision | Work after image startup | -|---|---| -| [photogram-capstone](https://github.com/appdev-projects/photogram-capstone/blob/602fe8d71e90f5cf7135b22081f8df7f876f345e/.devcontainer/devcontainer.json) | Bundle check/install fallback, database prep, browser downloads, formatter gems | -| [link-in-bio-4-validations](https://github.com/appdev-projects/link-in-bio-4-validations/blob/90e20cced7a9f76e8d15966c81d14032ff658bba/.devcontainer/devcontainer.json) | Same pattern | -| [ai-chat-2](https://github.com/appdev-projects/ai-chat-2/blob/ea223dede5ca8dbf3fca70f22996f9f17ea3c742/.devcontainer/devcontainer.json) | No post-create command | - -These setups support baking stable shared dependencies, but do not establish uniformly network-free Rails startup -or a controlled timing comparison. Drawing Board keeps its existing immutable, public, multi-platform image. - -The local baking experiment made agent setup sub-second with networking disabled, but added about 230 MB of -compressed arm64 image layers. About 209 MB was the expected native Claude/Codex payload; no other-platform payload -or package-manager cache explained it. It also coupled every CLI or Skill pin update to image publication and -qualification. That iteration cost and additional image transfer did not justify adoption. Neither candidate image -was published. No image-source receipt bypass or extra image-build machinery remains in the final change. - -Generated-app gems and JavaScript packages still install after Compile, when the actual application's dependencies -are known. Selenium stays on demand. No Compiler, CLI package, Skill pin, or service change was needed for the -startup experiments. - -## Onboarding verification - -The README now selects direct-template creation and places **Save your app to GitHub** immediately after Compile, -before application setup or feature work. VS Code showed **Publish to GitHub** for the tested no-remote workspace. -Publishing inside VS Code adds a remote and pushes the commits; the separate Codespaces-list publication flow -leaves the existing Codespace unlinked. The primary route creates a personally owned repository; repository-first -creation remains documented for organization ownership. See [GitHub's template workflow](https://docs.github.com/en/codespaces/developing-in-a-codespace/creating-a-codespace-from-a-template) -and [VS Code publishing](https://code.visualstudio.com/docs/sourcecontrol/repos-remotes#publish-to-github). - -The exact npm CLI `0.2.2` matches source tag `799a184cb2453ceadf5575f7b46ba975e084f192`. On Node 24.18.0, 103 -upstream init/push/status/compile/root-output tests passed against that package. A separate no-remote fixture ran -init, push, status, root Compile, relocated status, and baseline commit; Git history/config, staged artifact -bytes/modes, and ignored private state were preserved. This used a loopback service and synthetic artifact, not -live Compilation. No origin-dependent CLI or Skill change was necessary. - -The hosted template smoke verified Claude `2.1.226`, Codex `0.147.0`, CLI `0.2.2`, Skill -`8ae02160b44b40d21ec432cf2d1ab2772f9aae6b`, both Skill links and Codex discovery, Ruby `4.0.5`, Node `24.18.0`, -PostgreSQL `18.6`, wrapper PATH, SSH policy, and Selenium remaining stopped. No First Draft or agent credentials -were supplied. The complete live Compile → VS Code private publication journey remains unobserved in this audit; -installation, local no-remote CLI behavior, and documented publishing semantics are separate evidence. - -Normal `script/check`, including the strict image receipt and depth-one checkout regression, passed. Independent -review found no material documentation issues in the initial guide change. Temporary Codespaces were deleted after -verification; pre-existing user Codespaces were preserved. - -Logs and fixtures remain in the task checkout's ignored `tmp/startup-evidence/` and `tmp/references/cli-no-remote/`. - -## Publication credentials - -On September 8 at 23:38 UTC, the fresh direct-template Codespace `fuzzy-tribble-jxx5vxw7j3q4qx` successfully created -a private repository through `POST /user/codespaces/{codespace_name}/publish`, using only its built-in `GITHUB_TOKEN`. -It used the same template tree recorded above and reported `prebuild: true`. The probes ran in the normal VS Code -terminal with an empty, isolated `GH_CONFIG_DIR` and competing token variables unset. Noninteractive Codespaces SSH -did not receive `GITHUB_TOKEN` or `CODESPACE_NAME`, so its presence-only guard stopped before a mutation; it was used -only to transfer scripts and non-secret receipts afterward. - -| Route | Observed result | -|---|---| -| `gh repo create OWNER/REPO --private` with GitHub CLI 2.98.0 | GraphQL rejected `CreateRepository` for insufficient permissions | -| `POST /user/repos`, `private: true` | HTTP 403, `Resource not accessible by integration` | -| `POST /user/codespaces/{codespace_name}/publish`, `name` and `private: true` | Created private repository `1362007861` and associated this Codespace with it | - -The repository remained empty and local Git still had no remote after API publication. An isolated Git fixture then -added the repository as `origin` and pushed two README-only commits, without workflows or application source. Git's -credential helper was `gh auth git-credential`, with the same isolated CLI configuration and built-in token as its -only credential. The initial push and subsequent ordinary `git push` succeeded. A separate host-side read verified -the private repository, its sole README, and final commit `a14f051c765499ae990e97c496c57e1ee5c2bed9`. - -This is the supported API for creating a repository and granting the Codespace write access in one operation; -see [GitHub's endpoint contract](https://docs.github.com/en/rest/codespaces/codespaces#create-a-repository-from-an-unpublished-codespace) -and [token access behavior](https://docs.github.com/en/codespaces/managing-your-codespaces/managing-repository-access-for-your-codespaces). -The [terminal recipe](CONTRIBUTING.md#publish-from-the-codespace-terminal) therefore uses this route and explicitly -adds the local remote and pushes afterward. General repository-creation permissions are not required for this path. - -No First Draft credential was supplied. Current Service source at `9f3cdcd9a5966b6d839d6985f398cf8d79f3f1ef` does have -a private-repository client, but its public Publication API starts a Compilation and publishes a fresh server-built -artifact, rather than saving the existing Codespace's Git history. No new Service endpoint or credential handoff is -needed for the tested Codespaces route. This fixture does not replace the pending complete live journey from root -Compile through publication. Non-secret scripts and logs are retained under ignored `tmp/token-publication-probe/`. -The disposable Codespace was deleted after those receipts were saved. The private test repository -`raghubetina/drawing-board-token-probe-20260908-233239` remains available with its two README-only commits. diff --git a/script/agent-smoke b/script/agent-smoke deleted file mode 100755 index f2a6d78..0000000 --- a/script/agent-smoke +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -# shellcheck disable=SC1091 -source .devcontainer/agent-versions.env - -# Interactive nvm initialization must retain Node and the installed agents. -for shell_mode in -c -ic -lic; do - bash "$shell_mode" 'set -e; node --version; npm --version; claude --version; codex --version' -done - -# These are receipts, not equality checks against a historical agent release. -claude --version -codex --version - -test "$(command -v claude)" = "$HOME/.local/bin/claude" -test "$(command -v codex)" = "$HOME/.local/bin/codex" - -bin/agent-doctor --installation-only -claude --effort high --permission-mode plan --allowed-tools "Read,Glob,Grep,Skill" --version >/dev/null -codex --sandbox read-only --ask-for-approval never exec --help >/dev/null -node script/check-codex-configuration.mjs --runtime - -compile_help="$(firstdraft plan compile --help)" -grep -F -- "--output" <<<"$compile_help" >/dev/null - -skills_checkout="$HOME/.cache/firstdraft/skills/$FIRSTDRAFT_SKILLS_REVISION" -codex_skill_path="$HOME/.agents/skills/create-full-stack-app" -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 "Describe the available First Draft Skills.")" -node .devcontainer/agent-skills.mjs codex \ - --checkout "$skills_checkout" \ - --revision "$FIRSTDRAFT_SKILLS_REVISION" \ - --claude-root "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills" \ - --codex-root "$HOME/.agents/skills" \ - --claude-authoring-name "$FIRSTDRAFT_CLAUDE_SKILL_NAME" \ - --codex-authoring-name "$FIRSTDRAFT_CODEX_SKILL_NAME" <<<"$prompt_input" - -node script/check-claude-discovery.mjs "$skills_checkout" \ - "$FIRSTDRAFT_CLAUDE_SKILL_NAME" "$FIRSTDRAFT_CODEX_SKILL_NAME" - -echo "Agent installation smoke passed (no sign-in or model turn)." diff --git a/script/application-smoke b/script/application-smoke deleted file mode 100755 index ef7b22c..0000000 --- a/script/application-smoke +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -root="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -application_root="${root}/application" - -unset GIT_DIR \ - GIT_WORK_TREE \ - GIT_INDEX_FILE \ - GIT_OBJECT_DIRECTORY \ - GIT_ALTERNATE_OBJECT_DIRECTORIES \ - GIT_COMMON_DIR \ - GIT_NAMESPACE - -if [[ ! -x "${application_root}/bin/setup" || ! -x "${application_root}/bin/ci" ]]; then - echo "Expected a generated Rails Foundation under ${application_root}." >&2 - exit 1 -fi - -application_root="$(cd -P "${application_root}" && pwd -P)" -case "${application_root}" in - "${root}"/*) ;; - *) - echo "The generated application must resolve inside the Drawing Board." >&2 - exit 1 - ;; -esac -application_git_root="$(git -C "${application_root}" rev-parse --show-toplevel 2>/dev/null || true)" -if [[ -z "${application_git_root}" ]]; then - echo "Initialize the generated application with script/initialize-application before running its CI." >&2 - exit 1 -fi -application_git_root="$(cd -P "${application_git_root}" && pwd -P)" -if [[ "${application_git_root}" != "${application_root}" ]]; then - echo "The generated application Git repository must resolve at its physical application root." >&2 - exit 1 -fi - -# shellcheck disable=SC1091 -source "${root}/.devcontainer/agent-versions.env" - -selenium_started=false -server_pid="" -cleanup() { - local exit_status=$? - trap - EXIT - if [[ -n "${server_pid}" ]]; then - kill "${server_pid}" >/dev/null 2>&1 || true - wait "${server_pid}" >/dev/null 2>&1 || true - fi - if [[ "${selenium_started}" == true ]]; then - if ! "${root}/script/selenium" stop; then - echo "Could not stop the Selenium service started by the application smoke." >&2 - if [[ "${exit_status}" -eq 0 ]]; then - exit_status=1 - fi - fi - fi - exit "${exit_status}" -} -trap cleanup EXIT - -if "${root}/script/selenium" running; then - selenium_started=false -else - selenium_status=$? - case "${selenium_status}" in - 1) - selenium_started=true - ;; - *) - echo "Could not determine whether Selenium was already running." >&2 - exit "${selenium_status}" - ;; - esac -fi -"${root}/script/selenium" start - -cd "${application_root}" - -expected_ruby="$(tr -d '\r\n' < .ruby-version)" -expected_ruby="${expected_ruby#ruby-}" -expected_node="$(tr -d '\r\n' < .node-version)" -actual_ruby="$(ruby -e 'print RUBY_VERSION')" -actual_node="$(node --version)" -actual_node="${actual_node#v}" - -[[ "${expected_ruby}" == "${FOUNDATION_RUBY_VERSION}" ]] || { - echo "Generated Foundation expects Ruby ${expected_ruby}; the Drawing Board provides ${FOUNDATION_RUBY_VERSION}." >&2 - exit 1 -} -[[ "${expected_node}" == "${FOUNDATION_NODE_VERSION}" ]] || { - echo "Generated Foundation expects Node ${expected_node}; the Drawing Board provides ${FOUNDATION_NODE_VERSION}." >&2 - exit 1 -} -[[ "${actual_ruby}" == "${expected_ruby}" ]] || { - echo "Expected Ruby ${expected_ruby}, got ${actual_ruby}." >&2 - exit 1 -} -[[ "${actual_node}" == "${expected_node}" ]] || { - echo "Expected Node ${expected_node}, got ${actual_node}." >&2 - exit 1 -} - -bin/setup --skip-server - -postgres_version="$(bin/rails runner 'print ActiveRecord::Base.connection.select_value("SHOW server_version")')" -[[ "${postgres_version}" == "${FOUNDATION_POSTGRES_VERSION}."* ]] || { - echo "Expected PostgreSQL ${FOUNDATION_POSTGRES_VERSION}, got ${postgres_version}." >&2 - exit 1 -} - -bin/rails server --binding 127.0.0.1 --port 3100 >tmp/drawing-board-application-server.log 2>&1 & -server_pid=$! - -ready_status="" -for _ in {1..60}; do - ready_status="$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:3100/ready || true)" - [[ "${ready_status}" == "200" ]] && break - kill -0 "${server_pid}" >/dev/null 2>&1 || { - cat tmp/drawing-board-application-server.log >&2 - exit 1 - } - sleep 1 -done -[[ "${ready_status}" == "200" ]] || { - cat tmp/drawing-board-application-server.log >&2 - echo "Generated Foundation did not become ready." >&2 - exit 1 -} - -kill "${server_pid}" >/dev/null 2>&1 || true -wait "${server_pid}" >/dev/null 2>&1 || true -server_pid="" - -CI=1 bin/ci - -echo "Generated application setup, PostgreSQL, readiness, and CI passed." diff --git a/script/check b/script/check deleted file mode 100755 index 30825da..0000000 --- a/script/check +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Keep these shared configurations in strict JSON, a machine-checkable subset -# of the JSONC accepted by the devcontainer reader. -node -e ' - const fs = require("node:fs"); - for (const path of [ - ".devcontainer/devcontainer.json", - ".devcontainer/image/devcontainer.json", - ".devcontainer/image/devcontainer-lock.json", - ".devcontainer/image/receipt.json", - ]) JSON.parse(fs.readFileSync(path, "utf8")); -' - -node <<'NODE' -const fs = require("node:fs"); -const runtimeConfiguration = JSON.parse(fs.readFileSync(".devcontainer/devcontainer.json", "utf8")); -const imageConfiguration = JSON.parse(fs.readFileSync(".devcontainer/image/devcontainer.json", "utf8")); -const lockfile = JSON.parse(fs.readFileSync(".devcontainer/image/devcontainer-lock.json", "utf8")); -const expectedFeatures = [ - "ghcr.io/devcontainers/features/docker-outside-of-docker:1", - "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(); -const lockedFeatures = Object.keys(lockfile.features ?? {}).sort(); -if (JSON.stringify(configuredFeatures) !== JSON.stringify(expectedFeatures) || - JSON.stringify(lockedFeatures) !== JSON.stringify(expectedFeatures)) { - console.error("The devcontainer features and lockfile must contain the exact reviewed feature set."); - process.exit(1); -} -for (const feature of expectedFeatures) { - const entry = lockfile.features[feature]; - const resolvedPrefix = `${feature.replace(/:\d+$/, "")}@sha256:`; - if (!/^\d+\.\d+\.\d+$/.test(entry?.version ?? "") || - !entry?.resolved?.startsWith(resolvedPrefix) || - !/^[0-9a-f]{64}$/.test(entry.resolved.slice(resolvedPrefix.length)) || - entry.integrity !== entry.resolved.split("@")[1]) { - console.error(`The lockfile entry for ${feature} is incomplete.`); - process.exit(1); - } -} -if ("features" in runtimeConfiguration || fs.existsSync(".devcontainer/devcontainer-lock.json")) { - console.error("Runtime Features must come only from the pinned development image."); - process.exit(1); -} -if (imageConfiguration.features["ghcr.io/devcontainers/features/docker-outside-of-docker:1"]?.moby !== false) { - console.error("The trixie-based development image must use Docker CE, not Moby."); - process.exit(1); -} -if (imageConfiguration.features["ghcr.io/rails/devcontainer/features/postgres-client:1"]?.version !== "18") { - console.error("The development image must install the PostgreSQL 18 client tools."); - process.exit(1); -} -if (!fs.readFileSync(".devcontainer/image/devcontainer-lock.json", "utf8").endsWith("\n")) { - console.error("The development-image lockfile must end with a newline."); - process.exit(1); -} -NODE - -node script/check-image-receipt.mjs - -# shellcheck disable=SC1091 -source .devcontainer/agent-versions.env - -for script in script/agent-smoke .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/refresh-codespaces-private-port script/selenium; do - bash -n "$script" - if [[ ! -x "$script" ]]; then - echo "$script must be executable." >&2 - exit 1 - fi -done - -for javascript in \ - .devcontainer/agent-skills.mjs \ - bin/firstdraft \ - script/application-repository-inventory-lib.mjs \ - script/application-repository-inventory.mjs \ - script/check-image-receipt.mjs \ - script/check-firstdraft-wrapper.mjs \ - script/check-agent-skills.mjs \ - script/check-agent-setup.mjs \ - script/check-claude-discovery.mjs \ - script/check-initialize-application.mjs; do - node --check "$javascript" -done -if [[ ! -x bin/firstdraft ]]; then - echo "bin/firstdraft must be executable." >&2 - exit 1 -fi -node script/check-firstdraft-wrapper.mjs -node script/check-agent-skills.mjs -node script/check-agent-setup.mjs -node script/check-initialize-application.mjs - -expected_bin_entries=$'bin/agent-doctor\nbin/firstdraft\nbin/review-plan-with-claude\nbin/review-plan-with-codex' -actual_bin_entries="$(find bin -mindepth 1 -maxdepth 1 -print | sort)" -if [[ "$actual_bin_entries" != "$expected_bin_entries" ]]; then - echo "Review the devcontainer PATH contract before adding another file under bin/." >&2 - exit 1 -fi - -if [[ "$(cat .env.example)" != $'FIRSTDRAFT_API_URL=https://staging.firstdraft.com\nFIRSTDRAFT_API_TOKEN=' ]]; then - echo ".env.example must select staging and leave its token blank." >&2 - exit 1 -fi -if ! git check-ignore --quiet .env || git ls-files --error-unmatch .env >/dev/null 2>&1; then - echo ".env must be ignored and untracked." >&2 - exit 1 -fi - -workspace_path="$(node -e ' - const configuration = JSON.parse(require("node:fs").readFileSync(".devcontainer/devcontainer.json")); - process.stdout.write(configuration.remoteEnv?.PATH ?? ""); -')" -if [[ "$workspace_path" != '${containerWorkspaceFolder}/bin:/home/vscode/.local/bin:${containerEnv:PATH}' ]]; then - echo "The devcontainer PATH must prefer the Drawing Board wrapper." >&2 - exit 1 -fi - -node <<'NODE' -const fs = require("node:fs"); -const configuration = JSON.parse(fs.readFileSync(".devcontainer/devcontainer.json", "utf8")); -const compose = fs.readFileSync(".devcontainer/compose.yaml", "utf8"); -const dockerfile = fs.readFileSync(".devcontainer/Dockerfile", "utf8"); -const imageWorkflow = fs.readFileSync(".github/workflows/devcontainer-image.yml", "utf8"); -const ciWorkflow = fs.readFileSync(".github/workflows/ci.yml", "utf8"); -const imageReceipt = JSON.parse(fs.readFileSync(".devcontainer/image/receipt.json", "utf8")); -const required = (condition, message) => { - if (!condition) { - console.error(message); - process.exit(1); - } -}; - -required(configuration.dockerComposeFile === "compose.yaml", "The Drawing Board must use its reviewed Compose stack."); -required(configuration.service === "rails-app", "The Dev Container must attach to rails-app."); -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(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."); -required(imageReceipt.publication.anonymous_pull === "passed", "The exact workspace image must retain its anonymous-pull observation."); -required(imageReceipt.publication.comparison_codespace === "passed", "The exact workspace image must retain its comparison-Codespace 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."); -required(compose.includes("selenium/standalone-chromium:4.47.0-20260808@sha256:1d3d834a2ce93f26cc0d0ae3c61abd189755b32649f5c356c6c5cf9502aa397e"), "Selenium must use the reviewed release and image digest."); -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") && !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."); -required(imageWorkflow.includes("docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0"), "The Buildx setup action must remain exact."); -required(imageWorkflow.includes("docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0"), "The registry login action must remain exact."); -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), "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"), "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 -node script/check-codex-configuration.mjs - -if ! git check-ignore --quiet application/example; then - echo "Generated application output must remain outside the Drawing Board Git repository." >&2 - exit 1 -fi - -script/check-depth-one - -if [[ "$FIRSTDRAFT_CLI_DEFAULT_API_URL" != "https://firstdraft.com" || \ - "$FIRSTDRAFT_STAGING_API_URL" != "https://staging.firstdraft.com" ]]; then - echo "The pinned releases no longer match the documented staging-wrapper contract." >&2 - exit 1 -fi - -if ! grep -Fqx "ARG RUBY_VERSION=$FOUNDATION_RUBY_VERSION" .devcontainer/Dockerfile || \ - ! grep -Fq "\"ghcr.io/devcontainers/features/node:1\": { \"version\": \"$FOUNDATION_NODE_VERSION\" }" .devcontainer/image/devcontainer.json || \ - ! grep -Fqx " image: postgres:$FOUNDATION_POSTGRES_VERSION" .devcontainer/compose.yaml; then - echo "The Drawing Board runtime pins must match the current generated Foundation." >&2 - exit 1 -fi - -if [[ -e .claude/settings.json ]]; then - echo "Claude settings must remain user-scoped, not committed to the Drawing Board." >&2 - exit 1 -fi - -if [[ "$FIRSTDRAFT_CLAUDE_SKILL_NAME" != "create-full-stack-app" ]]; then - echo "The Claude Skill name must match the canonical Skill." >&2 - exit 1 -fi -if [[ "$FIRSTDRAFT_CODEX_SKILL_NAME" != "firstdraft:create-full-stack-app" ]]; then - echo "The Codex Skill name must match the canonical namespaced Skill." >&2 - exit 1 -fi - -if [[ ! "$FIRSTDRAFT_SKILLS_REVISION" =~ ^[0-9a-f]{40}$ ]]; then - echo "The First Draft Skill revision must be one exact commit SHA." >&2 - exit 1 -fi - -if [[ ! "$FIRSTDRAFT_CLI_VERSION" =~ ^0\.[0-9]+\.[0-9]+$ ]]; then - echo "The First Draft CLI version must use ordinary pre-1.0 SemVer." >&2 - exit 1 -fi - -set +e -git grep --untracked -IEn '(fd_[A-Za-z0-9_-]{20,}|FIRSTDRAFT_(STAGING_)?API_TOKEN=.+|gh[opsu]_[A-Za-z0-9]{20,})' \ - -- . ':(exclude)script/check' -credential_status=$? -set -e -case "$credential_status" in - 0) - echo "A credential-like value was found in the repository." >&2 - exit 1 - ;; - 1) ;; - *) - echo "The credential scan could not inspect the repository." >&2 - exit 1 - ;; -esac - -echo "Drawing Board contract checks passed." diff --git a/script/check-agent-setup.mjs b/script/check-agent-setup.mjs deleted file mode 100644 index fbd5b10..0000000 --- a/script/check-agent-setup.mjs +++ /dev/null @@ -1,193 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { - copyFileSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const configuration = JSON.parse(readFileSync(path.join(repository, ".devcontainer/devcontainer.json"), "utf8")); -const versions = readFileSync(path.join(repository, ".devcontainer/agent-versions.env"), "utf8"); -const pins = Object.fromEntries([...versions.matchAll(/^([A-Z_]+)=(.+)$/gm)].map((match) => match.slice(1))); -assert(!("CLAUDE_CODE_VERSION" in pins), "Claude uses the no-argument native bootstrap's latest default; a temporary regression pin or frozen experiment must update this check and its qualification receipt"); -assert.equal(pins.CODEX_VERSION, "latest", "CODEX_VERSION: normal policy is latest; a temporary regression pin or frozen experiment must update this check and its qualification receipt"); -const temporary = realpathSync(mkdtempSync(path.join(tmpdir(), "drawing-board-agent-setup-"))); -const home = path.join(temporary, "home"); -const workspace = path.join(temporary, "workspace"); -const stubs = path.join(temporary, "stubs"); -const local = (value) => { - assert(value.startsWith("/home/vscode/"), "Agent paths must remain in the devcontainer user's home"); - return path.join(home, value.slice("/home/vscode/".length)); -}; -const environment = { - HOME: home, - PATH: `${stubs}:/usr/bin:/bin`, - TMPDIR: path.join(temporary, "tmp"), - CODESPACES: "true", - CLAUDE_CONFIG_DIR: local(configuration.containerEnv.CLAUDE_CONFIG_DIR), - CODEX_HOME: local(configuration.containerEnv.CODEX_HOME), - NPM_CONFIG_CACHE: local(configuration.containerEnv.NPM_CONFIG_CACHE), - GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_ALLOW_PROTOCOL: "", - GIT_TERMINAL_PROMPT: "0", - SETUP_TEST_REGISTRY: path.join(temporary, "registry.json"), - SETUP_TEST_INSTALLS: path.join(temporary, "installs.jsonl"), - SETUP_TEST_NATIVE_INSTALLER: path.join(stubs, "native-install.mjs"), -}; -const run = (command, args, cwd = workspace) => { - const result = spawnSync(command, args, { cwd, env: environment, encoding: "utf8", timeout: 30_000 }); - assert.equal(result.status, 0, result.stderr || result.error?.message); - return result.stdout.trim(); -}; -const write = (file, content, mode = 0o600) => { - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, content, { mode }); -}; - -try { - assert(!("DISABLE_AUTOUPDATER" in configuration.containerEnv), "Drawing Board must allow normal Claude updates"); - for (const key of ["CLAUDE_CONFIG_DIR", "CODEX_HOME"]) { - assert(configuration.mounts.some((mount) => mount.split(",").includes(`target=${configuration.containerEnv[key]}`)), - `${key} must use a mounted configuration directory`); - } - assert(!("NPM_CONFIG_PREFIX" in configuration.containerEnv), "A global npm prefix must not break the image's nvm initialization"); - for (const directory of [home, workspace, stubs, environment.TMPDIR]) mkdirSync(directory, { recursive: true }); - symlinkSync(process.execPath, path.join(stubs, "node")); - write(path.join(stubs, "id"), '#!/bin/sh\n[ "$1" = "-u" ] || exit 1\nprintf "1000\\n"\n', 0o755); - write(path.join(stubs, "sudo"), '#!/bin/sh\necho "Host integration is outside this fixture" >&2\nexit 1\n', 0o755); - write(path.join(stubs, "curl"), `#!/usr/bin/env node -import assert from "node:assert/strict"; -const args = process.argv.slice(2); -assert.equal(args.length, 2); -assert.equal(args[0], "-fsSL"); -const agent = { - "https://claude.ai/install.sh": "claude", - "https://chatgpt.com/codex/install.sh": "codex", -}[args[1]]; -assert(agent, "Only supported vendor installer URLs may be requested"); -process.stdout.write('exec node "$SETUP_TEST_NATIVE_INSTALLER" ' + agent + ' "$@"\\n'); -`, 0o755); - write(environment.SETUP_TEST_NATIVE_INSTALLER, `import assert from "node:assert/strict"; -import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -const [agent, ...args] = process.argv.slice(2); -assert.deepEqual(args, agent === "claude" ? [] : ["--release", "latest"], - "Use Claude's no-argument latest bootstrap without changing the user's channel, or Codex's latest release; a temporary pin must update this check and its qualification receipt"); -if (agent === "codex") assert.equal(process.env.CODEX_NON_INTERACTIVE, "1", "Codex setup must set CODEX_NON_INTERACTIVE=1"); -appendFileSync(process.env.SETUP_TEST_INSTALLS, JSON.stringify({ agent, args }) + "\\n"); -const version = JSON.parse(readFileSync(process.env.SETUP_TEST_REGISTRY, "utf8"))[agent]; -assert(version, "Fixture release must exist"); -const bin = path.join(process.env.HOME, ".local/bin"); -mkdirSync(bin, { recursive: true }); -writeFileSync(path.join(bin, agent), "#!/usr/bin/env node\\nconsole.log(" + - JSON.stringify(agent + " " + version) + ");\\n", { mode: 0o755 }); -`); - write(path.join(stubs, "npm"), `#!/usr/bin/env node -import assert from "node:assert/strict"; -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -const args = process.argv.slice(2); -const prefix = path.join(process.env.HOME, ".local"); -assert.deepEqual(args.slice(0, 4), ["install", "--global", "--prefix", prefix]); -assert.equal(args.length, 5); -const version = args[4].match(/^@firstdraft[.]com\\/cli@([0-9]+[.][0-9]+[.][0-9]+)$/)?.[1]; -assert(version, "Only the pinned First Draft CLI may use npm"); -appendFileSync(process.env.SETUP_TEST_INSTALLS, JSON.stringify({ agent: "firstdraft", args }) + "\\n"); -mkdirSync(path.join(prefix, "bin"), { recursive: true }); -writeFileSync(path.join(prefix, "bin/firstdraft"), "#!/usr/bin/env node\\nconsole.log(" + - JSON.stringify("firstdraft " + version) + ");\\n", { mode: 0o755 }); -`, 0o755); - - const devcontainer = path.join(workspace, ".devcontainer"); - mkdirSync(devcontainer); - for (const helper of ["agent-skills.mjs", "configure-codex.mjs"]) { - copyFileSync(path.join(repository, ".devcontainer", helper), path.join(devcontainer, helper)); - } - copyFileSync(path.join(repository, ".env.example"), path.join(workspace, ".env.example")); - const setup = readFileSync(path.join(repository, ".devcontainer/setup-agents"), "utf8"); - const hostEnvironmentGuard = "if [[ -x /usr/sbin/sshd && -f /etc/environment ]]; then"; - assert.equal(setup.split(hostEnvironmentGuard).length, 2, "Review fixture isolation if the host integration changes"); - // Exercise production setup while excluding the SSH host integration, even on Linux. - write(path.join(devcontainer, "setup-agents"), setup.replace(hostEnvironmentGuard, "if false; then"), 0o755); - - const cache = path.join(home, ".cache/firstdraft/skills"); - const skillName = pins.FIRSTDRAFT_CLAUDE_SKILL_NAME; - const candidate = path.join(cache, "fixture"); - write(path.join(candidate, ".claude-plugin/plugin.json"), JSON.stringify({ - name: "firstdraft", skills: [`./skills/${skillName}`], - }) + "\n"); - write(path.join(candidate, "skills", skillName, "SKILL.md"), `---\nname: ${skillName}\n---\nOffline setup fixture.\n`); - const git = (args) => run("git", ["-c", "core.hooksPath=/dev/null", "-c", "init.templateDir=", ...args], candidate); - git(["init", "--quiet"]); - git(["add", "."]); - git(["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Fixture"]); - const revision = git(["rev-parse", "HEAD"]); - const checkout = path.join(cache, revision); - renameSync(candidate, checkout); - write(path.join(devcontainer, "agent-versions.env"), versions.replace(/^FIRSTDRAFT_SKILLS_REVISION=.+$/m, - `FIRSTDRAFT_SKILLS_REVISION=${revision}`)); - - const preserved = new Map([ - [path.join(home, ".claude.json"), '{"fixture":"existing user settings"}\n'], - [path.join(environment.CLAUDE_CONFIG_DIR, ".claude.json"), '{"fixture":"mounted global settings"}\n'], - [path.join(environment.CLAUDE_CONFIG_DIR, ".credentials.json"), '{"fixture":"synthetic Claude credentials"}\n'], - [path.join(environment.CLAUDE_CONFIG_DIR, "settings.json"), '{"theme":"dark","autoUpdatesChannel":"stable"}\n'], - [path.join(environment.CLAUDE_CONFIG_DIR, "projects/workspace/session.jsonl"), '{"fixture":"existing Claude conversation"}\n'], - [path.join(environment.CODEX_HOME, "auth.json"), '{"fixture":"synthetic Codex credentials"}\n'], - [path.join(environment.CODEX_HOME, "config.toml"), 'model = "user-choice"\nsandbox_mode = "workspace-write"\n'], - [path.join(environment.CODEX_HOME, "sessions/session.jsonl"), '{"fixture":"existing Codex conversation"}\n'], - [path.join(environment.CLAUDE_CONFIG_DIR, "skills/user-skill/SKILL.md"), "Claude user Skill\n"], - [path.join(home, ".agents/skills/user-skill/SKILL.md"), "Codex user Skill\n"], - [path.join(workspace, ".env"), `FIRSTDRAFT_API_URL=https://staging.firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=fixture-placeholder\n`], - ]); - for (const [file, content] of preserved) write(file, content); - const verifyPreserved = () => { - for (const [file, content] of preserved) assert.equal(readFileSync(file, "utf8"), content, `Setup changed ${file}`); - for (const root of [path.join(environment.CLAUDE_CONFIG_DIR, "skills"), path.join(home, ".agents/skills")]) { - assert.equal(realpathSync(path.join(root, skillName)), path.join(checkout, "skills", skillName)); - } - }; - const publishFixtureRelease = (version) => write(environment.SETUP_TEST_REGISTRY, JSON.stringify({ - claude: version, codex: version, - }) + "\n"); - const expectedInstalls = [ - { agent: "claude", args: [] }, - { agent: "codex", args: ["--release", "latest"] }, - { agent: "firstdraft", args: ["install", "--global", "--prefix", path.join(home, ".local"), `@firstdraft.com/cli@${pins.FIRSTDRAFT_CLI_VERSION}`] }, - ]; - const installs = () => readFileSync(environment.SETUP_TEST_INSTALLS, "utf8").trim().split("\n").map(JSON.parse); - const byAgent = (records) => records.toSorted((left, right) => left.agent.localeCompare(right.agent)); - - publishFixtureRelease("1.0.0"); - const first = run("bash", [path.join(devcontainer, "setup-agents")]); - assert.deepEqual(byAgent(installs()), expectedInstalls); - assert(first.includes("claude 1.0.0") && first.includes("codex 1.0.0")); - assert(first.includes(`firstdraft ${pins.FIRSTDRAFT_CLI_VERSION}`)); - verifyPreserved(); - - publishFixtureRelease("2.0.0"); - run("bash", ["-o", "pipefail", "-c", 'curl -fsSL https://claude.ai/install.sh | bash']); - run("bash", ["-o", "pipefail", "-c", 'curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh -s -- --release latest']); - for (const agent of ["claude", "codex"]) assert.equal(run(path.join(home, ".local/bin", agent), ["--version"]), `${agent} 2.0.0`); - const rerun = run("bash", [path.join(devcontainer, "setup-agents")]); - assert.equal(installs().length, 8); - assert.deepEqual(byAgent(installs().slice(-3)), expectedInstalls, "Reruns must not reapply an obsolete client pin"); - assert(rerun.includes("claude 2.0.0") && rerun.includes("codex 2.0.0")); - assert(rerun.includes(`firstdraft ${pins.FIRSTDRAFT_CLI_VERSION}`)); - verifyPreserved(); -} finally { - rmSync(temporary, { recursive: true, force: true }); -} - -console.log("Offline native installer selectors, pinned CLI, rerun, and user-state preservation checks passed."); diff --git a/script/check-agent-skills.mjs b/script/check-agent-skills.mjs deleted file mode 100644 index ac0a572..0000000 --- a/script/check-agent-skills.mjs +++ /dev/null @@ -1,172 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - readlinkSync, - realpathSync, - renameSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - linkAgentSkills, - readAgentSkills, - verifyAgentSkills, - verifyCodexSkills, -} from "../.devcontainer/agent-skills.mjs"; - -const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const temporary = realpathSync(mkdtempSync(path.join(tmpdir(), "firstdraft-agent-skills-"))); -const cache = path.join(temporary, "cache"); -const roots = { claude: path.join(temporary, "claude"), codex: path.join(temporary, "codex") }; -const names = ["create-full-stack-app", "extend-app-ui", "review-ui-consistency"]; -const authoringNames = { claude: names[0], codex: "firstdraft:" + names[0] }; - -function candidate(revision, skillNames) { - const checkout = path.join(cache, revision); - mkdirSync(path.join(checkout, ".claude-plugin"), { recursive: true }); - writeFileSync(path.join(checkout, ".claude-plugin", "plugin.json"), JSON.stringify({ - name: "firstdraft", - skills: skillNames.map((name) => "./skills/" + name), - }) + "\n"); - for (const name of skillNames) { - const source = path.join(checkout, "skills", name); - mkdirSync(path.join(source, "references"), { recursive: true }); - writeFileSync(path.join(source, "SKILL.md"), "---\nname: " + JSON.stringify(name) + "\n---\nFixture\n"); - writeFileSync(path.join(source, "references", "example.md"), "Canonical " + revision + " " + name + "\n"); - } - return checkout; -} - -function codexPrompt(skills) { - return [{ - content: [{ - type: "input_text", - text: "\n- `r0` = `" + roots.codex + "`\n" + - skills.map(({ name, codexName }) => "- " + codexName + ": Fixture (file: r0/" + name + "/SKILL.md)").join("\n"), - }], - }]; -} - -try { - const oneRoot = candidate("1".repeat(40), [names[0]]); - const threeRoot = candidate("3".repeat(40), names); - const one = readAgentSkills(oneRoot, authoringNames); - const three = readAgentSkills(threeRoot, authoringNames); - assert.deepEqual(three.map(({ name }) => name), names); - - linkAgentSkills(one, roots, cache); - verifyAgentSkills(one, roots, cache); - linkAgentSkills(three, roots, cache); - linkAgentSkills(three, roots, cache); - verifyAgentSkills(three, roots, cache); - for (const { name, source } of three) { - for (const root of Object.values(roots)) { - assert.equal(realpathSync(path.join(root, name)), source); - assert.equal( - readFileSync(path.join(root, name, "references", "example.md"), "utf8"), - readFileSync(path.join(source, "references", "example.md"), "utf8"), - ); - } - } - - verifyCodexSkills(three, roots.codex, codexPrompt(three)); - const aliasedCodexRoot = path.join(temporary, "linked-codex"); - symlinkSync(roots.codex, aliasedCodexRoot, "dir"); - verifyCodexSkills(three, aliasedCodexRoot, codexPrompt(three)); - assert.throws(() => verifyCodexSkills(three, roots.codex, codexPrompt(three.slice(0, 2))), /exactly one/); - const duplicate = codexPrompt([...three, three[2]]); - assert.throws(() => verifyCodexSkills(three, roots.codex, duplicate), /exactly one/); - const unexpectedPath = codexPrompt(three); - unexpectedPath[0].content[0].text = unexpectedPath[0].content[0].text.replace("r0/extend-app-ui/", "r0/another-skill/"); - assert.throws(() => verifyCodexSkills(three, roots.codex, unexpectedPath), /unexpected path/); - - const independentSkill = path.join(temporary, "independent"); - mkdirSync(independentSkill); - symlinkSync(independentSkill, path.join(roots.claude, "user-skill"), "dir"); - writeFileSync(path.join(roots.codex, "notes.txt"), "Keep this user file\n"); - linkAgentSkills(one, roots, cache); - verifyAgentSkills(one, roots, cache); - for (const root of Object.values(roots)) { - for (const name of names.slice(1)) assert.equal(lstatSync(path.join(root, name), { throwIfNoEntry: false }), undefined); - } - assert.equal(realpathSync(path.join(roots.claude, "user-skill")), independentSkill); - assert.equal(readFileSync(path.join(roots.codex, "notes.txt"), "utf8"), "Keep this user file\n"); - - const aliasedCache = path.join(temporary, "linked-cache"); - symlinkSync(cache, aliasedCache, "dir"); - const aliasedRoots = { claude: path.join(temporary, "alias-claude"), codex: path.join(temporary, "alias-codex") }; - for (const root of Object.values(aliasedRoots)) { - mkdirSync(root); - symlinkSync(path.join(aliasedCache, path.basename(oneRoot), "skills", names[0]), path.join(root, names[0]), "dir"); - } - linkAgentSkills(three, aliasedRoots, aliasedCache); - verifyAgentSkills(three, aliasedRoots, aliasedCache); - linkAgentSkills(one, aliasedRoots, aliasedCache); - verifyAgentSkills(one, aliasedRoots, aliasedCache); - - writeFileSync(path.join(roots.codex, "extend-app-ui"), "Keep existing content\n"); - const prior = readlinkSync(path.join(roots.claude, names[0])); - assert.throws(() => linkAgentSkills(three, roots, cache), /preserving it/); - assert.equal(readlinkSync(path.join(roots.claude, names[0])), prior); - assert.equal(readFileSync(path.join(roots.codex, "extend-app-ui"), "utf8"), "Keep existing content\n"); - assert.equal(existsSync(path.join(roots.claude, "extend-app-ui")), false); - rmSync(path.join(roots.codex, "extend-app-ui")); - symlinkSync(independentSkill, path.join(roots.codex, "extend-app-ui"), "dir"); - assert.throws(() => linkAgentSkills(three, roots, cache), /unmanaged symlink/); - assert.equal(realpathSync(path.join(roots.codex, "extend-app-ui")), independentSkill); - rmSync(path.join(roots.codex, "extend-app-ui")); - - const malformed = candidate("4".repeat(40), [names[0]]); - const manifestPath = path.join(malformed, ".claude-plugin", "plugin.json"); - for (const invalidSkills of [["./skills/" + names[0], "./skills/" + names[0]], ["../outside"]]) { - writeFileSync(manifestPath, JSON.stringify({ name: "firstdraft", skills: invalidSkills })); - assert.throws(() => readAgentSkills(malformed, authoringNames), /duplicate|unsupported/); - } - writeFileSync(manifestPath, JSON.stringify({ name: "firstdraft", skills: ["./skills/" + names[0]] })); - writeFileSync(path.join(malformed, "skills", names[0], "SKILL.md"), "---\nname: wrong-name\n---\n"); - assert.throws(() => readAgentSkills(malformed, authoringNames), /differs from its directory/); - assert.throws(() => readAgentSkills(oneRoot, { ...authoringNames, codex: "wrong:" + names[0] }), /differs from the plugin/); - - const commandRoot = candidate("command", names); - const git = (args) => { - const result = spawnSync("git", [ - "-c", "core.excludesFile=/dev/null", "-c", "core.hooksPath=/dev/null", "-c", "init.templateDir=", ...args, - ], { cwd: commandRoot, encoding: "utf8" }); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); - }; - git(["init", "--quiet"]); - git(["add", "."]); - git(["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Fixture"]); - const revision = git(["rev-parse", "HEAD"]); - const movedRoot = path.join(cache, revision); - renameSync(commandRoot, movedRoot); - const cliArgs = [ - path.join(repository, ".devcontainer", "agent-skills.mjs"), "link", - "--checkout", movedRoot, "--revision", revision, - "--claude-root", roots.claude, "--codex-root", roots.codex, - "--claude-authoring-name", authoringNames.claude, "--codex-authoring-name", authoringNames.codex, - ]; - const result = spawnSync(process.execPath, cliArgs, { encoding: "utf8" }); - assert.equal(result.status, 0, result.stderr); - assert(result.stdout.includes(names.join(", "))); - const wrongRevisionArgs = [...cliArgs]; - wrongRevisionArgs[wrongRevisionArgs.indexOf("--revision") + 1] = "0".repeat(40); - const wrongRevision = spawnSync(process.execPath, wrongRevisionArgs, { encoding: "utf8" }); - assert.equal(wrongRevision.status, 1); - assert.match(wrongRevision.stderr, /revision differs from the pin/); - - console.log("Agent Skill linking, upgrade/rollback, preservation, revision, and discovery checks passed."); -} finally { - rmSync(temporary, { recursive: true, force: true }); -} diff --git a/script/check-claude-discovery.mjs b/script/check-claude-discovery.mjs deleted file mode 100644 index 127a5c0..0000000 --- a/script/check-claude-discovery.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { readAgentSkills } from "../.devcontainer/agent-skills.mjs"; - -const [checkout, claude, codex] = process.argv.slice(2); -const skills = readAgentSkills(checkout, { claude, codex }); -const probe = mkdtempSync(join(tmpdir(), "drawing-board-claude-discovery-")); -try { - const config = join(probe, ".claude"); - const root = join(config, "skills"); - mkdirSync(root, { recursive: true }); - for (const skill of skills) symlinkSync(skill.source, join(root, skill.name)); - const log = join(probe, "discovery.log"); - const result = spawnSync("claude", [ - "--init-only", "--setting-sources", "user", - "--settings", '{"disableAllHooks":true}', - "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', - "--debug-file", log, - ], { - cwd: probe, - env: { PATH: process.env.PATH, HOME: probe, CLAUDE_CONFIG_DIR: config }, - encoding: "utf8", - timeout: 30_000, - }); - assert.equal(result.status, 0, result.error?.message ?? result.stderr); - const diagnosticChanged = "Claude discovery diagnostics changed; inspect the installed client and update this probe"; - assert.ok(existsSync(log), diagnosticChanged); - const debug = readFileSync(log, "utf8"); - const scan = debug.match(/Loading skills from: [^\n]*/)?.[0]; - const catalog = debug.match(/Loaded \d+ unique skills \([^\n]*\)/)?.[0]; - assert.ok(scan && catalog, diagnosticChanged); - assert.ok(scan.includes(`user=${root}, project=[]`), "Claude must scan the isolated user Skill directory"); - assert.ok(catalog.includes(`user: ${skills.length},`), - "Claude must discover every installed First Draft Skill without a model turn"); - console.log("Claude Skill catalog: " + skills.map(({ name }) => name).join(", ")); -} finally { - rmSync(probe, { recursive: true, force: true }); -} diff --git a/script/check-codespaces-private-port.mjs b/script/check-codespaces-private-port.mjs deleted file mode 100755 index a868ca5..0000000 --- a/script/check-codespaces-private-port.mjs +++ /dev/null @@ -1,344 +0,0 @@ -#!/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 {postAttachCommand} = JSON.parse(fs.readFileSync(path.join(repositoryRoot, ".devcontainer", "devcontainer.json"), "utf8")); -assert.equal(typeof postAttachCommand, "string", "The post-attach command must use the Dev Container shell lifecycle."); -const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "drawing-board-port-refresh-test-")); -const workspaceRoot = path.join(temporaryRoot, "workspace with spaces"); -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("/bin/sh", ["-c", postAttachCommand], { - cwd: workspaceRoot, - 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(path.join(workspaceRoot, "script"), {recursive: true}); - fs.copyFileSync(refresher, path.join(workspaceRoot, "script", "refresh-codespaces-private-port")); - fs.chmodSync(path.join(workspaceRoot, "script", "refresh-codespaces-private-port"), 0o755); - 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"); - } - - const archiveRoot = path.join(workspaceRoot, ".firstdraft", "design"); - const archivedScriptRoot = path.join(archiveRoot, "script"); - const archivedRefresher = path.join(archivedScriptRoot, "refresh-codespaces-private-port"); - fs.mkdirSync(archivedScriptRoot, {recursive: true}); - fs.writeFileSync(archivedRefresher, "#!/bin/sh\nexit 99\n", {mode: 0o755}); - fs.writeFileSync(statePath, "private"); - fs.writeFileSync(logPath, ""); - const preferredRoot = run(); - assert.equal(preferredRoot.status, 0, preferredRoot.stderr); - assert.match(preferredRoot.stdout, /private visibility confirmed/); - - const rootFailure = run({MOCK_PUBLIC_FAILURE: "true"}); - assert.equal(rootFailure.status, 42, rootFailure.stderr); - - fs.rmSync(archivedScriptRoot, {recursive: true}); - fs.renameSync(path.join(workspaceRoot, "script"), archivedScriptRoot); - for (let attach = 0; attach < 2; attach += 1) { - fs.writeFileSync(statePath, "private"); - fs.writeFileSync(logPath, ""); - const adoptedRoot = run(); - assert.equal(adoptedRoot.status, 0, adoptedRoot.stderr); - assert.match(adoptedRoot.stdout, /private visibility confirmed/); - assert.equal(fs.readFileSync(statePath, "utf8"), "private"); - assert.equal(logLines().filter((line) => line.includes(" visibility ")).length, 2); - } - - fs.writeFileSync(logPath, ""); - const adoptedListener = run({MOCK_LISTENER: "true"}); - assert.notEqual(adoptedListener.status, 0); - assert.match(adoptedListener.stderr, /Refusing to re-register port 3000 while a listener is active/); - assert.match(adoptedListener.stderr, /rerun .*\.firstdraft\/design\/script\/refresh-codespaces-private-port/); - assert.equal(logLines().some((line) => line.includes(" visibility ")), false); - assert.equal(fs.readFileSync(statePath, "utf8"), "private"); - - const adoptedOutside = run({CODESPACES: "false"}); - assert.equal(adoptedOutside.status, 0, adoptedOutside.stderr); - assert.match(adoptedOutside.stdout, /skipped outside GitHub Codespaces/); - - const archivedFailure = run({MOCK_PUBLIC_FAILURE: "true"}); - assert.equal(archivedFailure.status, 42, archivedFailure.stderr); - - fs.chmodSync(archivedRefresher, 0o644); - for (const archivePresent of [true, false]) { - if (!archivePresent) fs.rmSync(archiveRoot, {recursive: true}); - for (const listener of ["false", "true"]) { - fs.writeFileSync(logPath, ""); - const noExecutableHelper = run({MOCK_LISTENER: listener}); - assert.equal(noExecutableHelper.status, 0, noExecutableHelper.stderr); - assert.equal(noExecutableHelper.stdout, ""); - assert.equal(noExecutableHelper.stderr, ""); - assert.deepEqual(logLines(), []); - assert.equal(fs.readFileSync(statePath, "utf8"), "private"); - } - } - console.log("Codespaces post-attach contracts passed with root, archived, and absent helpers, preserving helper failures."); -} finally { - fs.rmSync(temporaryRoot, {recursive: true, force: true}); -} diff --git a/script/check-codex-configuration.mjs b/script/check-codex-configuration.mjs deleted file mode 100644 index 13df39f..0000000 --- a/script/check-codex-configuration.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const setup = fileURLToPath(new URL("../.devcontainer/configure-codex.mjs", import.meta.url)); -const fixture = mkdtempSync(join(tmpdir(), "drawing-board-codex-")); -const configure = (codexHome, codespaces) => { - const result = spawnSync(process.execPath, [setup], { - env: { ...process.env, CODEX_HOME: codexHome, CODESPACES: codespaces }, - encoding: "utf8", - }); - assert.equal(result.status, 0, result.stderr); -}; - -try { - for (const codespaces of ["", "false"]) { - const localHome = join(fixture, `local-${codespaces}`); - configure(localHome, codespaces); - assert.equal(existsSync(localHome), false, "Local devcontainers must keep their own policy"); - } - - const mountedHome = join(fixture, "mounted-codex-home"); - mkdirSync(mountedHome); - writeFileSync(join(mountedHome, "auth-placeholder"), "preserve"); - configure(mountedHome, "true"); - const configPath = join(mountedHome, "config.toml"); - const initial = readFileSync(configPath, "utf8"); - assert.equal(initial, 'sandbox_mode = "danger-full-access"\napproval_policy = "on-request"\n'); - assert.equal(statSync(configPath).mode & 0o777, 0o600); - configure(mountedHome, "true"); - assert.equal(readFileSync(configPath, "utf8"), initial, "Repeated setup is idempotent"); - assert.equal(readFileSync(join(mountedHome, "auth-placeholder"), "utf8"), "preserve"); - - const custom = 'model = "example-model"\nsandbox_mode = "workspace-write"\n'; - writeFileSync(configPath, custom); - configure(mountedHome, "true"); - assert.equal(readFileSync(configPath, "utf8"), custom, "Preserve existing user configuration byte for byte"); - - const linkedHome = join(fixture, "dotfiles-home"); - mkdirSync(linkedHome); - symlinkSync(configPath, join(linkedHome, "config.toml")); - configure(linkedHome, "true"); - assert.equal(readFileSync(configPath, "utf8"), custom, "Preserve dotfile symlinks and their targets"); - - const danglingHome = join(fixture, "missing-dotfile-target"); - mkdirSync(danglingHome); - const absentTarget = join(fixture, "absent-config.toml"); - symlinkSync(absentTarget, join(danglingHome, "config.toml")); - configure(danglingHome, "true"); - assert.equal(existsSync(absentTarget), false, "Do not replace a user's dangling dotfile symlink"); - - const freshHome = join(fixture, "new-volume"); - configure(freshHome, "true"); - assert.equal(readFileSync(join(freshHome, "config.toml"), "utf8"), initial, "Seed every fresh mounted home"); - - if (process.argv.includes("--runtime")) { - const readPermissions = (overrides = []) => { - const result = spawnSync("codex", [...overrides, "debug", "prompt-input", "Configuration smoke."], { - env: { ...process.env, CODEX_HOME: freshHome }, - encoding: "utf8", - maxBuffer: 10 * 1024 * 1024, - timeout: 30_000, - }); - assert.equal(result.status, 0, result.stderr); - const permissions = JSON.parse(result.stdout) - .flatMap((item) => item.content ?? []) - .filter((item) => item.type === "input_text") - .map((item) => item.text.match(/[\s\S]*?<\/permissions instructions>/)?.[0]) - .find(Boolean); - assert.ok(permissions, "Codex must expose its effective permission instructions"); - return permissions; - }; - const permissions = readPermissions(); - assert.match(permissions, /`sandbox_mode` is `danger-full-access`/); - assert.match(permissions, /# Escalation Requests/); - assert.doesNotMatch(permissions, /Approval policy is currently never/); - const never = readPermissions(["-c", 'approval_policy="never"']); - assert.match(never, /Approval policy is currently never/); - assert.doesNotMatch(never, /# Escalation Requests/); - } -} finally { - rmSync(fixture, { recursive: true, force: true }); -} - -console.log("Codex configuration checks passed."); diff --git a/script/check-depth-one b/script/check-depth-one deleted file mode 100755 index 0eebb84..0000000 --- a/script/check-depth-one +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -root="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -head="$(git -C "${root}" rev-parse HEAD)" -source_commit="$(node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(process.argv[1])).source.commit)' "${root}/.devcontainer/image/receipt.json")" -scratch="$(mktemp -d /tmp/firstdraft-drawing-board-depth-one.XXXXXX)" -temporary_ref="refs/firstdraft/depth-one-$$-${RANDOM}" -temporary_ref_created=false -cleanup() { - if [[ "${temporary_ref_created}" == true ]]; then - git -C "${root}" update-ref -d "${temporary_ref}" "${head}" >/dev/null 2>&1 || true - fi - case "${scratch}" in - /tmp/firstdraft-drawing-board-depth-one.*) rm -rf -- "${scratch}" ;; - esac -} -trap cleanup EXIT - -git -C "${root}" update-ref "${temporary_ref}" "${head}" "" -temporary_ref_created=true -git init --quiet "${scratch}/repo" -git -C "${scratch}/repo" remote add origin "file://${root}" -git -C "${scratch}/repo" fetch --quiet --no-tags --depth=1 origin "${temporary_ref}" -git -C "${scratch}/repo" checkout --quiet --detach FETCH_HEAD - -if [[ "$(git -C "${scratch}/repo" rev-list --count HEAD)" != "1" ]]; then - echo "The depth-one receipt regression fetched more than one commit." >&2 - exit 1 -fi -if git -C "${scratch}/repo" cat-file -e "${source_commit}^{commit}" 2>/dev/null; then - echo "The depth-one receipt regression unexpectedly fetched the image-source commit." >&2 - exit 1 -fi - -cd "${scratch}/repo" -FIRSTDRAFT_REQUIRE_IMAGE_SOURCE_COMMIT=0 node script/check-image-receipt.mjs -echo "Depth-one development-image receipt contract passed." diff --git a/script/check-firstdraft-wrapper.mjs b/script/check-firstdraft-wrapper.mjs deleted file mode 100644 index 000f066..0000000 --- a/script/check-firstdraft-wrapper.mjs +++ /dev/null @@ -1,223 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import {createRequire} from "node:module"; -import {fileURLToPath} from "node:url"; - -const require = createRequire(import.meta.url); -const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const {readConfiguration, requiresApiToken, run} = require( - path.join(repositoryRoot, "bin", "firstdraft"), -); -const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "drawing-board-wrapper-")); - -try { - const testRepository = path.join(temporaryRoot, "repository"); - const devcontainerDirectory = path.join(testRepository, ".devcontainer"); - const fakeCli = path.join(temporaryRoot, "firstdraft"); - const probeOutput = path.join(temporaryRoot, "probe.json"); - const versionProbeOutput = path.join(temporaryRoot, "version-probe.json"); - fs.mkdirSync(devcontainerDirectory, {recursive: true}); - fs.copyFileSync( - path.join(repositoryRoot, ".devcontainer", "agent-versions.env"), - path.join(devcontainerDirectory, "agent-versions.env"), - ); - fs.writeFileSync(fakeCli, `#!/usr/bin/env node -const fs = require("node:fs"); -const arguments_ = process.argv.slice(2); -const probe = { - apiUrl: process.env.FIRSTDRAFT_API_URL, - arguments_, - stagingTokenIsExpected: process.env.FIRSTDRAFT_STAGING_API_TOKEN === "test-token", - stagingTokenPresent: Boolean(process.env.FIRSTDRAFT_STAGING_API_TOKEN), - productionTokenPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_API_TOKEN"), - legacyUrlPresent: Object.prototype.hasOwnProperty.call(process.env, "FIRSTDRAFT_BASE_URL"), - pluginOptionsPresent: [ - "CLAUDE_PLUGIN_OPTION_API_TOKEN", - "CLAUDE_PLUGIN_OPTION_API_URL", - "CLAUDE_PLUGIN_OPTION_api_token", - "CLAUDE_PLUGIN_OPTION_api_url", - ].some((key) => Object.prototype.hasOwnProperty.call(process.env, key)), -}; -if (arguments_.length === 1 && arguments_[0] === "--version") { - fs.writeFileSync(process.env.FIRSTDRAFT_TEST_VERSION_OUTPUT, JSON.stringify(probe)); - process.stdout.write("firstdraft " + - (process.env.FIRSTDRAFT_TEST_CLI_VERSION ?? "0.7.0") + "\\n"); - if (process.env.FIRSTDRAFT_TEST_CLI_NOTICE) { - process.stderr.write("A benign version notice.\\n"); - } - process.exit(0); -} -fs.writeFileSync(process.env.FIRSTDRAFT_TEST_OUTPUT, JSON.stringify(probe)); -`); - fs.chmodSync(fakeCli, 0o755); - - const writeEnvironment = ({ - apiToken = "", - apiUrl = "https://staging.firstdraft.com", - extra = "", - mode = 0o600, - } = {}) => { - const environmentPath = path.join(testRepository, ".env"); - fs.writeFileSync( - environmentPath, - `FIRSTDRAFT_API_URL=${apiUrl}\n${"FIRSTDRAFT_API_TOKEN"}=${apiToken}\n${extra}`, - ); - fs.chmodSync(environmentPath, mode); - }; - const testEnvironment = { - ...process.env, - FIRSTDRAFT_API_TOKEN: "ambient-production-token", - FIRSTDRAFT_STAGING_API_TOKEN: "ambient-staging-token", - FIRSTDRAFT_API_URL: "https://wrong.example.com", - FIRSTDRAFT_BASE_URL: "https://legacy.example.com", - CLAUDE_PLUGIN_OPTION_API_TOKEN: "uppercase-token", - CLAUDE_PLUGIN_OPTION_API_URL: "https://uppercase.example.com", - CLAUDE_PLUGIN_OPTION_api_token: "lowercase-token", - CLAUDE_PLUGIN_OPTION_api_url: "https://lowercase.example.com", - FIRSTDRAFT_TEST_OUTPUT: probeOutput, - FIRSTDRAFT_TEST_VERSION_OUTPUT: versionProbeOutput, - }; - - assert.throws( - () => readConfiguration(testRepository), - /.env is missing/, - ); - - const symlinkTarget = path.join(testRepository, "environment-target"); - fs.writeFileSync( - symlinkTarget, - `FIRSTDRAFT_API_URL=https://staging.firstdraft.com\n${"FIRSTDRAFT_API_TOKEN"}=\n`, - ); - fs.chmodSync(symlinkTarget, 0o600); - fs.symlinkSync(symlinkTarget, path.join(testRepository, ".env")); - assert.throws( - () => readConfiguration(testRepository), - /must be a regular file, not a link/, - ); - fs.unlinkSync(path.join(testRepository, ".env")); - fs.unlinkSync(symlinkTarget); - - writeEnvironment(); - assert.deepEqual(readConfiguration(testRepository), { - apiToken: "", - apiUrl: "https://staging.firstdraft.com", - }); - assert.equal(requiresApiToken(["plan", "push"]), true); - assert.equal(requiresApiToken(["plan", "push", "--help"]), false); - assert.equal(requiresApiToken(["plan", "init", "--name", "Test"]), false); - assert.equal(requiresApiToken(["generate", "uuid"]), false); - assert.equal(requiresApiToken(["future", "network-command"]), true); - assert.equal(requiresApiToken(["--version"]), false); - assert.equal(requiresApiToken(["--staging", "--version"]), false); - assert.equal(requiresApiToken(["--staging", "plan", "init", "--name", "Test"]), false); - assert.equal(requiresApiToken(["--staging", "generate", "uuid"]), false); - assert.equal(requiresApiToken(["--staging", "plan", "push"]), true); - await assert.rejects( - run({ - arguments_: ["plan", "push"], - downstreamCli: fakeCli, - environment: testEnvironment, - root: testRepository, - stdio: "ignore", - }), - /FIRSTDRAFT_API_TOKEN is blank/, - ); - assert.equal(fs.existsSync(probeOutput), false); - - writeEnvironment({apiToken: "test-token"}); - const result = await run({ - arguments_: ["plan", "push"], - downstreamCli: fakeCli, - environment: {...testEnvironment, FIRSTDRAFT_TEST_CLI_NOTICE: "1"}, - root: testRepository, - stdio: "ignore", - }); - assert.deepEqual(result, {signal: null, status: 0}); - assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), { - apiUrl: "https://staging.firstdraft.com", - arguments_: ["plan", "push"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - productionTokenPresent: false, - stagingTokenIsExpected: true, - stagingTokenPresent: true, - }); - assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), { - apiUrl: "https://staging.firstdraft.com", - arguments_: ["--version"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - productionTokenPresent: false, - stagingTokenIsExpected: true, - stagingTokenPresent: true, - }); - - writeEnvironment(); - const localResult = await run({ - arguments_: ["--staging", "plan", "init", "--name", "Test"], - downstreamCli: fakeCli, - environment: testEnvironment, - root: testRepository, - stdio: "ignore", - }); - assert.deepEqual(localResult, {signal: null, status: 0}); - assert.deepEqual(JSON.parse(fs.readFileSync(probeOutput, "utf8")), { - apiUrl: "https://staging.firstdraft.com", - arguments_: ["--staging", "plan", "init", "--name", "Test"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - productionTokenPresent: false, - stagingTokenIsExpected: false, - stagingTokenPresent: false, - }); - assert.deepEqual(JSON.parse(fs.readFileSync(versionProbeOutput, "utf8")), { - apiUrl: "https://staging.firstdraft.com", - arguments_: ["--version"], - legacyUrlPresent: false, - pluginOptionsPresent: false, - productionTokenPresent: false, - stagingTokenIsExpected: false, - stagingTokenPresent: false, - }); - - const injectionMarker = path.join(temporaryRoot, "injected"); - writeEnvironment({extra: `UNEXPECTED=$(touch ${injectionMarker})\n`}); - assert.throws( - () => readConfiguration(testRepository), - /must contain only FIRSTDRAFT_API_URL and FIRSTDRAFT_API_TOKEN/, - ); - assert.equal(fs.existsSync(injectionMarker), false); - - writeEnvironment({apiToken: "test-token", mode: 0o644}); - assert.throws(() => readConfiguration(testRepository), /mode 0600/); - - writeEnvironment({apiToken: "test-token", apiUrl: "https://firstdraft.com"}); - await assert.rejects( - run({ - arguments_: ["plan", "compile"], - downstreamCli: fakeCli, - environment: testEnvironment, - root: testRepository, - stdio: "ignore", - }), - /FIRSTDRAFT_API_URL in .env must be https:\/\/staging\.firstdraft\.com/, - ); - - writeEnvironment({apiToken: "test-token"}); - await assert.rejects( - run({ - arguments_: ["--version"], - downstreamCli: fakeCli, - environment: {...testEnvironment, FIRSTDRAFT_TEST_CLI_VERSION: "9.9.9"}, - root: testRepository, - stdio: "ignore", - }), - /standalone First Draft CLI must be exactly 0\.7\.0/, - ); -} finally { - fs.rmSync(temporaryRoot, {force: true, recursive: true}); -} - -process.stdout.write("First Draft wrapper checks passed.\n"); diff --git a/script/check-image-receipt.mjs b/script/check-image-receipt.mjs deleted file mode 100755 index 529ab52..0000000 --- a/script/check-image-receipt.mjs +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env node - -import childProcess from "node:child_process"; -import crypto from "node:crypto"; -import fs from "node:fs"; - -const receiptPath = ".devcontainer/image/receipt.json"; -const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); -const requireSourceCommit = process.env.FIRSTDRAFT_REQUIRE_IMAGE_SOURCE_COMMIT; -const sha256Pattern = /^sha256:[0-9a-f]{64}$/; -const gitObjectPattern = /^[0-9a-f]{40}$/; -const required = (condition, message) => { - if (!condition) { - console.error(message); - process.exit(1); - } -}; - -required(receipt.format === "firstdraft.drawing-board-development-image/1", "The development-image receipt format changed."); -required([undefined, "0", "1"].includes(requireSourceCommit), "FIRSTDRAFT_REQUIRE_IMAGE_SOURCE_COMMIT must be 0, 1, or unset."); -required(receipt.source?.repository === "firstdraft/drawing-board", "The development-image receipt must name its source repository."); -required(gitObjectPattern.test(receipt.source?.commit ?? ""), "The development-image receipt must name one exact source commit."); -required(gitObjectPattern.test(receipt.source?.tree ?? ""), "The development-image receipt must name one exact source tree."); -required(receipt.source?.tag === `devcontainer-image-candidate-safe-${receipt.source.commit.slice(0, 7)}`, "The development-image tag must identify its exact source commit."); - -const expectedInputPaths = [ - ".devcontainer/Dockerfile", - ".devcontainer/image/devcontainer.json", - ".devcontainer/image/devcontainer-lock.json", - ".github/workflows/devcontainer-image.yml", - "script/devcontainer-image-smoke", -].sort(); -required(JSON.stringify(Object.keys(receipt.inputs ?? {}).sort()) === JSON.stringify(expectedInputPaths), "The development-image receipt must bind the exact reviewed source inputs."); -for (const path of expectedInputPaths) { - const actual = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); - required(actual === receipt.inputs[path], `The development-image receipt does not match ${path}.`); -} - -required(receipt.publication?.package === "ghcr.io/firstdraft/drawing-board-workspace", "The receipt must use the corrected workspace-image package."); -required(Number.isSafeInteger(receipt.publication?.workflow_run) && receipt.publication.workflow_run > 0, "The receipt must bind one workflow run."); -required(Number.isSafeInteger(receipt.publication?.build_job) && receipt.publication.build_job > 0, "The receipt must bind one build job."); -required(Number.isSafeInteger(receipt.publication?.verify_job) && receipt.publication.verify_job > 0, "The receipt must bind one verification job."); -required(sha256Pattern.test(receipt.publication?.manifest ?? ""), "The receipt must bind one immutable image index."); -const platformDigests = receipt.publication?.platforms ?? {}; -required(JSON.stringify(Object.keys(platformDigests).sort()) === JSON.stringify(["linux/amd64", "linux/arm64"]), "The receipt must bind exactly the supported image platforms."); -for (const digest of Object.values(platformDigests)) required(sha256Pattern.test(digest), "Every platform must use an immutable image digest."); -required(new Set([receipt.publication.manifest, ...Object.values(platformDigests)]).size === 3, "The image index and platform manifests must be distinct."); -required(["private", "public"].includes(receipt.publication?.visibility), "The receipt must name the observed package visibility."); -required(["not_yet_observed", "passed"].includes(receipt.publication?.anonymous_pull), "The receipt must name the anonymous-pull observation state."); -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; - required(!fs.readFileSync(path).includes(rejectedPackage), `The rejected package must not be consumed from ${path}.`); -} - -const sourceObject = childProcess.spawnSync("git", ["cat-file", "-e", `${receipt.source.commit}^{commit}`], { encoding: "utf8" }); -if (sourceObject.status === 0) { - const actualTree = childProcess.execFileSync("git", ["show", "-s", "--format=%T", receipt.source.commit], { encoding: "utf8" }).trim(); - required(actualTree === receipt.source.tree, "The development-image source tree does not match its commit."); - for (const path of expectedInputPaths) { - const sourcePath = `${receipt.source.commit}:${path}`; - const sourceEntry = childProcess.spawnSync("git", ["cat-file", "-e", sourcePath]); - required(sourceEntry.status === 0, `The development-image source commit does not contain ${path}.`); - const sourceBytes = childProcess.execFileSync("git", ["show", sourcePath]); - const sourceSha256 = crypto.createHash("sha256").update(sourceBytes).digest("hex"); - required(sourceSha256 === receipt.inputs[path], `The development-image receipt does not match ${path} at its source commit.`); - } -} else { - required(requireSourceCommit !== "1", "The development-image source commit is required but absent from this checkout."); -} - -console.log("Development image receipt contract passed."); diff --git a/script/check-initialize-application.mjs b/script/check-initialize-application.mjs deleted file mode 100644 index 28928aa..0000000 --- a/script/check-initialize-application.mjs +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env node - -import assert from "node:assert/strict"; -import crypto from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import {execFileSync, spawnSync} from "node:child_process"; -import {fileURLToPath} from "node:url"; -import { - assertSameInventory, - committedInventory, - filesystemInventory, -} from "./application-repository-inventory-lib.mjs"; - -const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "drawing-board-application-init-test-")); -const harnessRoot = path.join(temporaryRoot, "drawing-board"); -const applicationRoot = path.join(harnessRoot, "application"); -const initializer = path.join(harnessRoot, "script", "initialize-application"); -const originalUmask = process.umask(0o077); - -class PrerequisiteError extends Error {} - -function writeAt(root, relativePath, contents, mode = 0o644) { - const destination = path.join(root, relativePath); - fs.mkdirSync(path.dirname(destination), {recursive: true}); - fs.writeFileSync(destination, contents, {mode}); - fs.chmodSync(destination, mode); -} - -function write(relativePath, contents, mode = 0o644) { - writeAt(harnessRoot, relativePath, contents, mode); -} - -function copyApplication(name) { - const destination = path.join(harnessRoot, name); - fs.cpSync(applicationRoot, destination, {recursive: true}); - return destination; -} - -function initializerEnvironment(changes = {}) { - return { - ...process.env, - GIT_AUTHOR_NAME: "Drawing Board Test", - GIT_AUTHOR_EMAIL: "drawing-board-test@example.invalid", - ...changes, - }; -} - -function runInitializer(applicationPath, environment = {}) { - return spawnSync("bash", [initializer, applicationPath], { - cwd: harnessRoot, - encoding: "utf8", - env: initializerEnvironment(environment), - }); -} - -function sha256(contents) { - return crypto.createHash("sha256").update(contents).digest("hex"); -} - -function fileTreeDigest(root) { - const records = []; - - function visit(directory, relativeDirectory = "") { - for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { - const relativePath = path.posix.join(relativeDirectory, entry.name); - const absolutePath = path.join(directory, entry.name); - if (entry.isDirectory()) { - visit(absolutePath, relativePath); - } else if (entry.isFile()) { - records.push([relativePath, sha256(fs.readFileSync(absolutePath))]); - } else { - throw new Error(`Unexpected ambient Git entry: ${relativePath}`); - } - } - } - - visit(root); - return sha256(Buffer.from(JSON.stringify(records.sort()), "utf8")); -} - -try { - fs.mkdirSync(path.join(harnessRoot, "script"), {recursive: true}); - for (const script of [ - "application-smoke", - "initialize-application", - "application-repository-inventory.mjs", - "application-repository-inventory-lib.mjs", - ]) { - fs.copyFileSync(path.join(repositoryRoot, "script", script), path.join(harnessRoot, "script", script)); - } - - const inventoryLink = path.join(harnessRoot, "script", "inventory-entrypoint-link.mjs"); - fs.symlinkSync("application-repository-inventory.mjs", inventoryLink); - const linkedEntrypoint = spawnSync(process.execPath, [inventoryLink], {encoding: "utf8"}); - assert.notEqual(linkedEntrypoint.status, 0, "the inventory entry point must not skip execution through a symlink"); - assert.match(linkedEntrypoint.stderr, /Usage: application-repository-inventory\.mjs/); - - let rubyVersion; - try { - rubyVersion = execFileSync("ruby", ["-e", "print RUBY_VERSION"], {encoding: "utf8"}); - } catch (error) { - if (error?.code === "ENOENT") { - throw new PrerequisiteError( - "script/check requires the pinned Ruby on PATH; run it through the pinned toolchain or in the Dev Container.", - ); - } - throw error; - } - const nodeVersion = process.versions.node; - write( - ".devcontainer/agent-versions.env", - `FOUNDATION_RUBY_VERSION=${rubyVersion}\n` + - `FOUNDATION_NODE_VERSION=${nodeVersion}\n` + - "FOUNDATION_POSTGRES_VERSION=18\n", - ); - - write( - "application/.gitignore", - "/.firstdraft/\n/.env*\n/config/*.key\n/node_modules\n", - ); - write("application/.ruby-version", `ruby-${rubyVersion}\n`); - write("application/.node-version", `${nodeVersion}\n`); - write("application/.firstdraft/submitted-foundation-plan.json", "{\"plan\":true}\n"); - write("application/.firstdraft/gaps.json", "{\"gaps\":[]}\n"); - write("application/bin/setup", "#!/usr/bin/env bash\ntouch setup-ran\n", 0o755); - write("application/bin/ci", "#!/usr/bin/env bash\nexit 0\n", 0o755); - write("application/ordinary.txt", "trailing whitespace stays exact \n"); - process.umask(0o022); - - const expectedPaths = [ - ".firstdraft/gaps.json", - ".firstdraft/submitted-foundation-plan.json", - ".gitignore", - ".node-version", - ".ruby-version", - "bin/ci", - "bin/setup", - "ordinary.txt", - ].sort(); - const expected = filesystemInventory(applicationRoot, expectedPaths); - - copyApplication("custom-application"); - const envApplication = copyApplication("env-application"); - writeAt(envApplication, ".env", "SECRET=do-not-commit\n", 0o600); - const keyApplication = copyApplication("key-application"); - writeAt(keyApplication, "config/master.key", "do-not-commit\n", 0o600); - const extraFirstdraftApplication = copyApplication("extra-firstdraft-application"); - writeAt(extraFirstdraftApplication, ".firstdraft/extra.json", "{}\n"); - const setupApplication = copyApplication("setup-application"); - writeAt(setupApplication, "node_modules/tool.js", "export default true;\n"); - fs.mkdirSync(path.join(setupApplication, "node_modules/.bin"), {recursive: true}); - fs.symlinkSync("../tool.js", path.join(setupApplication, "node_modules/.bin/tool")); - const linkedApplication = copyApplication("linked-application"); - fs.symlinkSync("ordinary.txt", path.join(linkedApplication, "ordinary-link")); - const modeApplication = copyApplication("mode-application"); - fs.chmodSync(path.join(modeApplication, "ordinary.txt"), 0o664); - - const hostileGitRoot = path.join(temporaryRoot, "ambient-repository"); - fs.mkdirSync(hostileGitRoot); - execFileSync("git", ["init", "--quiet", "--initial-branch=main", hostileGitRoot]); - writeAt(hostileGitRoot, "marker.txt", "ambient repository\n"); - execFileSync("git", ["-C", hostileGitRoot, "add", "marker.txt"]); - execFileSync( - "git", - [ - "-C", - hostileGitRoot, - "-c", - "user.name=Ambient Test", - "-c", - "user.email=ambient@example.invalid", - "commit", - "--quiet", - "--message=Ambient repository", - ], - ); - const hostileGitDirectory = path.join(hostileGitRoot, ".git"); - const ambientHead = execFileSync("git", ["-C", hostileGitRoot, "rev-parse", "HEAD"], { - encoding: "utf8", - }).trim(); - const ambientIndexSha256 = sha256(fs.readFileSync(path.join(hostileGitDirectory, "index"))); - const ambientObjectsSha256 = fileTreeDigest(path.join(hostileGitDirectory, "objects")); - - const uninitializedSmoke = spawnSync("bash", [path.join(harnessRoot, "script", "application-smoke")], { - cwd: harnessRoot, - encoding: "utf8", - env: { - ...process.env, - GIT_DIR: hostileGitDirectory, - GIT_WORK_TREE: hostileGitRoot, - GIT_INDEX_FILE: path.join(hostileGitDirectory, "index"), - }, - }); - assert.notEqual(uninitializedSmoke.status, 0, "application smoke must reject a missing nested repository"); - assert.match(uninitializedSmoke.stderr, /Initialize the generated application with script\/initialize-application/); - assert.equal( - fs.existsSync(path.join(applicationRoot, "setup-ran")), - false, - "application setup ran before Git validation", - ); - - const extraArguments = spawnSync("bash", [initializer, "application", "extra"], { - cwd: harnessRoot, - encoding: "utf8", - }); - assert.notEqual(extraArguments.status, 0, "initializer must reject extra arguments"); - assert.match(extraArguments.stderr, /Usage: script\/initialize-application/); - assert.equal(fs.existsSync(path.join(applicationRoot, ".git")), false); - - const outsideRoot = spawnSync("bash", [initializer, temporaryRoot], { - cwd: harnessRoot, - encoding: "utf8", - }); - assert.notEqual(outsideRoot.status, 0, "initializer must reject paths outside Drawing Board"); - assert.match(outsideRoot.stderr, /must resolve inside the Drawing Board/); - assert.equal(fs.existsSync(path.join(applicationRoot, ".git")), false); - - for (const [name, rejectedRoot, rejectedPath] of [ - ["env-application", envApplication, ".env"], - ["key-application", keyApplication, "config/"], - ["extra-firstdraft-application", extraFirstdraftApplication, ".firstdraft/extra.json"], - ["setup-application", setupApplication, "node_modules/"], - ]) { - const rejected = runInitializer(name); - assert.notEqual(rejected.status, 0, `${name} must reject ignored local state`); - assert.match(rejected.stderr, new RegExp(`application/${rejectedPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); - assert.match(rejected.stderr, /nothing was removed/); - assert.equal(fs.existsSync(path.join(rejectedRoot, ".git")), false); - } - assert.equal(fs.readFileSync(path.join(envApplication, ".env"), "utf8"), "SECRET=do-not-commit\n"); - assert.equal(fs.readFileSync(path.join(keyApplication, "config/master.key"), "utf8"), "do-not-commit\n"); - assert.equal(fs.lstatSync(path.join(setupApplication, "node_modules/.bin/tool")).isSymbolicLink(), true); - - const linkedRejected = runInitializer("linked-application"); - assert.notEqual(linkedRejected.status, 0, "initializer must reject an admitted symbolic link"); - assert.match(linkedRejected.stderr, /Unsupported generated application entry: ordinary-link/); - assert.equal(fs.lstatSync(path.join(linkedApplication, "ordinary-link")).isSymbolicLink(), true); - assert.equal(fs.existsSync(path.join(linkedApplication, ".git")), false); - - const modeRejected = runInitializer("mode-application"); - assert.notEqual(modeRejected.status, 0, "initializer must reject a noncanonical generated mode"); - assert.match(modeRejected.stderr, /Unsupported generated application mode at ordinary\.txt: 664/); - assert.match(modeRejected.stderr, /Compile again into a fresh absent directory/); - assert.equal(fs.statSync(path.join(modeApplication, "ordinary.txt")).mode & 0o777, 0o664); - assert.equal(fs.existsSync(path.join(modeApplication, ".git")), false); - - const initialized = runInitializer("application", { - GIT_DIR: hostileGitDirectory, - GIT_WORK_TREE: hostileGitRoot, - GIT_INDEX_FILE: path.join(hostileGitDirectory, "index"), - GIT_OBJECT_DIRECTORY: path.join(hostileGitDirectory, "objects"), - GIT_ALTERNATE_OBJECT_DIRECTORIES: path.join(hostileGitDirectory, "objects"), - GIT_COMMON_DIR: hostileGitDirectory, - GIT_NAMESPACE: "ambient", - GIT_CONFIG_COUNT: "3", - GIT_CONFIG_KEY_0: "commit.gpgsign", - GIT_CONFIG_VALUE_0: "true", - GIT_CONFIG_KEY_1: "core.autocrlf", - GIT_CONFIG_VALUE_1: "true", - GIT_CONFIG_KEY_2: "core.fileMode", - GIT_CONFIG_VALUE_2: "false", - }); - assert.equal(initialized.status, 0, initialized.stderr); - - assert.equal( - execFileSync("git", ["-C", hostileGitRoot, "rev-parse", "HEAD"], {encoding: "utf8"}).trim(), - ambientHead, - ); - assert.equal( - sha256(fs.readFileSync(path.join(hostileGitDirectory, "index"))), - ambientIndexSha256, - "initializer changed the ambient Git index", - ); - assert.equal( - fileTreeDigest(path.join(hostileGitDirectory, "objects")), - ambientObjectsSha256, - "initializer changed the ambient Git object store", - ); - assert.equal( - execFileSync("git", ["-C", hostileGitRoot, "status", "--porcelain"], {encoding: "utf8"}).trim(), - "", - ); - - const git = (...arguments_) => execFileSync("git", ["-C", applicationRoot, ...arguments_], { - encoding: "utf8", - }).trim(); - assert.equal(git("branch", "--show-current"), "main"); - assert.equal(git("rev-list", "--parents", "--max-count=1", "HEAD").split(/\s+/).length, 1); - assert.equal(git("status", "--porcelain"), ""); - - const current = filesystemInventory(applicationRoot, expectedPaths); - const committed = committedInventory(applicationRoot, path.join(applicationRoot, ".git")); - assertSameInventory(expected, current, "Fixture source inventory"); - assertSameInventory(expected, committed, "Fixture commit inventory"); - assert.equal(committed.find((record) => record.path === "bin/setup")?.mode, "100755"); - assert.deepEqual(committed.map((record) => record.path), expectedPaths); - assert.equal( - fs.readFileSync(path.join(applicationRoot, "ordinary.txt"), "utf8"), - "trailing whitespace stays exact \n", - ); - - const customRoot = path.join(harnessRoot, "custom-application"); - const customInitialized = runInitializer("custom-application"); - assert.equal(customInitialized.status, 0, customInitialized.stderr); - assert.equal(execFileSync("git", ["-C", customRoot, "branch", "--show-current"], { - encoding: "utf8", - }).trim(), "main"); - assertSameInventory(expected, filesystemInventory(customRoot, expectedPaths), "Custom-path source inventory"); - assertSameInventory( - expected, - committedInventory(customRoot, path.join(customRoot, ".git")), - "Custom-path commit inventory", - ); - - console.log("Generated application initialization contract passed."); -} catch (error) { - if (!(error instanceof PrerequisiteError)) throw error; - console.error(error.message); - process.exitCode = 1; -} finally { - process.umask(originalUmask); - fs.rmSync(temporaryRoot, {recursive: true, force: true}); -} diff --git a/script/devcontainer-image-smoke b/script/devcontainer-image-smoke deleted file mode 100755 index 3a64025..0000000 --- a/script/devcontainer-image-smoke +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -metadata_only=false -if [[ "${1:-}" == "--metadata-only" ]]; then - metadata_only=true - shift -fi - -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]*) ;; - *) - echo "The development-image smoke requires an immutable image reference." >&2 - exit 1 - ;; -esac - -repo_root="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -root="$(mktemp -d)" -containers=() -cleanup() { - if ((${#containers[@]})); then - docker rm --force "${containers[@]}" >/dev/null 2>&1 || true - fi - rm -rf "${root}" -} -trap cleanup EXIT - -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"); -const [inspectPath, lockPath] = process.argv.slice(2); -const inspect = JSON.parse(fs.readFileSync(inspectPath, "utf8")); -const lockfile = JSON.parse(fs.readFileSync(lockPath, "utf8")); -if (inspect.length !== 1) throw new Error("Expected one inspected development image."); -const rawMetadata = inspect[0]?.Config?.Labels?.["devcontainer.metadata"]; -if (!rawMetadata) throw new Error("The development image has no devcontainer.metadata label."); -const metadata = JSON.parse(rawMetadata); -const entries = Array.isArray(metadata) ? metadata : [metadata]; -const installedIds = entries.filter((entry) => entry?.id).map((entry) => entry.id); -const lockedFeatures = Object.entries(lockfile.features ?? {}); -const lockedIds = lockedFeatures.map(([id]) => id); -if (lockedIds.length === 0) throw new Error("The development-image lockfile has no Features."); -for (const [id, feature] of lockedFeatures) { - const integrity = feature?.integrity; - const resolved = feature?.resolved; - if (!/^sha256:[0-9a-f]{64}$/.test(integrity ?? "")) { - throw new Error(`The lockfile must bind an exact integrity digest for ${id}.`); - } - if (resolved !== `${id.replace(/:\d+$/, "")}@${integrity}`) { - throw new Error(`The lockfile resolved reference does not match its integrity digest for ${id}.`); - } -} -for (const id of lockedIds) { - if (installedIds.filter((installed) => installed === id).length !== 1) { - throw new Error(`Published Feature metadata must contain ${id} exactly once.`); - } - const feature = id.replace(/:\d+$/, ""); - if (installedIds.some((installed) => installed !== id && installed.replace(/:\d+$/, "") === feature)) { - 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 [[ "${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 \ - --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 _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 "The development image exited while starting the maintained SSH listener." >&2 - exit 1 - } - 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 - } -done - -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 -} -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 -} -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 default command, maintained-SSH lifecycle, and PostgreSQL ${postgresql_clients[0]} clients passed." diff --git a/script/devcontainer-smoke b/script/devcontainer-smoke deleted file mode 100755 index 3e31443..0000000 --- a/script/devcontainer-smoke +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2016 -set -euo pipefail - -cd "$(dirname "$0")/.." - -# shellcheck disable=SC1091 -source .devcontainer/agent-versions.env - -assert_version() { - local expected="$1" - shift - local output - if ! output="$("$@" 2>&1)"; then - echo "$* failed while checking expected version $expected." >&2 - exit 1 - fi - local normalized_output - normalized_output="$(printf '%s' "$output" | tr '[:space:]' ' ' | tr -s ' ')" - if [[ " $normalized_output " != *" $expected "* ]]; then - echo "Expected $expected from $*, found: $output" >&2 - exit 1 - fi -} - -assert_version "$FIRSTDRAFT_CLI_VERSION" firstdraft --version -assert_version "$FOUNDATION_RUBY_VERSION" ruby --version -assert_version "v$FOUNDATION_NODE_VERSION" node --version -psql --version | grep -Eq "^psql \(PostgreSQL\) ${FOUNDATION_POSTGRES_VERSION}\." -pg_dump --version | grep -Eq "^pg_dump \(PostgreSQL\) ${FOUNDATION_POSTGRES_VERSION}\." -docker version >/dev/null -docker compose version >/dev/null - -test -x /usr/sbin/sshd -sudo /usr/sbin/sshd -t -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 ' - const {parseEnv} = require("node:util"); - const environment = parseEnv(require("node:fs").readFileSync("/etc/environment", "utf8")); - process.stdout.write(environment.PATH ?? ""); -')" -test "${shared_path#"$PWD/bin:$HOME/.local/bin:"}" != "$shared_path" -test "$(PATH="$shared_path" command -v firstdraft)" = "$PWD/bin/firstdraft" -test "$(PATH="$shared_path" command -v claude)" = "$HOME/.local/bin/claude" -test "$(PATH="$shared_path" command -v codex)" = "$HOME/.local/bin/codex" - -script/agent-smoke - -test "$(command -v firstdraft)" = "$PWD/bin/firstdraft" -test "$(ruby -rrbconfig -e 'print RbConfig.ruby')" = "$(mise which ruby)" -test ! -L .env -test -f .env -test -O .env -test "$(stat -c '%a' .env)" = "600" - -FIRSTDRAFT_STAGING_API_URL="$FIRSTDRAFT_STAGING_API_URL" node -e ' - const {parseEnv} = require("node:util"); - const parsed = parseEnv(require("node:fs").readFileSync(".env", "utf8")); - const valid = Object.keys(parsed).sort().join(",") === - "FIRSTDRAFT_API_TOKEN,FIRSTDRAFT_API_URL" && - parsed.FIRSTDRAFT_API_URL === process.env.FIRSTDRAFT_STAGING_API_URL && - parsed.FIRSTDRAFT_API_TOKEN === ""; - process.exit(valid ? 0 : 1); -' - -test "$DB_HOST" = "postgres" -test "$SELENIUM_HOST" = "selenium" -test "$CAPYBARA_SERVER_HOST" = "rails-app" -PGPASSWORD=postgres pg_isready --host "$DB_HOST" --username postgres >/dev/null -postgres_version="$(PGPASSWORD=postgres psql --host "$DB_HOST" --username postgres --dbname postgres --tuples-only --no-align --command 'SHOW server_version')" -[[ "$postgres_version" == "$FOUNDATION_POSTGRES_VERSION."* ]] - -if script/selenium running; then - echo "Selenium must stay stopped until generated browser tests request it." >&2 - exit 1 -else - selenium_status=$? - if [[ "${selenium_status}" -ne 1 ]]; then - echo "Could not verify that Selenium is stopped." >&2 - exit "${selenium_status}" - fi -fi - -if [[ -d application ]]; then - script/application-smoke -else - echo "Generated application smoke skipped: ./application is not present." -fi - -echo "Devcontainer installation smoke passed." diff --git a/script/selenium b/script/selenium deleted file mode 100755 index c22105f..0000000 --- a/script/selenium +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -root="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -compose_file="${root}/.devcontainer/compose.yaml" - -container_id="${HOSTNAME:-$(hostname)}" -project="$(docker inspect --format '{{ index .Config.Labels "com.docker.compose.project" }}' "${container_id}" 2>/dev/null || true)" -if [[ -z "${project}" || ! "${project}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then - echo "Run script/selenium inside the Drawing Board Dev Container." >&2 - exit 2 -fi - -case "${1:-start}" in - start) - docker compose --project-name "${project}" --file "${compose_file}" up --detach --wait selenium - for _ in {1..60}; do - status="$(curl --silent --show-error --fail http://selenium:4444/wd/hub/status 2>/dev/null || true)" - if node -e ' - const input = JSON.parse(require("node:fs").readFileSync(0, "utf8")); - process.exit(input.value?.ready === true ? 0 : 1); - ' <<<"${status}" 2>/dev/null; then - exit 0 - fi - sleep 1 - done - docker compose --project-name "${project}" --file "${compose_file}" logs selenium >&2 - echo "Selenium did not become ready." >&2 - exit 1 - ;; - status) - docker compose --project-name "${project}" --file "${compose_file}" ps selenium - ;; - running) - if ! running_services="$(docker compose --project-name "${project}" --file "${compose_file}" ps --status running --services selenium)"; then - echo "Could not determine the Selenium service state." >&2 - exit 2 - fi - [[ "${running_services}" == "selenium" ]] - ;; - stop) - docker compose --project-name "${project}" --file "${compose_file}" stop selenium - ;; - *) - echo "Usage: script/selenium [start|status|running|stop]" >&2 - exit 1 - ;; -esac