diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5f32b0a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directories: + - / + - /drawing-board + - /drawing-board/image + - /drawing-board/image/verify + schedule: + interval: weekly + - package-ecosystem: devcontainers + directory: /drawing-board/image + schedule: + interval: weekly diff --git a/.github/workflows/drawing-board.yml b/.github/workflows/drawing-board.yml new file mode 100644 index 0000000..26f0579 --- /dev/null +++ b/.github/workflows/drawing-board.yml @@ -0,0 +1,48 @@ +name: Drawing Board checks + +on: + pull_request: + paths: [drawing-board/**, .github/workflows/drawing-board.yml] + push: + branches: [main] + paths: [drawing-board/**, .github/workflows/drawing-board.yml] + workflow_dispatch: + inputs: + candidate: + description: Exact firstdraft/drawing-board commit to qualify + required: true + default: 416ed5cbf08b0248f5a43cbe2bfe84c1a730c813 + +permissions: + contents: read + packages: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Select the exact template candidate + id: candidate + env: + CANDIDATE: ${{ inputs.candidate || '416ed5cbf08b0248f5a43cbe2bfe84c1a730c813' }} + run: | + [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]] + echo "sha=$CANDIDATE" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: firstdraft/drawing-board + ref: ${{ steps.candidate.outputs.sha }} + path: board-candidate + fetch-depth: 0 + persist-credentials: false + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: ./drawing-board + with: + workspace: board-candidate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..406e7bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/board-candidate/ +/tmp/ diff --git a/README.md b/README.md index cda2ae4..9d8a735 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # dockerfiles Storage for `Dockerfile`'s that are used for different student projects. + +[Drawing Board maintenance](drawing-board/README.md) owns its image source, template checks, and retained +qualification reports. The student workspace remains in [firstdraft/drawing-board](https://github.com/firstdraft/drawing-board). diff --git a/drawing-board/LICENSE b/drawing-board/LICENSE new file mode 100644 index 0000000..e63b7d2 --- /dev/null +++ b/drawing-board/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 firstdraft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/drawing-board/README.md b/drawing-board/README.md new file mode 100644 index 0000000..c9c01aa --- /dev/null +++ b/drawing-board/README.md @@ -0,0 +1,385 @@ +# Drawing Board maintenance + +This directory owns the checks, image source, and qualification reports for the +[Drawing Board student template](https://github.com/firstdraft/drawing-board). Read that candidate's `AGENTS.md` when +changing its runtime or onboarding. The student checkout contains bootstrap and application work, while CI obtains +these maintainer tools separately. [The relocation inventory](docs/relocation.md) maps every former path. + +Run commands below from `drawing-board/` in this repository. `DRAWING_BOARD_PATH` is an absolute path to a separate, +disposable Drawing Board checkout. Keep credentials and user work out of that checkout. + +## 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](docs/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](https://github.com/firstdraft/drawing-board/blob/main/README.md#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. Its initializer remains +a student tool; its optional `script/application-smoke` qualification runner lives here and is not needed by students. **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](docs/DIRECT_COMPILATION_PLAN.md). + +## Repository map + +| Location | Responsibility | +|---|---| +| Drawing Board `.devcontainer/` | Runtime Compose, lifecycle configuration, agent setup and selected tool pins | +| Drawing Board `bin/` | CLI wrapper, installation diagnostics and optional Plan review | +| Drawing Board `script/initialize-application` and inventory modules | Nested application's initial Git checkpoint | +| Drawing Board `script/refresh-codespaces-private-port` | Existing Codespaces attachment behavior | +| `image/` | Dockerfile, locked build Features and the historical published-image receipt | +| `action.yml` | Source checks and two installation smokes in the exact caller checkout's Dev Container | +| `image/action.yml`, `image/verify/action.yml` | Authorized image build, then AMD64 runtime / ARM64 metadata verification in a dependent job | +| `script/check*` | Source, credential, installation, preservation, discovery and Git-initialization checks | +| `script/agent-smoke`, `script/devcontainer-smoke` | Installed-agent and workspace runtime checks | +| `script/application-smoke`, `script/selenium` | Optional nested-app qualification; not the root materialization path | +| `docs/` | Relocation inventory and retained investigation/qualification evidence | + +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` in the repository being changed. From this directory, run: + +```sh +script/check /absolute/path/to/drawing-board-candidate +``` + +Use the candidate's pinned Ruby and Node, or run inside its Dev Container. Checks read the candidate's production +modules directly and keep their fixtures in temporary directories. They do not need a First Draft token. + +Drawing Board's `contract` job checks out GitHub's exact PR merge candidate (or the pushed main commit), then calls +`firstdraft/dockerfiles/drawing-board` at one full commit SHA. The action copies its `script/` and `image/` into an +owned ignored `tmp/` directory only in that disposable CI checkout, starts the actual candidate Dev Container with +the existing pinned `devcontainers/ci` action, and runs source checks plus the installation smoke twice. An always +step removes only that temporary directory. No maintainer code is committed to the template or retained in a +student planning archive. Keep the original check name and read-only token permissions in the caller. + +This repository's own workflow tests changes against the explicit Drawing Board SHA in +`.github/workflows/drawing-board.yml`. Manual dispatch can select another exact SHA. That check qualifies the +maintainer change against the named template; the Board PR still needs its own current-candidate check after its +caller pin changes. Land the maintainer revision first, then update all Board action pins to its merged SHA. +No private Service checkout, dispatch credential, or repository-access change is required. + +For a local Dev Container run, mount this directory outside the workspace, for example at `/opt/board-checks`, using +the Dev Container CLI's `--mount` option. Inside the container: + +```sh +export DRAWING_BOARD_PATH=/workspaces/drawing-board +/opt/board-checks/script/check "$DRAWING_BOARD_PATH" +/opt/board-checks/script/devcontainer-smoke +/opt/board-checks/script/devcontainer-smoke +``` + +Use a disposable checkout and task-owned Compose resources. The runtime checks verify the pinned Ruby and its +mise-selected runtime; interactive [mise activation](https://mise.jdx.dev/dev-tools/shims.html) can select the real +executable ahead of its shim. Agent probes require no sign-in or model turn. They do not prove authenticated +Compilation or Codespaces attachment. The optional nested runner is a separate explicit invocation with +`DRAWING_BOARD_PATH` set; it runs an already-compiled `application/` and never substitutes for root materialization. + +### 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](docs/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 `image/Dockerfile` or `image/.devcontainer.json` in this repository; +2. let the current Dev Container CLI regenerate `image/.devcontainer-lock.json`, then review every + resolved Feature version and digest rather than editing the lock by hand; +3. after separate publication approval, update both Board image-action pins to the merged maintainer revision and + push one Board `devcontainer-image-candidate-safe-` tag (or use its existing manual dispatch); +4. verify both image platforms, then record the reviewed receipt and immutable digest, including the Board caller + revision and exact dockerfiles image-source revision; update the receipt checker for that successor provenance; +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 remains Board-owned and uses its existing package-write job token. Its SHA-pinned build +and verification actions live here; they do not move a stable or `latest` tag. Keep verification in a separate job +with `needs: build`, because `devcontainers/ci` pushes during the build job's post phase. No image was published +during this relocation. +The retained receipt describes the prior Board publication, not a build from this new location. Its historical +source commit and blobs are still checked against Board Git history. The relocated Dockerfile and lockfile are +byte-identical; the image configuration differs only in relative build paths. The 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](https://github.com/firstdraft/drawing-board/blob/main/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](docs/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 "$DRAWING_BOARD_PATH"`, 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](docs/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](docs/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](docs/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](docs/STARTUP_INVESTIGATION.md) for measurements and proof boundaries. + +## Publish from the Codespace terminal + +The student-facing [terminal publication instructions](https://github.com/firstdraft/drawing-board/blob/main/README.md#publish-from-the-codespace-terminal) +remain with the template. The [historical credential receipt](docs/STARTUP_INVESTIGATION.md#publication-credentials) +records the existing built-in-token behavior; relocation adds no publication permission or provider observation. + +## Credentials and external systems + +Never commit a First Draft API token, GitHub token, agent credential, or generated `.env`. `script/check` scans the +Board candidate 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](https://github.com/firstdraft/drawing-board/blob/main/README.md) focused on the beginner journey. Put maintainer commands and implementation details here, +and keep student agent guardrails in Drawing Board `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](docs/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/drawing-board/action.yml b/drawing-board/action.yml new file mode 100644 index 0000000..f6a5d13 --- /dev/null +++ b/drawing-board/action.yml @@ -0,0 +1,44 @@ +name: Check Drawing Board +description: Check the caller's exact Drawing Board checkout in its own Dev Container +inputs: + workspace: + description: Relative path to the Drawing Board candidate checkout + default: . +runs: + using: composite + steps: + - name: Stage external checks in the disposable checkout + id: checks + shell: bash + working-directory: ${{ inputs.workspace }} + env: + CHECKS_SOURCE: ${{ github.action_path }} + run: | + set -euo pipefail + mkdir -p tmp + directory="$(mktemp -d tmp/drawing-board-checks.XXXXXX)" + cp -R "$CHECKS_SOURCE/script" "$CHECKS_SOURCE/image" "$directory/" + echo "directory=$directory" >> "$GITHUB_OUTPUT" + - uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + subFolder: ${{ inputs.workspace }} + push: never + runCmd: | + set -euo pipefail + export DRAWING_BOARD_PATH="$PWD" + checks="${{ steps.checks.outputs.directory }}/script" + FIRSTDRAFT_REQUIRE_IMAGE_SOURCE_COMMIT=1 "$checks/check" "$PWD" + "$checks/devcontainer-smoke" + "$checks/devcontainer-smoke" + - name: Remove the staged checks + if: always() + shell: bash + working-directory: ${{ inputs.workspace }} + env: + CHECK_DIRECTORY: ${{ steps.checks.outputs.directory }} + run: | + case "$CHECK_DIRECTORY" in + tmp/drawing-board-checks.*) rm -rf -- "$CHECK_DIRECTORY" ;; + "") ;; + *) echo "Unexpected check directory: $CHECK_DIRECTORY" >&2; exit 1 ;; + esac diff --git a/drawing-board/docs/DIRECT_COMPILATION_PLAN.md b/drawing-board/docs/DIRECT_COMPILATION_PLAN.md new file mode 100644 index 0000000..373be75 --- /dev/null +++ b/drawing-board/docs/DIRECT_COMPILATION_PLAN.md @@ -0,0 +1,650 @@ +# Direct Compilation in the Drawing Board workspace + +Retained Drawing Board evidence, relocated from `416ed5cbf08b` without a new provider trial. +Current maintenance commands live in [the maintainer guide](../README.md). + +## 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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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`](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/.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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/README.md#5-describe-your-app) +and [browser-testing instructions](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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/drawing-board/docs/PREBUILD_EXPERIMENT.md b/drawing-board/docs/PREBUILD_EXPERIMENT.md new file mode 100644 index 0000000..40019ca --- /dev/null +++ b/drawing-board/docs/PREBUILD_EXPERIMENT.md @@ -0,0 +1,138 @@ +# Tool preparation in Codespaces prebuilds + +Retained Drawing Board evidence, relocated from `416ed5cbf08b` without a new provider trial. +Current maintenance commands live in [the maintainer guide](../README.md). + +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/drawing-board/docs/RELOCATION_QUALIFICATION.md b/drawing-board/docs/RELOCATION_QUALIFICATION.md new file mode 100644 index 0000000..74f4a9c --- /dev/null +++ b/drawing-board/docs/RELOCATION_QUALIFICATION.md @@ -0,0 +1,72 @@ +# Maintainer relocation qualification — 2026-09-26 + +This records local and hosted checks for [Drawing Board #54](https://github.com/firstdraft/drawing-board/issues/54). +It does not qualify a new image publication or a Codespaces provider session. + +## Revisions and installation + +| Input | Observed revision or version | +|---|---| +| Original Board | `416ed5cbf08b0248f5a43cbe2bfe84c1a730c813` | +| Cleaned Board used for local application proof | `467ed2307ceff4932538ebdfb534134f9b35af2b` | +| External checks and relocated image recipe | `1f3efee9456c201c9ae221dc01a7adef9fd2638b` | +| Local Service Compiler | `ac29a2556e9ae8b0eb30ae50961f2cb93a560833` | +| Released First Draft CLI | `0.7.0` | +| Installed Claude / Codex | `2.1.283` / `0.157.1` | +| Container Ruby / Node / PostgreSQL | `4.0.5` / `24.18.0` / `18` | +| Dev Container CLI | `0.89.0` | + +The cleaned candidate preserves every retained runtime/bootstrap file's bytes and mode. The external source +checks passed against both original and cleaned candidates. They exercised setup/rerun, wrapper credentials, +Skill discovery and preservation, nested Git initialization, private-port behavior, Codex configuration, and +the historical image receipt, including its source commit through a real shallow clone. + +The [maintainer contract](https://github.com/firstdraft/dockerfiles/actions/runs/36276370503/job/108499800105) +passed against the original Board. The [Board contract](https://github.com/firstdraft/drawing-board/actions/runs/36276405837/job/108499900873) +passed against merge candidate `28a5ae9` for cleaned Board `467ed23`, using the SHA-pinned external action. +Each ran the actual candidate Dev Container and two installation smokes. Final amendments still require the +current Board candidate's own hosted result; these historical runs cannot substitute for that gate. + +A separate local ARM64 Dev Container used the published immutable runtime image and mounted the maintainer +directory outside the student workspace. Source checks and two complete installation smokes passed, including +native agent discovery without sign-in or a model turn, blank credential-file permissions, PostgreSQL, SSH, +and Selenium remaining stopped. This exercised the existing latest-agent installation policy unchanged. + +## Root application proof + +A task-private adaptation of the Service's existing HTTP/CLI smoke pushed and analyzed its Movie Catalog Plan +through a local Service, then invoked the released CLI with `plan compile --output .` in the live cleaned Board +checkout. It produced 259 files, preserved Git HEAD and the existing remote, preserved an extra student note in +the planning archive, and retained the submitted Plan byte-for-byte. The archive contained no removed maintainer +runner, image recipe, contributor guide, or investigation report. The fixture recorded zero GitHub Publications. +The local Service transport was used directly; this was not a staging-wrapper or remote-publication observation. + +- Compiler identity: `foundation-plan-rails/compiler-application-2026-09-26-rails-csp`, profile `rails-sketch/2026-09`. +- Artifact SHA-256: `7611e21cd3a6a676dbab04307dbc71cb4727fdddac999cfb3e112fa010aa4646` (1,114,289 bytes). +- File manifest SHA-256: `32efefb539a1241cea6f960b7cd38a0c7ad0d7fcd8c1f7519937a9eb156cba00`. + +The entire `.firstdraft/design/` directory was then moved outside the generated application. Its generated +Compose recipe started Selenium in the existing container's project. Ordinary `bin/setup --skip-server` and +`CI=1 bin/ci` passed: dependency/security checks, style, schema, assets, 77 ordinary RSpec examples, seeds, +9 browser examples, and no generated diff. Selenium reported `4.47.0` from the locally available image; +neither the server session-request deadline nor the Ruby HTTP timeout was changed. + +The Service fixture's final database drop waited on a shared PostgreSQL `ProcSignalBarrier`. Only this task's +drop was canceled; the stopped fixture's disposable database was handed to the coordinator for later cleanup. +The materialization assertions and generated application CI passed, but the fixture's complete cleanup did not. +No shared PostgreSQL restart or unrelated backend cancellation was performed. + +## Relocated image recipe + +The relocated recipe and locked Features built locally for ARM64. The resulting local image ID was +`sha256:916f0ef942c6979f2cf2e195247f1d022959e70c91c108f283e0a1ca0f41b260`. +The external image smoke passed locked Feature-ID metadata, the default command, the maintained SSH Feature's +lifecycle and key-only policy, and matching PostgreSQL client tools (`18.6`). This image was never pushed and +does not replace the historical receipt or the Board's runtime digest. + +The image caller preserves separate `build` and `verify` jobs because the pinned `devcontainers/ci` publishes +during its post phase. That ordering is checked against the pinned action source; no publication was performed +to test it. Workflow lint, shell/Node syntax, documentation links, and whitespace checks also passed. + +Paid Codespaces creation, private forwarded-port behavior, authenticated model invocation, publication, +deployment, and release were not exercised. Service #729 and #730 retain their provider qualification scope. diff --git a/drawing-board/docs/STARTUP_FOLLOWUP.md b/drawing-board/docs/STARTUP_FOLLOWUP.md new file mode 100644 index 0000000..02bd583 --- /dev/null +++ b/drawing-board/docs/STARTUP_FOLLOWUP.md @@ -0,0 +1,264 @@ +# Remaining Codespaces startup avenues + +Retained Drawing Board evidence, relocated from `416ed5cbf08b` without a new provider trial. +Current maintenance commands live in [the maintainer guide](../README.md). + +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/drawing-board/docs/STARTUP_INVESTIGATION.md b/drawing-board/docs/STARTUP_INVESTIGATION.md new file mode 100644 index 0000000..2c939ae --- /dev/null +++ b/drawing-board/docs/STARTUP_INVESTIGATION.md @@ -0,0 +1,167 @@ +# Drawing Board startup investigation + +Retained Drawing Board evidence, relocated from `416ed5cbf08b` without a new provider trial. +Current maintenance commands live in [the maintainer guide](../README.md). + +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](https://github.com/firstdraft/drawing-board/blob/416ed5cbf08b0248f5a43cbe2bfe84c1a730c813/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/drawing-board/docs/relocation.md b/drawing-board/docs/relocation.md new file mode 100644 index 0000000..dc94d0f --- /dev/null +++ b/drawing-board/docs/relocation.md @@ -0,0 +1,94 @@ +# Drawing Board file ownership + +Inventory from template `416ed5cbf08b0248f5a43cbe2bfe84c1a730c813` (47 tracked files). +The destination is the existing public `firstdraft/dockerfiles` repository, under `drawing-board/`. +Paths in the destination column are relative to that directory unless they name Drawing Board. + +| Original Drawing Board path | Responsibility and destination | +|---|---| +| `.devcontainer/Dockerfile` | Image build input → `image/Dockerfile` | +| `.devcontainer/agent-skills.mjs` | Runtime configuration / installation; stays in Board | +| `.devcontainer/agent-versions.env` | Runtime configuration / installation; stays in Board | +| `.devcontainer/compose.yaml` | Runtime configuration / installation; stays in Board | +| `.devcontainer/configure-codex.mjs` | Runtime configuration / installation; stays in Board | +| `.devcontainer/devcontainer.json` | Runtime configuration / installation; stays in Board | +| `.devcontainer/image/devcontainer-lock.json` | Image build input / historical receipt → `image/.devcontainer-lock.json` | +| `.devcontainer/image/devcontainer.json` | Image build input / historical receipt → `image/.devcontainer.json` | +| `.devcontainer/image/receipt.json` | Image build input / historical receipt → `image/receipt.json` | +| `.devcontainer/setup-agents` | Runtime configuration / installation; stays in Board | +| `.env.example` | Student context / onboarding / private-file protection; stays in Board | +| `.github/dependabot.yml` | Runtime and caller dependency updates stay; maintainer image/action updates move here | +| `.github/workflows/ci.yml` | Thin exact-candidate caller remains; implementation → `action.yml` | +| `.github/workflows/devcontainer-image.yml` | Thin existing publication trigger and build/verify jobs remain; implementation → `image/action.yml` and `image/verify/action.yml` | +| `.gitignore` | Student context / onboarding / private-file protection; stays in Board | +| `AGENTS.md` | Student context / onboarding / private-file protection; stays in Board | +| `CLAUDE.md` | Student context / onboarding / private-file protection; stays in Board | +| `CONTRIBUTING.md` | Maintainer guidance → `README.md`; student terminal publication → Board `README.md` | +| `DIRECT_COMPILATION_PLAN.md` | Historical investigation / qualification → `docs/DIRECT_COMPILATION_PLAN.md` | +| `LICENSE` | Attribution stays in Board and accompanies relocated code here | +| `PREBUILD_EXPERIMENT.md` | Historical investigation / qualification → `docs/PREBUILD_EXPERIMENT.md` | +| `README.md` | Student context / onboarding / private-file protection; stays in Board | +| `STARTUP_FOLLOWUP.md` | Historical investigation / qualification → `docs/STARTUP_FOLLOWUP.md` | +| `STARTUP_INVESTIGATION.md` | Historical investigation / qualification → `docs/STARTUP_INVESTIGATION.md` | +| `bin/agent-doctor` | Student CLI / installation diagnostics / Plan review; stays in Board | +| `bin/firstdraft` | Student CLI / installation diagnostics / Plan review; stays in Board | +| `bin/review-plan-with-claude` | Student CLI / installation diagnostics / Plan review; stays in Board | +| `bin/review-plan-with-codex` | Student CLI / installation diagnostics / Plan review; stays in Board | +| `script/agent-smoke` | Maintainer check / qualification runner → `script/agent-smoke` | +| `script/application-repository-inventory-lib.mjs` | Student nested Git initialization; stays in Board | +| `script/application-repository-inventory.mjs` | Student nested Git initialization; stays in Board | +| `script/application-smoke` | Maintainer check / qualification runner → `script/application-smoke` | +| `script/check` | Maintainer check / qualification runner → `script/check` | +| `script/check-agent-setup.mjs` | Maintainer check / qualification runner → `script/check-agent-setup.mjs` | +| `script/check-agent-skills.mjs` | Maintainer check / qualification runner → `script/check-agent-skills.mjs` | +| `script/check-claude-discovery.mjs` | Maintainer check / qualification runner → `script/check-claude-discovery.mjs` | +| `script/check-codespaces-private-port.mjs` | Maintainer check / qualification runner → `script/check-codespaces-private-port.mjs` | +| `script/check-codex-configuration.mjs` | Maintainer check / qualification runner → `script/check-codex-configuration.mjs` | +| `script/check-depth-one` | Maintainer check / qualification runner → `script/check-depth-one` | +| `script/check-firstdraft-wrapper.mjs` | Maintainer check / qualification runner → `script/check-firstdraft-wrapper.mjs` | +| `script/check-image-receipt.mjs` | Maintainer check / qualification runner → `script/check-image-receipt.mjs` | +| `script/check-initialize-application.mjs` | Maintainer check / qualification runner → `script/check-initialize-application.mjs` | +| `script/devcontainer-image-smoke` | Maintainer check / qualification runner → `script/devcontainer-image-smoke` | +| `script/devcontainer-smoke` | Maintainer check / qualification runner → `script/devcontainer-smoke` | +| `script/initialize-application` | Student nested Git initialization; stays in Board | +| `script/refresh-codespaces-private-port` | Runtime Codespaces attachment; stays in Board | +| `script/selenium` | Maintainer check / qualification runner → `script/selenium` | + +## Checks and interpretation + +The [relocation qualification receipt](RELOCATION_QUALIFICATION.md) records the actual local and hosted checks, +including root application CI with the planning archive removed and the remaining provider boundary. + +The relocated checks import the candidate's actual setup, wrapper, Skill and nested-initialization code. They retain +installation/rerun behavior, user-state preservation, credential handling, Git bytes/modes, private-port behavior, +actual agent discovery and ordinary application CI. Old assertions that only repeated workflow action strings and +Skill prose were removed; executable CLI compatibility and pinned image/runtime checks remain. + +`application-smoke` remains an optional maintainer runner for a nested, already-compiled app. It is no longer a +student command or a conditional branch of the installation smoke. Root materialization uses the CLI's existing +root-output transaction and the generated app's own setup, Compose and tests. The planning archive is optional +context, not a test-runner location. Ordinary generated application tests are unchanged. + +## Integration and retained decisions + +Land this maintainer destination before updating the Board CI/image caller pins and removing their old files. +After the Board lands, update both default candidate SHAs in the maintainer workflow to that merged Board revision. +The small Board callers retain the `contract` job, pull-request/main triggers, candidate-image trigger and existing +job-token permissions. The image action is not run by ordinary CI. Publication requires its existing separate +authorization; this relocation neither publishes an image nor changes GHCR package access. + +The image verification job must keep `needs: build`: `devcontainers/ci` publishes in its end-of-job post step. +Verification inside the build job would run before the candidate exists in GHCR. + +GitHub supports [public callers using public reusable code](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#access-to-reusable-workflows). +Using the existing public image repository avoids a private-Service dispatch credential or access change. +No branch-protection setting is changed. A required-check result must still belong to the actual Board merge +candidate; the maintainer repository's fixed-candidate test does not replace it. + +The maintainer guide was moved from current Board main. Pending Board #48 maps from `CONTRIBUTING.md` to this +`README.md`; Service #762 still owns its separate `RELEASE_COORDINATION.md` change. Neither pending diff was +absorbed. The root coordinator owns their later reconciliation. + +Latest Claude/Codex native installation, the runtime image digest, tool pins, attachment logic and Selenium +session timeouts are unchanged. Local/source/container results are distinct from Codespaces-provider behavior. +Service #729 and #730 retain their remaining provider qualification. No paid provider trial is part of this move. diff --git a/drawing-board/image/.devcontainer-lock.json b/drawing-board/image/.devcontainer-lock.json new file mode 100644 index 0000000..881b032 --- /dev/null +++ b/drawing-board/image/.devcontainer-lock.json @@ -0,0 +1,34 @@ +{ + "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/drawing-board/image/.devcontainer.json b/drawing-board/image/.devcontainer.json new file mode 100644 index 0000000..c148003 --- /dev/null +++ b/drawing-board/image/.devcontainer.json @@ -0,0 +1,16 @@ +{ + "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/drawing-board/image/Dockerfile b/drawing-board/image/Dockerfile new file mode 100644 index 0000000..b305d34 --- /dev/null +++ b/drawing-board/image/Dockerfile @@ -0,0 +1,30 @@ +# 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/drawing-board/image/action.yml b/drawing-board/image/action.yml new file mode 100644 index 0000000..16151af --- /dev/null +++ b/drawing-board/image/action.yml @@ -0,0 +1,14 @@ +name: Build Drawing Board image +description: Publish an authorized candidate using the caller's existing GHCR credentials +runs: + using: composite + steps: + - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + configFile: ${{ github.action_path }}/.devcontainer.json + imageName: ghcr.io/firstdraft/drawing-board-workspace + imageTag: sha-${{ github.sha }} + platform: linux/amd64,linux/arm64 + push: always diff --git a/drawing-board/image/receipt.json b/drawing-board/image/receipt.json new file mode 100644 index 0000000..82e5165 --- /dev/null +++ b/drawing-board/image/receipt.json @@ -0,0 +1,61 @@ +{ + "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/drawing-board/image/verify/action.yml b/drawing-board/image/verify/action.yml new file mode 100644 index 0000000..dfdf4e7 --- /dev/null +++ b/drawing-board/image/verify/action.yml @@ -0,0 +1,21 @@ +name: Verify Drawing Board image +description: Verify the candidate after the caller's build job finishes publishing +runs: + using: composite + steps: + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - name: Verify the published platforms + shell: bash + env: + IMAGE: ghcr.io/firstdraft/drawing-board-workspace:sha-${{ github.sha }} + IMAGE_TOOLS: ${{ github.action_path }} + run: | + set -euo pipefail + index="$(docker buildx imagetools inspect --raw "$IMAGE")" + amd64="$(jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' <<< "$index")" + arm64="$(jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest' <<< "$index")" + [[ "$(jq '[.manifests[] | select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64"))] | length' <<< "$index")" == 2 ]] + docker pull --platform linux/amd64 "${IMAGE%:*}@$amd64" + "$IMAGE_TOOLS/../../script/devcontainer-image-smoke" "${IMAGE%:*}@$amd64" + docker pull --platform linux/arm64 "${IMAGE%:*}@$arm64" + "$IMAGE_TOOLS/../../script/devcontainer-image-smoke" --metadata-only "${IMAGE%:*}@$arm64" diff --git a/drawing-board/script/agent-smoke b/drawing-board/script/agent-smoke new file mode 100755 index 0000000..daceb3f --- /dev/null +++ b/drawing-board/script/agent-smoke @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +checks="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +cd "${DRAWING_BOARD_PATH:?Set DRAWING_BOARD_PATH to the candidate checkout.}" + +# 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 "${checks}/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" + +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 "${checks}/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/drawing-board/script/application-smoke b/drawing-board/script/application-smoke new file mode 100755 index 0000000..913f1b9 --- /dev/null +++ b/drawing-board/script/application-smoke @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +checks="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="$(cd -P "${DRAWING_BOARD_PATH:?Set DRAWING_BOARD_PATH to the candidate checkout.}" && 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 ! "${checks}/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 "${checks}/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 +"${checks}/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/drawing-board/script/check b/drawing-board/script/check new file mode 100755 index 0000000..e63cdd5 --- /dev/null +++ b/drawing-board/script/check @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +set -euo pipefail + +checks="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +export DRAWING_BOARD_PATH="$(cd -P "${1:?Usage: check DRAWING_BOARD_CHECKOUT}" && pwd -P)" +export DRAWING_BOARD_CHECKS="$checks" +cd "$DRAWING_BOARD_PATH" + +# 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", + process.env.DRAWING_BOARD_CHECKS + "/../image/.devcontainer.json", + process.env.DRAWING_BOARD_CHECKS + "/../image/.devcontainer-lock.json", + process.env.DRAWING_BOARD_CHECKS + "/../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(process.env.DRAWING_BOARD_CHECKS + "/../image/.devcontainer.json", "utf8")); +const lockfile = JSON.parse(fs.readFileSync(process.env.DRAWING_BOARD_CHECKS + "/../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(process.env.DRAWING_BOARD_CHECKS + "/../image/.devcontainer-lock.json", "utf8").endsWith("\n")) { + console.error("The development-image lockfile must end with a newline."); + process.exit(1); +} +NODE + +node "$checks/check-image-receipt.mjs" + +# shellcheck disable=SC1091 +source .devcontainer/agent-versions.env + +for script in .devcontainer/setup-agents bin/agent-doctor bin/review-plan-with-claude bin/review-plan-with-codex script/initialize-application script/refresh-codespaces-private-port; do + bash -n "$script" + test -x "$script" +done +for script in "$checks"/{check,agent-smoke,devcontainer-smoke,devcontainer-image-smoke,application-smoke,selenium,check-depth-one}; do + bash -n "$script" + test -x "$script" +done +for javascript in .devcontainer/*.mjs bin/firstdraft script/*.mjs "$checks"/*.mjs; do + node --check "$javascript" +done +test -x bin/firstdraft +for check in check-firstdraft-wrapper check-agent-skills check-agent-setup check-initialize-application; do + node "$checks/$check.mjs" +done + +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(process.env.DRAWING_BOARD_CHECKS + "/../image/Dockerfile", "utf8"); +const imageReceipt = JSON.parse(fs.readFileSync(process.env.DRAWING_BOARD_CHECKS + "/../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."); + +NODE + +node "$checks/check-codespaces-private-port.mjs" +node "$checks/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 + +"$checks/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" "$checks/../image/Dockerfile" || \ + ! grep -Fq "\"ghcr.io/devcontainers/features/node:1\": { \"version\": \"$FOUNDATION_NODE_VERSION\" }" "$checks/../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 -qIE '(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/drawing-board/script/check-agent-setup.mjs b/drawing-board/script/check-agent-setup.mjs new file mode 100644 index 0000000..ea23843 --- /dev/null +++ b/drawing-board/script/check-agent-setup.mjs @@ -0,0 +1,192 @@ +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"; + +const repository = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); +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/drawing-board/script/check-agent-skills.mjs b/drawing-board/script/check-agent-skills.mjs new file mode 100644 index 0000000..a701acb --- /dev/null +++ b/drawing-board/script/check-agent-skills.mjs @@ -0,0 +1,169 @@ +import {pathToFileURL} from "node:url"; +import {resolve} from "node:path"; +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"; + +const {linkAgentSkills, readAgentSkills, verifyAgentSkills, verifyCodexSkills} = + await import(pathToFileURL(resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd(), ".devcontainer/agent-skills.mjs"))); + +const repository = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); +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/drawing-board/script/check-claude-discovery.mjs b/drawing-board/script/check-claude-discovery.mjs new file mode 100644 index 0000000..70bfb85 --- /dev/null +++ b/drawing-board/script/check-claude-discovery.mjs @@ -0,0 +1,43 @@ +import {pathToFileURL} from "node:url"; +import {resolve} from "node:path"; +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"; +const {readAgentSkills} = await import(pathToFileURL(resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd(), ".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/drawing-board/script/check-codespaces-private-port.mjs b/drawing-board/script/check-codespaces-private-port.mjs new file mode 100755 index 0000000..18d0715 --- /dev/null +++ b/drawing-board/script/check-codespaces-private-port.mjs @@ -0,0 +1,343 @@ +#!/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"; + +const repositoryRoot = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); +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/drawing-board/script/check-codex-configuration.mjs b/drawing-board/script/check-codex-configuration.mjs new file mode 100644 index 0000000..909a6e4 --- /dev/null +++ b/drawing-board/script/check-codex-configuration.mjs @@ -0,0 +1,87 @@ +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"; + +const setup = join(process.env.DRAWING_BOARD_PATH ?? process.cwd(), ".devcontainer/configure-codex.mjs"); +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/drawing-board/script/check-depth-one b/drawing-board/script/check-depth-one new file mode 100755 index 0000000..f23ba5a --- /dev/null +++ b/drawing-board/script/check-depth-one @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +checks="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="${DRAWING_BOARD_PATH:?Set DRAWING_BOARD_PATH to the candidate checkout.}" +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)' "${checks}/../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 "${checks}/check-image-receipt.mjs" +echo "Depth-one development-image receipt contract passed." diff --git a/drawing-board/script/check-firstdraft-wrapper.mjs b/drawing-board/script/check-firstdraft-wrapper.mjs new file mode 100644 index 0000000..3ca3187 --- /dev/null +++ b/drawing-board/script/check-firstdraft-wrapper.mjs @@ -0,0 +1,222 @@ +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"; + +const require = createRequire(import.meta.url); +const repositoryRoot = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); +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/drawing-board/script/check-image-receipt.mjs b/drawing-board/script/check-image-receipt.mjs new file mode 100755 index 0000000..d5b12c0 --- /dev/null +++ b/drawing-board/script/check-image-receipt.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import childProcess from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import {fileURLToPath} from "node:url"; + +const imageRoot = fileURLToPath(new URL("../image/", import.meta.url)); +const receiptPath = imageRoot + "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 [sourcePath, localPath] of [ + [".devcontainer/Dockerfile", "Dockerfile"], + [".devcontainer/image/devcontainer-lock.json", ".devcontainer-lock.json"], +]) { + const actual = crypto.createHash("sha256").update(fs.readFileSync(imageRoot + localPath)).digest("hex"); + required(actual === receipt.inputs[sourcePath], `The development-image receipt does not match ${localPath}.`); +} +const configuration = JSON.parse(fs.readFileSync(imageRoot + ".devcontainer.json", "utf8")); +required(configuration.build.dockerfile === "Dockerfile" && configuration.build.context === ".", + "The relocated image build must use its own directory."); +// Only path relocation changes the recipe; the historical source is verified below. +const originalConfiguration = fs.readFileSync(imageRoot + ".devcontainer.json", "utf8") + .replace('"dockerfile": "Dockerfile"', '"dockerfile": "../Dockerfile"') + .replace('"context": "."', '"context": "../.."'); +required(crypto.createHash("sha256").update(originalConfiguration).digest("hex") === + receipt.inputs[".devcontainer/image/devcontainer.json"], "The relocated image recipe changed beyond its paths."); + +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 === ".devcontainer/image/receipt.json" || !fs.existsSync(path) || !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/drawing-board/script/check-initialize-application.mjs b/drawing-board/script/check-initialize-application.mjs new file mode 100644 index 0000000..f66399e --- /dev/null +++ b/drawing-board/script/check-initialize-application.mjs @@ -0,0 +1,324 @@ +#!/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, pathToFileURL} from "node:url"; +const {assertSameInventory, committedInventory, filesystemInventory} = await import( + pathToFileURL(path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd(), "script/application-repository-inventory-lib.mjs")), +); + +const repositoryRoot = path.resolve(process.env.DRAWING_BOARD_PATH ?? process.cwd()); +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 [ + "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", [fileURLToPath(new URL("application-smoke", import.meta.url))], { + cwd: harnessRoot, + encoding: "utf8", + env: { + ...process.env, + DRAWING_BOARD_PATH: harnessRoot, + 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/drawing-board/script/devcontainer-image-smoke b/drawing-board/script/devcontainer-image-smoke new file mode 100755 index 0000000..81ca87f --- /dev/null +++ b/drawing-board/script/devcontainer-image-smoke @@ -0,0 +1,171 @@ +#!/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}/../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/drawing-board/script/devcontainer-smoke b/drawing-board/script/devcontainer-smoke new file mode 100755 index 0000000..5a48f57 --- /dev/null +++ b/drawing-board/script/devcontainer-smoke @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +checks="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +cd "${DRAWING_BOARD_PATH:?Set DRAWING_BOARD_PATH to the candidate checkout.}" + +# 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" + +"${checks}/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 "${checks}/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 + +echo "Devcontainer installation smoke passed." diff --git a/drawing-board/script/selenium b/drawing-board/script/selenium new file mode 100755 index 0000000..d9d3225 --- /dev/null +++ b/drawing-board/script/selenium @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +checks="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="$(cd -P "${DRAWING_BOARD_PATH:?Set DRAWING_BOARD_PATH to the candidate checkout.}" && 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 this helper 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