From 462ebea9d7d92650c8f261910e0453a07adb9208 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 16 Jun 2026 16:40:52 +0200 Subject: [PATCH 1/5] docs: make the RELEASE.md support contract version-agnostic Change-Id: I3a53be8ace00ec7df33a6e995ff82d373001c20f Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- RELEASE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index a7d900b..85e9382 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,7 +1,7 @@ # agent-tty release contract -This document defines the supported product contract for the current `0.2.x` release line. -The `0.1.x` beta line established the baseline for isolated, reviewable terminal automation for real TUI workflows, and `0.2.0` is the first stable cut on top of that baseline; later `0.2.x` releases may add compatible fixes and features without widening this core support contract. +This document defines the supported product contract for the current stable release line. +It builds on the `0.1.x` beta baseline for isolated, reviewable terminal automation of real TUI workflows; later stable releases may add compatible fixes and features without widening this core support contract. If a workflow depends on behavior outside this document, treat it as future-scope or best-effort rather than a guaranteed capability. For per-release changes, see [`CHANGELOG.md`](./CHANGELOG.md). For release mechanics, use [`docs/RELEASE-PROCESS.md`](./docs/RELEASE-PROCESS.md). For reviewer-facing proof bundles, start with [`dogfood/CATALOG.md`](./dogfood/CATALOG.md). From 9d50fa13a0e696ea49c3bd8604010b0a1b67fcdf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 16 Jun 2026 16:46:31 +0200 Subject: [PATCH 2/5] ci: gate CI on high-severity dependency advisories and clear current ones Add a [tasks.audit] mise task (aube audit --audit-level high) and wire it into both the [tasks.ci] chain and the linux-static CI job (after Lint). This fails the build on any future high/critical dependency advisory. Clear the 7 current advisories (3 high, 3 moderate, 1 low) by adding package.json overrides forcing patched transitive versions: brace-expansion 5.0.6, esbuild 0.28.1, vite 8.0.16, ws 8.21.0. aube audit now reports 0 vulnerabilities at all severities. Change-Id: Iff1b8f8043963786d348dd87939dab7b3df865bc Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- .github/workflows/ci.yml | 3 + aube-lock.yaml | 262 +++++++++++++++++++++------------------ mise.toml | 6 +- package.json | 6 + 4 files changed, 156 insertions(+), 121 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aff6ee..2479835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,9 @@ jobs: - name: Lint run: mise run lint + - name: Audit dependencies + run: mise run audit + - name: Typecheck run: mise run typecheck diff --git a/aube-lock.yaml b/aube-lock.yaml index ddb445a..e16de45 100644 --- a/aube-lock.yaml +++ b/aube-lock.yaml @@ -4,17 +4,24 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + brace-expansion: 5.0.6 + esbuild: 0.28.1 + vite: 8.0.16 + ws: 8.21.0 + time: '@alcalzone/ansi-tokenize@0.3.0': 2026-02-20T13:12:49.245Z '@babel/code-frame@7.29.7': 2026-05-25T11:15:38.208Z '@babel/helper-validator-identifier@7.29.7': 2026-05-25T11:15:31.539Z '@coder/libghostty-vt-node@0.1.0-beta.0': 2026-04-24T16:17:20.231Z '@conventional-commits/parser@0.4.1': 2021-01-07T02:41:52.208Z - '@esbuild/darwin-arm64@0.27.7': 2026-04-02T16:41:35.638Z - '@esbuild/linux-arm64@0.27.7': 2026-04-02T16:42:03.486Z - '@esbuild/linux-x64@0.27.7': 2026-04-02T16:42:42.543Z - '@esbuild/win32-arm64@0.27.7': 2026-04-02T16:43:29.208Z - '@esbuild/win32-x64@0.27.7': 2026-04-02T16:43:41.245Z + '@esbuild/darwin-arm64@0.28.1': 2026-06-11T22:44:42.682Z + '@esbuild/darwin-x64@0.28.1': 2026-06-11T22:44:48.973Z + '@esbuild/linux-arm64@0.28.1': 2026-06-11T22:45:12.450Z + '@esbuild/linux-x64@0.28.1': 2026-06-11T22:45:52.463Z + '@esbuild/win32-arm64@0.28.1': 2026-06-11T22:46:40.574Z + '@esbuild/win32-x64@0.28.1': 2026-06-11T22:46:52.393Z '@google-automations/git-file-utils@3.0.1': 2026-04-01T16:30:19.463Z '@iarna/toml@3.0.0': 2020-04-23T20:50:05.483Z '@jridgewell/sourcemap-codec@1.5.5': 2025-08-12T06:43:59.139Z @@ -32,7 +39,7 @@ time: '@octokit/request@8.4.1': 2025-02-15T00:08:47.891Z '@octokit/rest@20.1.2': 2025-02-26T22:20:28.740Z '@octokit/types@13.10.0': 2025-03-18T23:28:55.056Z - '@oxc-project/types@0.128.0': 2026-04-27T11:33:26.853Z + '@oxc-project/types@0.133.0': 2026-05-26T06:30:02.095Z '@oxfmt/binding-darwin-arm64@0.47.0': 2026-04-27T12:22:26.779Z '@oxfmt/binding-linux-arm64-gnu@0.47.0': 2026-04-27T12:22:40.334Z '@oxfmt/binding-linux-arm64-musl@0.47.0': 2026-04-27T12:22:44.823Z @@ -52,14 +59,15 @@ time: '@oxlint/binding-linux-x64-musl@1.62.0': 2026-04-27T12:07:28.786Z '@oxlint/binding-win32-arm64-msvc@1.62.0': 2026-04-27T12:05:47.226Z '@oxlint/binding-win32-x64-msvc@1.62.0': 2026-04-27T12:07:08.230Z - '@rolldown/binding-darwin-arm64@1.0.0-rc.18': 2026-04-29T13:43:27.149Z - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': 2026-04-29T13:43:21.315Z - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': 2026-04-29T13:43:32.890Z - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': 2026-04-29T13:42:57.861Z - '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': 2026-04-29T13:43:03.793Z - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': 2026-04-29T13:43:43.550Z - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': 2026-04-29T13:42:51.691Z - '@rolldown/pluginutils@1.0.0-rc.18': 2026-04-29T13:42:40.058Z + '@rolldown/binding-darwin-arm64@1.0.3': 2026-05-27T11:47:39.757Z + '@rolldown/binding-darwin-x64@1.0.3': 2026-05-27T11:46:54.917Z + '@rolldown/binding-linux-arm64-gnu@1.0.3': 2026-05-27T11:47:34.109Z + '@rolldown/binding-linux-arm64-musl@1.0.3': 2026-05-27T11:47:45.104Z + '@rolldown/binding-linux-x64-gnu@1.0.3': 2026-05-27T11:47:07.199Z + '@rolldown/binding-linux-x64-musl@1.0.3': 2026-05-27T11:47:13.920Z + '@rolldown/binding-win32-arm64-msvc@1.0.3': 2026-05-27T11:47:56.973Z + '@rolldown/binding-win32-x64-msvc@1.0.3': 2026-05-27T11:47:00.892Z + '@rolldown/pluginutils@1.0.1': 2026-05-13T03:52:29.927Z '@standard-schema/spec@1.1.0': 2025-12-15T20:49:46.431Z '@types/chai@5.2.3': 2025-10-20T23:32:43.277Z '@types/deep-eql@4.0.2': 2023-11-07T01:34:12.026Z @@ -93,7 +101,7 @@ time: balanced-match@4.0.4: 2026-02-22T11:38:25.951Z before-after-hook@2.2.3: 2022-10-04T00:19:26.570Z boolbase@1.0.0: 2014-02-15T14:44:50.620Z - brace-expansion@5.0.5: 2026-03-24T17:58:07.554Z + brace-expansion@5.0.6: 2026-05-08T05:41:43.205Z camelcase-keys@6.2.2: 2020-04-03T03:51:03.816Z camelcase@5.3.1: 2019-04-03T13:34:32.701Z chai@6.2.2: 2025-12-22T21:26:03.989Z @@ -136,7 +144,7 @@ time: es-errors@1.3.0: 2024-02-05T08:05:51.479Z es-module-lexer@2.1.0: 2026-04-25T22:50:31.041Z es-toolkit@1.47.0: 2026-05-25T08:00:38.148Z - esbuild@0.27.7: 2026-04-02T16:43:44.831Z + esbuild@0.28.1: 2026-06-11T22:47:05.085Z escalade@3.2.0: 2024-08-29T22:59:36.690Z escape-string-regexp@1.0.5: 2016-02-21T12:55:17.074Z escape-string-regexp@2.0.0: 2019-04-17T07:49:09.559Z @@ -172,7 +180,6 @@ time: is-in-ci@2.0.0: 2025-08-17T11:37:36.590Z is-obj@2.0.0: 2019-04-19T15:37:37.870Z is-plain-obj@1.1.0: 2015-11-05T09:31:58.189Z - jiti@2.7.0: 2026-05-05T21:02:47.918Z js-tokens@4.0.0: 2018-01-28T11:58:58.170Z js-yaml@4.2.0: 2026-05-31T22:17:13.783Z jsep@1.4.0: 2024-11-05T14:49:55.640Z @@ -236,7 +243,7 @@ time: picomatch@4.0.4: 2026-03-23T20:39:47.960Z playwright-core@1.60.0: 2026-05-11T19:09:40.047Z playwright@1.60.0: 2026-05-11T19:09:33.114Z - postcss@8.5.14: 2026-05-04T16:43:35.284Z + postcss@8.5.15: 2026-05-19T09:51:29.843Z quick-lru@4.0.1: 2019-05-29T17:21:30.565Z react-reconciler@0.33.0: 2025-10-01T21:39:00.081Z react@19.2.7: 2026-06-01T18:00:48.323Z @@ -250,7 +257,7 @@ time: restore-cursor@4.0.0: 2021-08-23T19:27:21.792Z retry@0.13.1: 2021-06-21T07:45:32.286Z rimraf@6.1.3: 2026-02-16T00:59:39.538Z - rolldown@1.0.0-rc.18: 2026-04-29T13:44:09.738Z + rolldown@1.0.3: 2026-05-27T11:49:15.029Z scheduler@0.27.0: 2025-10-01T21:39:15.208Z semver@5.7.2: 2023-07-10T19:57:47.111Z semver@7.7.4: 2026-02-05T17:23:11.131Z @@ -280,7 +287,7 @@ time: tinybench@2.9.0: 2024-08-02T15:09:44.961Z tinyexec@1.1.2: 2026-04-29T07:40:28.138Z tinyglobby@0.2.15: 2025-09-06T18:52:04.151Z - tinyglobby@0.2.16: 2026-04-07T23:37:03.457Z + tinyglobby@0.2.17: 2026-05-30T19:57:21.717Z tinypool@2.1.0: 2026-01-03T07:37:52.918Z tinyrainbow@3.1.0: 2026-03-12T09:21:01.296Z trim-newlines@3.0.1: 2021-05-28T16:46:12.397Z @@ -300,7 +307,7 @@ time: unist-util-visit@2.0.3: 2020-07-11T09:47:14.798Z universal-user-agent@6.0.1: 2023-11-04T22:29:03.740Z validate-npm-package-license@3.0.4: 2018-08-05T16:59:03.230Z - vite@8.0.11: 2026-05-07T06:05:59.982Z + vite@8.0.16: 2026-06-01T09:50:43.261Z vitest@4.1.2: 2026-03-26T14:36:51.447Z why-is-node-running@2.3.0: 2024-07-08T12:57:23.951Z widest-line@6.0.0: 2026-01-24T03:25:03.834Z @@ -308,7 +315,7 @@ time: wrap-ansi@10.0.0: 2026-02-20T10:03:54.703Z wrap-ansi@7.0.0: 2020-04-22T16:53:23.889Z wrappy@1.0.2: 2016-05-17T23:30:52.415Z - ws@8.20.0: 2026-03-21T17:31:08.578Z + ws@8.21.0: 2026-05-22T17:59:59.453Z xpath@0.0.34: 2023-12-16T14:31:54.702Z y18n@5.0.8: 2021-04-07T18:57:33.969Z yallist@4.0.0: 2019-09-30T20:08:08.970Z @@ -346,7 +353,7 @@ importers: version: 3.0.2 vite: specifier: ^6.0.0 || ^7.0.0 || ^8.0.0 - version: 8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + version: 8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4) zod: specifier: 4.3.6 version: 4.3.6 @@ -380,7 +387,7 @@ importers: version: 5.9.3 vitest: specifier: 4.1.2 - version: 4.1.2(@types/node@25.5.0)(vite@8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.2(@types/node@25.5.0)(vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: '@coder/libghostty-vt-node': specifier: 0.1.0-beta.0 @@ -407,40 +414,48 @@ packages: '@conventional-commits/parser@0.4.1': resolution: {integrity: sha512-H2ZmUVt6q+KBccXfMBhbBF14NlANeqHTXL4qCL6QGbMzrc4HDXyzWuxPxPNbz71f/5UkR5DrycP5VO9u7crahg==} - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} os: - darwin cpu: - arm64 - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + os: + - darwin + cpu: + - x64 + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} os: - linux cpu: - arm64 - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} os: - linux cpu: - x64 - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} os: - win32 cpu: - arm64 - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} os: - win32 @@ -521,8 +536,8 @@ packages: '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} - '@oxc-project/types@0.128.0': - resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} '@oxfmt/binding-darwin-arm64@0.47.0': resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==} @@ -687,16 +702,24 @@ packages: cpu: - x64 - '@rolldown/binding-darwin-arm64@1.0.0-rc.18': - resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} os: - darwin cpu: - arm64 - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': - resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + os: + - darwin + cpu: + - x64 + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} os: - linux @@ -705,8 +728,8 @@ packages: libc: - glibc - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': - resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} os: - linux @@ -715,8 +738,8 @@ packages: libc: - musl - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': - resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} os: - linux @@ -725,8 +748,8 @@ packages: libc: - glibc - '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': - resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} os: - linux @@ -735,24 +758,24 @@ packages: libc: - musl - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': - resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} os: - win32 cpu: - arm64 - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': - resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} os: - win32 cpu: - x64 - '@rolldown/pluginutils@1.0.0-rc.18': - resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -872,8 +895,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} camelcase-keys@6.2.2: @@ -1035,8 +1058,8 @@ packages: es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1194,10 +1217,6 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1476,8 +1495,8 @@ packages: engines: {node: '>=18'} hasBin: true - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} quick-lru@4.0.1: @@ -1536,8 +1555,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown@1.0.0-rc.18: - resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1647,8 +1666,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@2.1.0: @@ -1725,8 +1744,8 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - vite@8.0.11: - resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1827,8 +1846,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1898,15 +1917,17 @@ snapshots: unist-util-visit: 2.0.3 unist-util-visit-parents: 3.1.1 - '@esbuild/darwin-arm64@0.27.7': {} + '@esbuild/darwin-arm64@0.28.1': {} + + '@esbuild/darwin-x64@0.28.1': {} - '@esbuild/linux-arm64@0.27.7': {} + '@esbuild/linux-arm64@0.28.1': {} - '@esbuild/linux-x64@0.27.7': {} + '@esbuild/linux-x64@0.28.1': {} - '@esbuild/win32-arm64@0.27.7': {} + '@esbuild/win32-arm64@0.28.1': {} - '@esbuild/win32-x64@0.27.7': {} + '@esbuild/win32-x64@0.28.1': {} '@google-automations/git-file-utils@3.0.1(@octokit/core@5.2.2)': dependencies: @@ -1990,7 +2011,7 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 - '@oxc-project/types@0.128.0': {} + '@oxc-project/types@0.133.0': {} '@oxfmt/binding-darwin-arm64@0.47.0': {} @@ -2030,21 +2051,23 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.62.0': {} - '@rolldown/binding-darwin-arm64@1.0.0-rc.18': {} + '@rolldown/binding-darwin-arm64@1.0.3': {} - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': {} + '@rolldown/binding-darwin-x64@1.0.3': {} - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': {} + '@rolldown/binding-linux-arm64-gnu@1.0.3': {} - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': {} + '@rolldown/binding-linux-arm64-musl@1.0.3': {} - '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': {} + '@rolldown/binding-linux-x64-gnu@1.0.3': {} - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': {} + '@rolldown/binding-linux-x64-musl@1.0.3': {} - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': {} + '@rolldown/binding-win32-arm64-msvc@1.0.3': {} - '@rolldown/pluginutils@1.0.0-rc.18': {} + '@rolldown/binding-win32-x64-msvc@1.0.3': {} + + '@rolldown/pluginutils@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -2082,12 +2105,12 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(vite@8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.2(vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 - vite: 8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.2': dependencies: @@ -2151,7 +2174,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2300,13 +2323,14 @@ snapshots: es-toolkit@1.47.0: {} - esbuild@0.27.7: + esbuild@0.28.1: optionalDependencies: - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -2421,7 +2445,7 @@ snapshots: type-fest: 5.7.0 widest-line: 6.0.0 wrap-ansi: 10.0.0 - ws: 8.20.0 + ws: 8.21.0 yoga-layout: 3.2.1 is-arrayish@0.2.1: {} @@ -2442,8 +2466,6 @@ snapshots: is-plain-obj@1.1.0: {} - jiti@2.7.0: {} - js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -2532,7 +2554,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimist-options@4.1.0: dependencies: @@ -2673,7 +2695,7 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.14: + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -2763,18 +2785,19 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown@1.0.0-rc.18: + rolldown@1.0.3: dependencies: - '@oxc-project/types': 0.128.0 - '@rolldown/pluginutils': 1.0.0-rc.18 + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 scheduler@0.27.0: {} @@ -2865,7 +2888,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinyglobby@0.2.16(picomatch@4.0.4): + tinyglobby@0.2.17(picomatch@4.0.4): dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -2878,7 +2901,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.7 + esbuild: 0.28.1 get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 @@ -2925,26 +2948,25 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite@8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): + vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4): dependencies: '@types/node': 25.5.0 - esbuild: 0.27.7 - jiti: 2.7.0 + esbuild: 0.28.1 lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.14 - rolldown: 1.0.0-rc.18 - tinyglobby: 0.2.16(picomatch@4.0.4) + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17(picomatch@4.0.4) tsx: 4.21.0 yaml: 2.8.4 optionalDependencies: fsevents: 2.3.3 - vitest@4.1.2(@types/node@25.5.0)(vite@8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): + vitest@4.1.2(@types/node@25.5.0)(vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@types/node': 25.5.0 '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.2(vite@8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 @@ -2961,7 +2983,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.15(picomatch@4.0.4) tinyrainbow: 3.1.0 - vite: 8.0.11(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.16(@types/node@25.5.0)(esbuild@0.28.1)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 why-is-node-running@2.3.0: @@ -2989,7 +3011,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0: {} + ws@8.21.0: {} xpath@0.0.34: {} diff --git a/mise.toml b/mise.toml index b42ee0e..5f5a9dc 100644 --- a/mise.toml +++ b/mise.toml @@ -127,6 +127,10 @@ sources = [ "package.json", ] +[tasks.audit] +description = "Fail on high-severity dependency advisories" +run = "aube audit --audit-level high" + [tasks.format] description = "Format" run = "npm run format" @@ -193,7 +197,7 @@ sources = [ [tasks.ci] description = "Run CI checks" -run = "mise run format-check && mise run workflow-lint && mise run lint && mise run typecheck && mise run test && mise run build && mise run install-smoke" +run = "mise run format-check && mise run workflow-lint && mise run lint && mise run audit && mise run typecheck && mise run test && mise run build && mise run install-smoke" [settings] lockfile = true diff --git a/package.json b/package.json index 880ad39..e07ab41 100644 --- a/package.json +++ b/package.json @@ -98,5 +98,11 @@ "@parcel/watcher": true, "msgpackr-extract": true } + }, + "overrides": { + "brace-expansion": "5.0.6", + "esbuild": "0.28.1", + "vite": "8.0.16", + "ws": "8.21.0" } } From da4df0df330b59995cac12753e1734a97420a743 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 16 Jun 2026 16:58:00 +0200 Subject: [PATCH 3/5] feat: harden local-state permissions and characterize hostMain helpers Plan 001: restrict the per-Home socket directory to 0o700, the bound socket file to 0o600, and persisted state files (manifests, homes.json) to 0o600, regardless of umask. Adds an integration test asserting the permission bits and that the owner can still drive the session. Plan 004: export hostMain's pure decision helpers (normalizeExitSignal, isSessionCommandable, assertSessionCommandable, resolveHostRendererName) and add characterization unit tests plus an idle-timeout auto-exit integration test. No logic changes to hostMain. Change-Id: I4d8f8425e631752cf00ce653bd5654f8e86e230e Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- src/host/hostMain.ts | 16 ++- src/host/rpcServer.ts | 4 +- src/storage/manifests.ts | 5 +- test/integration/idle-timeout.test.ts | 67 ++++++++++ test/integration/socket-permissions.test.ts | 76 +++++++++++ test/unit/host/hostMain.test.ts | 136 +++++++++++++++++++- 6 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 test/integration/idle-timeout.test.ts create mode 100644 test/integration/socket-permissions.test.ts diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index bdd6559..a563054 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -1,4 +1,4 @@ -import { mkdir } from 'node:fs/promises'; +import { chmod, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import process from 'node:process'; @@ -84,7 +84,7 @@ type WaitOutcome = { timedOut: boolean; }; -function normalizeExitSignal(signal: number | null): string | null { +export function normalizeExitSignal(signal: number | null): string | null { invariant( signal === null || (Number.isInteger(signal) && signal >= 0), 'PTY exit signal must be a non-negative integer or null', @@ -93,11 +93,11 @@ function normalizeExitSignal(signal: number | null): string | null { return signal === null || signal === 0 ? null : String(signal); } -function isSessionCommandable(state: SessionState): boolean { +export function isSessionCommandable(state: SessionState): boolean { return isCommandableSessionStatus(state.snapshot().status); } -function assertSessionCommandable(state: SessionState): void { +export function assertSessionCommandable(state: SessionState): void { if (!isSessionCommandable(state)) { throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { // Preserve the legacy RPC wire contract: errors include only code and @@ -113,7 +113,9 @@ function rethrowAsync(error: unknown): void { }); } -function resolveHostRendererName(input: string | undefined): RendererName { +export function resolveHostRendererName( + input: string | undefined, +): RendererName { const rawRenderer = input ?? process.env[HOST_RENDERER_ENV_KEY] ?? @@ -1074,7 +1076,9 @@ export async function runHost(sessionId: string): Promise { try { await writeManifest(mPath, state.snapshot()); - await mkdir(dirname(sPath), { recursive: true }); + const socketDirectory = dirname(sPath); + await mkdir(socketDirectory, { recursive: true }); + await chmod(socketDirectory, 0o700); if (!isSessionCommandable(state)) { await initiateShutdown(); diff --git a/src/host/rpcServer.ts b/src/host/rpcServer.ts index 04831c0..ee2e155 100644 --- a/src/host/rpcServer.ts +++ b/src/host/rpcServer.ts @@ -1,4 +1,4 @@ -import { stat, unlink } from 'node:fs/promises'; +import { chmod, stat, unlink } from 'node:fs/promises'; import net from 'node:net'; import { CliError } from '../cli/errors.js'; @@ -226,6 +226,8 @@ export class RpcServer { this.server = null; throw error; } + + await chmod(this.socketPath, 0o600); } public async close(): Promise { diff --git a/src/storage/manifests.ts b/src/storage/manifests.ts index a0e54ae..8e16779 100644 --- a/src/storage/manifests.ts +++ b/src/storage/manifests.ts @@ -107,7 +107,10 @@ export async function writeTextFileAtomic( try { await mkdir(outputDirectory, { recursive: true }); - await writeFile(temporaryPath, options.contents, 'utf8'); + await writeFile(temporaryPath, options.contents, { + encoding: 'utf8', + mode: 0o600, + }); await rename(temporaryPath, options.path); } catch (error) { await rm(temporaryPath, { force: true }).catch(() => undefined); diff --git a/test/integration/idle-timeout.test.ts b/test/integration/idle-timeout.test.ts new file mode 100644 index 0000000..9ecc20b --- /dev/null +++ b/test/integration/idle-timeout.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupHome, + inspectSession, + runCli, + sleep, + type SuccessEnvelope, +} from '../helpers.js'; + +let testHome = ''; + +describe('idle-timeout integration', { timeout: 30000 }, () => { + beforeEach(async () => { + testHome = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-home-'))); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('auto-exits an idle session once the idle timeout elapses', async () => { + // `exec cat` blocks on stdin and produces no further output, so the session + // is idle from creation. The idle poller's cadence is + // min(idleTimeoutMs, IDLE_CHECK_CAP_MS=5000), so a small timeout makes the + // first poll fire quickly, kill the PTY, and reconcile the session to + // `exited` without any further input. + const idleTimeoutMs = 300; + const createResult = runCli( + [ + 'create', + '--idle-timeout-ms', + String(idleTimeoutMs), + '--json', + '--', + '/bin/sh', + '-c', + 'exec cat', + ], + { AGENT_TTY_HOME: testHome }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const sessionId = ( + JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }> + ).result.sessionId; + + // Poll inspect until the session reaches a terminal status, with a generous + // deadline that comfortably exceeds the poll cadence and reconciliation. + const deadline = Date.now() + 20_000; + let status = inspectSession(testHome, sessionId).status; + while ( + status !== 'exited' && + status !== 'failed' && + Date.now() < deadline + ) { + await sleep(200); + status = inspectSession(testHome, sessionId).status; + } + + expect(status).toBe('exited'); + }); +}); diff --git a/test/integration/socket-permissions.test.ts b/test/integration/socket-permissions.test.ts new file mode 100644 index 0000000..b39f356 --- /dev/null +++ b/test/integration/socket-permissions.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, realpath, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { sessionDir, socketPath } from '../../src/storage/sessionPaths.js'; +import { + cleanupHome, + createSession, + destroySession, + inspectSession, + runCli, + type SuccessEnvelope, +} from '../helpers.js'; + +let testHome = ''; + +// Unix mode bits are not meaningful on Windows (tier-2), where the socket is a +// named pipe and chmod is a no-op; skip the whole suite there. +describe.skipIf(process.platform === 'win32')( + 'socket and state file permissions', + { timeout: 30000 }, + () => { + beforeEach(async () => { + testHome = await realpath( + await mkdtemp(join(tmpdir(), 'agent-tty-home-')), + ); + }); + + afterEach(async () => { + await cleanupHome(testHome); + }); + + it('restricts the socket directory, socket file, and manifest to the owner', async () => { + const sessionId = createSession(testHome, ['/bin/sh', '-c', 'exec cat']); + + const sPath = socketPath(sessionDir(testHome, sessionId)); + const socketDirectory = dirname(sPath); + + // Socket directory is owner-only (0o700), regardless of umask. + const directoryStat = await stat(socketDirectory); + expect(directoryStat.mode & 0o777).toBe(0o700); + + // Socket file is owner-only (0o600), created during listen(). + const socketStat = await stat(sPath); + expect(socketStat.mode & 0o777).toBe(0o600); + + // Session manifest is owner-only (0o600). + const manifestStat = await stat( + join(testHome, 'sessions', sessionId, 'session.json'), + ); + expect(manifestStat.mode & 0o777).toBe(0o600); + + // The owner can still drive the session despite the tightened perms. + const inspected = inspectSession(testHome, sessionId); + expect(inspected.status).toBe('running'); + + const typeResult = runCli( + ['type', sessionId, 'echo owner-ok\n', '--json'], + { + AGENT_TTY_HOME: testHome, + }, + ); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + const typeEnvelope = JSON.parse(typeResult.stdout) as SuccessEnvelope< + Record + >; + expect(typeEnvelope.ok).toBe(true); + + destroySession(testHome, sessionId); + }); + }, +); diff --git a/test/unit/host/hostMain.test.ts b/test/unit/host/hostMain.test.ts index ff2dd14..27379f5 100644 --- a/test/unit/host/hostMain.test.ts +++ b/test/unit/host/hostMain.test.ts @@ -1,9 +1,141 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MAX_CONSECUTIVE_POLL_FAILURES } from '../../../src/host/hostMain.js'; +import { CliError } from '../../../src/cli/errors.js'; +import { ERROR_CODES } from '../../../src/protocol/errors.js'; +import { + MAX_CONSECUTIVE_POLL_FAILURES, + assertSessionCommandable, + isSessionCommandable, + normalizeExitSignal, + resolveHostRendererName, +} from '../../../src/host/hostMain.js'; +import { SessionState } from '../../../src/host/sessionState.js'; +import { HOST_RENDERER_ENV_KEY } from '../../../src/config/defaults.js'; +import { + DEFAULT_RENDERER_NAME, + type RendererName, +} from '../../../src/renderer/names.js'; +import type { + SessionRecord, + SessionStatus, +} from '../../../src/protocol/schemas.js'; + +function makeSessionState(status: SessionStatus): SessionState { + const terminal = status === 'exited' || status === 'failed'; + const record: SessionRecord = { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: terminal ? null : 123, + childPid: terminal ? null : 456, + exitCode: status === 'exited' ? 0 : null, + exitSignal: null, + }; + + return new SessionState(record); +} describe('waitForRender polling limits', () => { it('exports the consecutive renderer failure cap', () => { expect(MAX_CONSECUTIVE_POLL_FAILURES).toBe(10); }); }); + +describe('normalizeExitSignal', () => { + it('maps null to null', () => { + expect(normalizeExitSignal(null)).toBeNull(); + }); + + it('maps 0 to null (a clean exit carries no signal)', () => { + expect(normalizeExitSignal(0)).toBeNull(); + }); + + it('stringifies a positive signal number', () => { + expect(normalizeExitSignal(9)).toBe('9'); + expect(normalizeExitSignal(15)).toBe('15'); + }); + + it('rejects a negative signal', () => { + expect(() => normalizeExitSignal(-1)).toThrow(); + }); + + it('rejects a non-integer signal', () => { + expect(() => normalizeExitSignal(2.5)).toThrow(); + }); +}); + +describe('isSessionCommandable / assertSessionCommandable', () => { + it('treats a running session as commandable', () => { + const state = makeSessionState('running'); + expect(isSessionCommandable(state)).toBe(true); + expect(() => { + assertSessionCommandable(state); + }).not.toThrow(); + }); + + it('treats an exiting session as not commandable', () => { + const state = makeSessionState('exiting'); + expect(isSessionCommandable(state)).toBe(false); + expect(() => { + assertSessionCommandable(state); + }).toThrow(CliError); + }); + + it('treats a terminal (exited) session as not commandable', () => { + const state = makeSessionState('exited'); + expect(isSessionCommandable(state)).toBe(false); + + try { + assertSessionCommandable(state); + expect.unreachable('assertSessionCommandable should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODES.SESSION_NOT_RUNNING); + expect((error as CliError).message).toBe('Session is not running.'); + } + }); +}); + +describe('resolveHostRendererName', () => { + // vi.stubEnv tracks and restores process.env so each case starts from a known + // state and nothing leaks into other tests. Passing undefined clears the var. + beforeEach(() => { + vi.stubEnv(HOST_RENDERER_ENV_KEY, undefined); + vi.stubEnv('AGENT_TTY_RENDERER', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('resolves an explicit input over the environment', () => { + vi.stubEnv(HOST_RENDERER_ENV_KEY, 'ghostty-web'); + expect(resolveHostRendererName('libghostty-vt')).toBe('libghostty-vt'); + }); + + it('falls back to the host renderer env var when input is undefined', () => { + vi.stubEnv(HOST_RENDERER_ENV_KEY, 'libghostty-vt'); + expect(resolveHostRendererName(undefined)).toBe('libghostty-vt'); + }); + + it('falls back to the default renderer when input and env are absent', () => { + const expected: RendererName = DEFAULT_RENDERER_NAME; + expect(resolveHostRendererName(undefined)).toBe(expected); + }); + + it('throws INVALID_INPUT for an unknown renderer name', () => { + try { + resolveHostRendererName('nope'); + expect.unreachable('resolveHostRendererName should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODES.INVALID_INPUT); + } + }); +}); From a4bba8ae3526dc331b705dd68684f5b0835025fb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 16 Jun 2026 17:01:54 +0200 Subject: [PATCH 4/5] refactor: share replay-event iteration and split the ghostty-web backend Plan 005: extract the replay-event seq/ordering invariants into a single tested helper (src/renderer/replayEvents.ts) and use it from both renderer backends' replayTo so they iterate replay events through one shared path. Plan 006: move the embedded harness HTML and the harness-decoding layer out of the ghostty-web backend god file into sibling modules (embeddedHarnessHtml.ts, harnessDecoding.ts), cutting backend.ts from 2798 to ~1620 lines. Pure move-and-reimport; the three externally-consumed decode symbols are re-exported from backend.ts to keep importers resolving. Change-Id: Ib38d070fcb1289bc78556b8d1100e5cc9b4578a6 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- src/renderer/ghosttyWeb/backend.ts | 1232 +---------------- .../ghosttyWeb/embeddedHarnessHtml.ts | 783 +++++++++++ src/renderer/ghosttyWeb/harnessDecoding.ts | 411 ++++++ src/renderer/libghosttyVt/backend.ts | 24 +- src/renderer/replayEvents.ts | 38 + test/unit/renderer/replayEvents.test.ts | 138 ++ 6 files changed, 1396 insertions(+), 1230 deletions(-) create mode 100644 src/renderer/ghosttyWeb/embeddedHarnessHtml.ts create mode 100644 src/renderer/ghosttyWeb/harnessDecoding.ts create mode 100644 src/renderer/replayEvents.ts create mode 100644 test/unit/renderer/replayEvents.test.ts diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index 5acfad6..7e0bde4 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -44,37 +44,23 @@ import type { } from '../types.js'; import { BUNDLED_FONT_ASSETS } from '../bundledFont.js'; import { hashProfile } from '../profiles.js'; - -interface GhosttyHarnessVisibleLine { - row: number; - text: string; -} - -interface GhosttyHarnessSnapshotCell { - char: string; - fg?: string; - bg?: string; - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; -} - -interface GhosttyHarnessRichLine { - lineNumber: number; - cells: GhosttyHarnessSnapshotCell[]; -} - -interface GhosttyHarnessSnapshot { - cols: number; - rows: number; - cursorRow: number; - cursorCol: number; - isAltScreen: boolean; - visibleLines: GhosttyHarnessVisibleLine[]; - scrollbackLines?: GhosttyHarnessVisibleLine[]; - cells?: GhosttyHarnessRichLine[]; -} +import { iterateInRangeReplayEvents } from '../replayEvents.js'; +import { + assertHexColor, + assertNonNegativeInteger, + assertPositiveInteger, + assertPositiveNumber, + loadHarnessHtml, + normalizeError, + validateHarnessSnapshot, +} from './harnessDecoding.js'; +import type { GhosttyHarnessSnapshot } from './harnessDecoding.js'; + +export { + assembleCanonicalLine, + stripTrailingAsciiSpaces, +} from './harnessDecoding.js'; +export type { GhosttyDecodedColumn } from './harnessDecoding.js'; interface GhosttyRequestAsset { body: Buffer; @@ -137,910 +123,10 @@ const WASM_CONTENT_TYPE = 'application/wasm'; const MAX_REPLAY_BATCH_SIZE = 1000; const RAF_TIMEOUT_MS = 5_000; -const EMBEDDED_HARNESS_HTML = ` - - - - - agent-tty ghostty-web harness - - - -
-
-
- - - -`; let servedAssetsPromise: Promise< ReadonlyMap > | null = null; -/** - * One decoded terminal column: the cell's full grapheme cluster plus its - * column span. `width === 0` marks a wide glyph's trailing spacer column. - */ -export interface GhosttyDecodedColumn { - grapheme: string; - width: number; -} - -/** - * Strip ONLY trailing ASCII spaces (0x20). Unlike String.prototype.trimEnd - * this preserves other trailing whitespace (tabs, NBSP, etc.), keeping the - * canonical visible text aligned with the libghostty-vt backend. - * - * Exported as the host-testable twin of the identical function embedded in - * EMBEDDED_HARNESS_HTML; the harness copy is the browser runtime and cannot - * import this module, so the two must stay byte-for-byte in sync. - */ -export function stripTrailingAsciiSpaces(text: string): string { - let end = text.length; - while (end > 0 && text.charCodeAt(end - 1) === 0x20) { - end -= 1; - } - return end === text.length ? text : text.slice(0, end); -} - -/** - * Assemble one canonical visible line from a per-column reader, then - * right-trim trailing ASCII spaces. A width-0 column (a wide glyph's trailing - * spacer) contributes nothing, so a row of `A`+wide(`漢`)+wide(`字`)+`B` - * yields `A漢字B` — matching the libghostty-vt backend's visibleLines[].text. - * A genuine blank interior cell decodes to a single ' ', so interior gaps - * survive and trailing gaps trim away. Non-empty cells contribute their FULL - * grapheme cluster, so continuation codepoints (emoji ZWJ, NFD combining - * marks) are preserved instead of being truncated to the base codepoint. - * - * The live ghostty-web engine returns the NUL codepoint (U+0000) for a blank - * cell: getGrapheme yields `[0]`, so getGraphemeString runs - * `String.fromCodePoint(0)` and produces a NUL, not ' ' (its empty-array - * ' ' fallback never fires). Left as-is those NULs would survive - * stripTrailingAsciiSpaces (which strips only 0x20) and diverge from the - * native backend's right-trimmed ' '-blank form, so a kept cell whose grapheme - * is a lone NUL is normalized to ' ' here. - * - * Exported as the host-testable twin of the decodeGraphemeLine function - * embedded in EMBEDDED_HARNESS_HTML; keep the two in sync. - */ -export function assembleCanonicalLine( - cols: number, - readColumn: (col: number) => GhosttyDecodedColumn, -): string { - assertNonNegativeInteger( - cols, - 'canonical line cols must be a non-negative integer', - ); - let text = ''; - for (let col = 0; col < cols; col += 1) { - const column = readColumn(col); - assertString(column.grapheme, 'decoded grapheme must be a string'); - assertNonNegativeInteger( - column.width, - 'decoded cell width must be a non-negative integer', - ); - if (column.width === 0) { - continue; - } - text += column.grapheme === '\u0000' ? ' ' : column.grapheme; - } - return stripTrailingAsciiSpaces(text); -} - -function assertNonNegativeInteger( - value: unknown, - message: string, -): asserts value is number { - invariant( - typeof value === 'number' && Number.isInteger(value) && value >= 0, - message, - ); -} - -function assertPositiveInteger( - value: unknown, - message: string, -): asserts value is number { - invariant( - typeof value === 'number' && Number.isInteger(value) && value > 0, - message, - ); -} - -function assertPositiveNumber( - value: unknown, - message: string, -): asserts value is number { - invariant( - typeof value === 'number' && Number.isFinite(value) && value > 0, - message, - ); -} - -function assertHexColor( - value: unknown, - message: string, -): asserts value is string { - assertString(value, message); - invariant(/^#[0-9a-fA-F]{6}$/u.test(value), message); -} - -function normalizeError(error: unknown, prefix: string): Error { - if (error instanceof Error) { - return new Error(`${prefix}: ${error.message}`, { cause: error }); - } - - return new Error(`${prefix}: ${String(error)}`); -} - async function closeServer(server: Server): Promise { if (!server.listening) { return; @@ -1058,13 +144,6 @@ async function closeServer(server: Server): Promise { }); } -function loadHarnessHtml(): string { - // The embedded harness is the canonical runtime copy. Serving it directly keeps - // snapshot extraction behavior in sync with the bridge implementation even when - // the standalone source template drifts. - return EMBEDDED_HARNESS_HTML; -} - async function loadServedAssets(): Promise< ReadonlyMap > { @@ -1166,258 +245,6 @@ async function getServedAssets(): Promise< return servedAssetsPromise; } -function validateHarnessLines( - lines: unknown, - label: string, - rowUpperBoundExclusive?: number, -): GhosttyHarnessVisibleLine[] { - invariant(Array.isArray(lines), `${label}s must be an array`); - - const validatedLines: GhosttyHarnessVisibleLine[] = []; - let previousRow = -1; - for (const [index, lineValue] of lines.entries()) { - const lineIndex = String(index); - invariant( - lineValue !== null && typeof lineValue === 'object', - `${label} ${lineIndex} must be an object`, - ); - - const lineCandidate = lineValue as { - row?: unknown; - text?: unknown; - }; - assertNonNegativeInteger( - lineCandidate.row, - `${label} ${lineIndex} row must be a non-negative integer`, - ); - assertString( - lineCandidate.text, - `${label} ${lineIndex} text must be a string`, - ); - if (rowUpperBoundExclusive !== undefined) { - invariant( - lineCandidate.row < rowUpperBoundExclusive, - `${label} ${lineIndex} row must be within bounds`, - ); - } - invariant( - lineCandidate.row > previousRow, - `${label} ${lineIndex} rows must be strictly increasing`, - ); - previousRow = lineCandidate.row; - validatedLines.push({ - row: lineCandidate.row, - text: lineCandidate.text, - }); - } - - return validatedLines; -} - -function validateHarnessSnapshotCells( - cells: unknown, - visibleLines: readonly GhosttyHarnessVisibleLine[], - cols: number, -): GhosttyHarnessRichLine[] { - invariant(Array.isArray(cells), 'snapshot cells must be an array'); - - const validatedRichLines: GhosttyHarnessRichLine[] = []; - for (const [lineIndex, lineValue] of cells.entries()) { - invariant( - lineValue !== null && typeof lineValue === 'object', - `snapshot cell line ${String(lineIndex)} must be an object`, - ); - - const lineCandidate = lineValue as { - lineNumber?: unknown; - cells?: unknown; - }; - assertNonNegativeInteger( - lineCandidate.lineNumber, - `snapshot cell line ${String(lineIndex)} lineNumber must be a non-negative integer`, - ); - invariant( - Array.isArray(lineCandidate.cells), - `snapshot cell line ${String(lineIndex)} cells must be an array`, - ); - invariant( - lineIndex < visibleLines.length, - `snapshot cell line ${String(lineIndex)} must map to a visible line`, - ); - invariant( - lineCandidate.lineNumber === visibleLines[lineIndex]?.row, - `snapshot cell line ${String(lineIndex)} lineNumber must match visible line row`, - ); - - const validatedCells: GhosttyHarnessSnapshotCell[] = []; - for (const [cellIndex, cellValue] of lineCandidate.cells.entries()) { - invariant( - cellValue !== null && typeof cellValue === 'object', - `snapshot cell ${String(lineIndex)}:${String(cellIndex)} must be an object`, - ); - - const cellCandidate = cellValue as { - char?: unknown; - fg?: unknown; - bg?: unknown; - bold?: unknown; - italic?: unknown; - underline?: unknown; - strikethrough?: unknown; - }; - assertString( - cellCandidate.char, - `snapshot cell ${String(lineIndex)}:${String(cellIndex)} char must be a string`, - ); - if (cellCandidate.fg !== undefined) { - assertHexColor( - cellCandidate.fg, - `snapshot cell ${String(lineIndex)}:${String(cellIndex)} fg must be a hex color`, - ); - } - if (cellCandidate.bg !== undefined) { - assertHexColor( - cellCandidate.bg, - `snapshot cell ${String(lineIndex)}:${String(cellIndex)} bg must be a hex color`, - ); - } - for (const [fieldName, fieldValue] of Object.entries({ - bold: cellCandidate.bold, - italic: cellCandidate.italic, - underline: cellCandidate.underline, - strikethrough: cellCandidate.strikethrough, - })) { - invariant( - fieldValue === undefined || typeof fieldValue === 'boolean', - `snapshot cell ${String(lineIndex)}:${String(cellIndex)} ${fieldName} must be a boolean when provided`, - ); - } - - const validatedCell: GhosttyHarnessSnapshotCell = { - char: cellCandidate.char, - }; - if (cellCandidate.fg !== undefined) { - validatedCell.fg = cellCandidate.fg; - } - if (cellCandidate.bg !== undefined) { - validatedCell.bg = cellCandidate.bg; - } - if (typeof cellCandidate.bold === 'boolean') { - validatedCell.bold = cellCandidate.bold; - } - if (typeof cellCandidate.italic === 'boolean') { - validatedCell.italic = cellCandidate.italic; - } - if (typeof cellCandidate.underline === 'boolean') { - validatedCell.underline = cellCandidate.underline; - } - if (typeof cellCandidate.strikethrough === 'boolean') { - validatedCell.strikethrough = cellCandidate.strikethrough; - } - validatedCells.push(validatedCell); - } - - invariant( - validatedCells.length <= cols, - `snapshot cell line ${String(lineIndex)} cell count must not exceed the terminal width`, - ); - validatedRichLines.push({ - lineNumber: lineCandidate.lineNumber, - cells: validatedCells, - }); - } - - invariant( - validatedRichLines.length === visibleLines.length, - 'snapshot cell line count must match visible line count', - ); - return validatedRichLines; -} - -function validateHarnessSnapshot(snapshot: unknown): GhosttyHarnessSnapshot { - invariant( - snapshot !== null && typeof snapshot === 'object', - 'ghostty-web snapshot must be an object', - ); - - const candidate = snapshot as { - cols?: unknown; - rows?: unknown; - cursorRow?: unknown; - cursorCol?: unknown; - isAltScreen?: unknown; - visibleLines?: unknown; - scrollbackLines?: unknown; - cells?: unknown; - }; - - assertPositiveInteger( - candidate.cols, - 'snapshot cols must be a positive integer', - ); - assertPositiveInteger( - candidate.rows, - 'snapshot rows must be a positive integer', - ); - assertNonNegativeInteger( - candidate.cursorRow, - 'snapshot cursorRow must be a non-negative integer', - ); - assertNonNegativeInteger( - candidate.cursorCol, - 'snapshot cursorCol must be a non-negative integer', - ); - invariant( - candidate.cursorRow < candidate.rows, - 'snapshot cursorRow must be within the terminal height', - ); - invariant( - candidate.cursorCol < candidate.cols, - 'snapshot cursorCol must be within the terminal width', - ); - invariant( - typeof candidate.isAltScreen === 'boolean', - 'snapshot isAltScreen must be a boolean', - ); - - const visibleLines = validateHarnessLines( - candidate.visibleLines, - 'snapshot visible line', - candidate.rows, - ); - invariant( - visibleLines.length <= candidate.rows, - 'snapshot visibleLines length must not exceed the viewport height', - ); - - const scrollbackLines = - candidate.scrollbackLines === undefined - ? undefined - : validateHarnessLines( - candidate.scrollbackLines, - 'snapshot scrollback line', - ); - const cells = - candidate.cells === undefined - ? undefined - : validateHarnessSnapshotCells( - candidate.cells, - visibleLines, - candidate.cols, - ); - - return { - cols: candidate.cols, - rows: candidate.rows, - cursorRow: candidate.cursorRow, - cursorCol: candidate.cursorCol, - isAltScreen: candidate.isAltScreen, - visibleLines, - ...(scrollbackLines !== undefined && { scrollbackLines }), - ...(cells !== undefined && { cells }), - }; -} - export class GhosttyWebBackend implements VideoCapableRendererBackend { public readonly rendererBackend = 'ghostty-web'; public isBooted = false; @@ -1586,7 +413,6 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { ); } - let previousEventSeq = -1; let highestProcessedSeq = this.lastAppliedSeq; let pendingOutputChunks: string[] = []; @@ -1599,26 +425,10 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { pendingOutputChunks = []; }; - for (const event of input.events) { - assertNonNegativeInteger( - event.seq, - 'replay event seq must be a non-negative integer', - ); - invariant( - event.seq > previousEventSeq, - 'replay events must be ordered by strictly increasing seq values', - ); - previousEventSeq = event.seq; - - if (event.seq <= this.lastAppliedSeq) { - continue; - } - - if (event.seq > input.targetSeq) { - await flushOutputBatch(); - break; - } - + for (const event of iterateInRangeReplayEvents( + input, + this.lastAppliedSeq, + )) { switch (event.type) { case 'output': { pendingOutputChunks.push(event.payload.data); diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts new file mode 100644 index 0000000..6da41a0 --- /dev/null +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -0,0 +1,783 @@ +export const EMBEDDED_HARNESS_HTML = ` + + + + + agent-tty ghostty-web harness + + + +
+
+
+ + + +`; diff --git a/src/renderer/ghosttyWeb/harnessDecoding.ts b/src/renderer/ghosttyWeb/harnessDecoding.ts new file mode 100644 index 0000000..17f5fef --- /dev/null +++ b/src/renderer/ghosttyWeb/harnessDecoding.ts @@ -0,0 +1,411 @@ +import { invariant, assertString } from '../../util/assert.js'; +import { EMBEDDED_HARNESS_HTML } from './embeddedHarnessHtml.js'; + +export interface GhosttyHarnessVisibleLine { + row: number; + text: string; +} + +export interface GhosttyHarnessSnapshotCell { + char: string; + fg?: string; + bg?: string; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; +} + +export interface GhosttyHarnessRichLine { + lineNumber: number; + cells: GhosttyHarnessSnapshotCell[]; +} + +export interface GhosttyHarnessSnapshot { + cols: number; + rows: number; + cursorRow: number; + cursorCol: number; + isAltScreen: boolean; + visibleLines: GhosttyHarnessVisibleLine[]; + scrollbackLines?: GhosttyHarnessVisibleLine[]; + cells?: GhosttyHarnessRichLine[]; +} + +/** + * One decoded terminal column: the cell's full grapheme cluster plus its + * column span. `width === 0` marks a wide glyph's trailing spacer column. + */ +export interface GhosttyDecodedColumn { + grapheme: string; + width: number; +} + +/** + * Strip ONLY trailing ASCII spaces (0x20). Unlike String.prototype.trimEnd + * this preserves other trailing whitespace (tabs, NBSP, etc.), keeping the + * canonical visible text aligned with the libghostty-vt backend. + * + * Exported as the host-testable twin of the identical function embedded in + * EMBEDDED_HARNESS_HTML; the harness copy is the browser runtime and cannot + * import this module, so the two must stay byte-for-byte in sync. + */ +export function stripTrailingAsciiSpaces(text: string): string { + let end = text.length; + while (end > 0 && text.charCodeAt(end - 1) === 0x20) { + end -= 1; + } + return end === text.length ? text : text.slice(0, end); +} + +/** + * Assemble one canonical visible line from a per-column reader, then + * right-trim trailing ASCII spaces. A width-0 column (a wide glyph's trailing + * spacer) contributes nothing, so a row of `A`+wide(`漢`)+wide(`字`)+`B` + * yields `A漢字B` — matching the libghostty-vt backend's visibleLines[].text. + * A genuine blank interior cell decodes to a single ' ', so interior gaps + * survive and trailing gaps trim away. Non-empty cells contribute their FULL + * grapheme cluster, so continuation codepoints (emoji ZWJ, NFD combining + * marks) are preserved instead of being truncated to the base codepoint. + * + * The live ghostty-web engine returns the NUL codepoint (U+0000) for a blank + * cell: getGrapheme yields `[0]`, so getGraphemeString runs + * `String.fromCodePoint(0)` and produces a NUL, not ' ' (its empty-array + * ' ' fallback never fires). Left as-is those NULs would survive + * stripTrailingAsciiSpaces (which strips only 0x20) and diverge from the + * native backend's right-trimmed ' '-blank form, so a kept cell whose grapheme + * is a lone NUL is normalized to ' ' here. + * + * Exported as the host-testable twin of the decodeGraphemeLine function + * embedded in EMBEDDED_HARNESS_HTML; keep the two in sync. + */ +export function assembleCanonicalLine( + cols: number, + readColumn: (col: number) => GhosttyDecodedColumn, +): string { + assertNonNegativeInteger( + cols, + 'canonical line cols must be a non-negative integer', + ); + let text = ''; + for (let col = 0; col < cols; col += 1) { + const column = readColumn(col); + assertString(column.grapheme, 'decoded grapheme must be a string'); + assertNonNegativeInteger( + column.width, + 'decoded cell width must be a non-negative integer', + ); + if (column.width === 0) { + continue; + } + text += column.grapheme === '\u0000' ? ' ' : column.grapheme; + } + return stripTrailingAsciiSpaces(text); +} + +export function assertNonNegativeInteger( + value: unknown, + message: string, +): asserts value is number { + invariant( + typeof value === 'number' && Number.isInteger(value) && value >= 0, + message, + ); +} + +export function assertPositiveInteger( + value: unknown, + message: string, +): asserts value is number { + invariant( + typeof value === 'number' && Number.isInteger(value) && value > 0, + message, + ); +} + +export function assertPositiveNumber( + value: unknown, + message: string, +): asserts value is number { + invariant( + typeof value === 'number' && Number.isFinite(value) && value > 0, + message, + ); +} + +export function assertHexColor( + value: unknown, + message: string, +): asserts value is string { + assertString(value, message); + invariant(/^#[0-9a-fA-F]{6}$/u.test(value), message); +} + +export function normalizeError(error: unknown, prefix: string): Error { + if (error instanceof Error) { + return new Error(`${prefix}: ${error.message}`, { cause: error }); + } + + return new Error(`${prefix}: ${String(error)}`); +} + +export function loadHarnessHtml(): string { + // The embedded harness is the canonical runtime copy. Serving it directly keeps + // snapshot extraction behavior in sync with the bridge implementation even when + // the standalone source template drifts. + return EMBEDDED_HARNESS_HTML; +} + +function validateHarnessLines( + lines: unknown, + label: string, + rowUpperBoundExclusive?: number, +): GhosttyHarnessVisibleLine[] { + invariant(Array.isArray(lines), `${label}s must be an array`); + + const validatedLines: GhosttyHarnessVisibleLine[] = []; + let previousRow = -1; + for (const [index, lineValue] of lines.entries()) { + const lineIndex = String(index); + invariant( + lineValue !== null && typeof lineValue === 'object', + `${label} ${lineIndex} must be an object`, + ); + + const lineCandidate = lineValue as { + row?: unknown; + text?: unknown; + }; + assertNonNegativeInteger( + lineCandidate.row, + `${label} ${lineIndex} row must be a non-negative integer`, + ); + assertString( + lineCandidate.text, + `${label} ${lineIndex} text must be a string`, + ); + if (rowUpperBoundExclusive !== undefined) { + invariant( + lineCandidate.row < rowUpperBoundExclusive, + `${label} ${lineIndex} row must be within bounds`, + ); + } + invariant( + lineCandidate.row > previousRow, + `${label} ${lineIndex} rows must be strictly increasing`, + ); + previousRow = lineCandidate.row; + validatedLines.push({ + row: lineCandidate.row, + text: lineCandidate.text, + }); + } + + return validatedLines; +} + +function validateHarnessSnapshotCells( + cells: unknown, + visibleLines: readonly GhosttyHarnessVisibleLine[], + cols: number, +): GhosttyHarnessRichLine[] { + invariant(Array.isArray(cells), 'snapshot cells must be an array'); + + const validatedRichLines: GhosttyHarnessRichLine[] = []; + for (const [lineIndex, lineValue] of cells.entries()) { + invariant( + lineValue !== null && typeof lineValue === 'object', + `snapshot cell line ${String(lineIndex)} must be an object`, + ); + + const lineCandidate = lineValue as { + lineNumber?: unknown; + cells?: unknown; + }; + assertNonNegativeInteger( + lineCandidate.lineNumber, + `snapshot cell line ${String(lineIndex)} lineNumber must be a non-negative integer`, + ); + invariant( + Array.isArray(lineCandidate.cells), + `snapshot cell line ${String(lineIndex)} cells must be an array`, + ); + invariant( + lineIndex < visibleLines.length, + `snapshot cell line ${String(lineIndex)} must map to a visible line`, + ); + invariant( + lineCandidate.lineNumber === visibleLines[lineIndex]?.row, + `snapshot cell line ${String(lineIndex)} lineNumber must match visible line row`, + ); + + const validatedCells: GhosttyHarnessSnapshotCell[] = []; + for (const [cellIndex, cellValue] of lineCandidate.cells.entries()) { + invariant( + cellValue !== null && typeof cellValue === 'object', + `snapshot cell ${String(lineIndex)}:${String(cellIndex)} must be an object`, + ); + + const cellCandidate = cellValue as { + char?: unknown; + fg?: unknown; + bg?: unknown; + bold?: unknown; + italic?: unknown; + underline?: unknown; + strikethrough?: unknown; + }; + assertString( + cellCandidate.char, + `snapshot cell ${String(lineIndex)}:${String(cellIndex)} char must be a string`, + ); + if (cellCandidate.fg !== undefined) { + assertHexColor( + cellCandidate.fg, + `snapshot cell ${String(lineIndex)}:${String(cellIndex)} fg must be a hex color`, + ); + } + if (cellCandidate.bg !== undefined) { + assertHexColor( + cellCandidate.bg, + `snapshot cell ${String(lineIndex)}:${String(cellIndex)} bg must be a hex color`, + ); + } + for (const [fieldName, fieldValue] of Object.entries({ + bold: cellCandidate.bold, + italic: cellCandidate.italic, + underline: cellCandidate.underline, + strikethrough: cellCandidate.strikethrough, + })) { + invariant( + fieldValue === undefined || typeof fieldValue === 'boolean', + `snapshot cell ${String(lineIndex)}:${String(cellIndex)} ${fieldName} must be a boolean when provided`, + ); + } + + const validatedCell: GhosttyHarnessSnapshotCell = { + char: cellCandidate.char, + }; + if (cellCandidate.fg !== undefined) { + validatedCell.fg = cellCandidate.fg; + } + if (cellCandidate.bg !== undefined) { + validatedCell.bg = cellCandidate.bg; + } + if (typeof cellCandidate.bold === 'boolean') { + validatedCell.bold = cellCandidate.bold; + } + if (typeof cellCandidate.italic === 'boolean') { + validatedCell.italic = cellCandidate.italic; + } + if (typeof cellCandidate.underline === 'boolean') { + validatedCell.underline = cellCandidate.underline; + } + if (typeof cellCandidate.strikethrough === 'boolean') { + validatedCell.strikethrough = cellCandidate.strikethrough; + } + validatedCells.push(validatedCell); + } + + invariant( + validatedCells.length <= cols, + `snapshot cell line ${String(lineIndex)} cell count must not exceed the terminal width`, + ); + validatedRichLines.push({ + lineNumber: lineCandidate.lineNumber, + cells: validatedCells, + }); + } + + invariant( + validatedRichLines.length === visibleLines.length, + 'snapshot cell line count must match visible line count', + ); + return validatedRichLines; +} + +export function validateHarnessSnapshot( + snapshot: unknown, +): GhosttyHarnessSnapshot { + invariant( + snapshot !== null && typeof snapshot === 'object', + 'ghostty-web snapshot must be an object', + ); + + const candidate = snapshot as { + cols?: unknown; + rows?: unknown; + cursorRow?: unknown; + cursorCol?: unknown; + isAltScreen?: unknown; + visibleLines?: unknown; + scrollbackLines?: unknown; + cells?: unknown; + }; + + assertPositiveInteger( + candidate.cols, + 'snapshot cols must be a positive integer', + ); + assertPositiveInteger( + candidate.rows, + 'snapshot rows must be a positive integer', + ); + assertNonNegativeInteger( + candidate.cursorRow, + 'snapshot cursorRow must be a non-negative integer', + ); + assertNonNegativeInteger( + candidate.cursorCol, + 'snapshot cursorCol must be a non-negative integer', + ); + invariant( + candidate.cursorRow < candidate.rows, + 'snapshot cursorRow must be within the terminal height', + ); + invariant( + candidate.cursorCol < candidate.cols, + 'snapshot cursorCol must be within the terminal width', + ); + invariant( + typeof candidate.isAltScreen === 'boolean', + 'snapshot isAltScreen must be a boolean', + ); + + const visibleLines = validateHarnessLines( + candidate.visibleLines, + 'snapshot visible line', + candidate.rows, + ); + invariant( + visibleLines.length <= candidate.rows, + 'snapshot visibleLines length must not exceed the viewport height', + ); + + const scrollbackLines = + candidate.scrollbackLines === undefined + ? undefined + : validateHarnessLines( + candidate.scrollbackLines, + 'snapshot scrollback line', + ); + const cells = + candidate.cells === undefined + ? undefined + : validateHarnessSnapshotCells( + candidate.cells, + visibleLines, + candidate.cols, + ); + + return { + cols: candidate.cols, + rows: candidate.rows, + cursorRow: candidate.cursorRow, + cursorCol: candidate.cursorCol, + isAltScreen: candidate.isAltScreen, + visibleLines, + ...(scrollbackLines !== undefined && { scrollbackLines }), + ...(cells !== undefined && { cells }), + }; +} diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index fc17adb..3361ccd 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -24,6 +24,7 @@ import type { SemanticSnapshot, } from '../types.js'; import { SemanticSnapshotSchema } from '../types.js'; +import { iterateInRangeReplayEvents } from '../replayEvents.js'; import { DEFAULT_COLS, DEFAULT_ROWS } from '../../config/defaults.js'; import { invariant, assertString, unreachable } from '../../util/assert.js'; import { Logger, createProcessLogger } from '../../util/logger.js'; @@ -481,26 +482,11 @@ export class LibghosttyVtBackend implements RendererBackend { ); } - let previousEventSeq = -1; let highestProcessedSeq = this.lastAppliedSeq; - for (const event of input.events) { - assertNonNegativeInteger( - event.seq, - 'replay event seq must be non-negative', - ); - invariant( - event.seq > previousEventSeq, - 'replay events must be ordered by strictly increasing seq values', - ); - previousEventSeq = event.seq; - - if (event.seq <= this.lastAppliedSeq) { - continue; - } - if (event.seq > input.targetSeq) { - break; - } - + for (const event of iterateInRangeReplayEvents( + input, + this.lastAppliedSeq, + )) { switch (event.type) { case 'output': terminal.feed(event.payload.data); diff --git a/src/renderer/replayEvents.ts b/src/renderer/replayEvents.ts new file mode 100644 index 0000000..84ba155 --- /dev/null +++ b/src/renderer/replayEvents.ts @@ -0,0 +1,38 @@ +import type { ReplayInput } from './types.js'; +import { invariant } from '../util/assert.js'; + +type ReplayEvent = ReplayInput['events'][number]; + +/** + * Yield the replay events that fall in the half-open range + * (lastAppliedSeq, targetSeq], in order. Enforces the seq invariants shared by + * every renderer backend: each event seq is a non-negative integer, seqs are + * strictly increasing across ALL events (including skipped ones), events at or + * below lastAppliedSeq are skipped, and iteration stops at the first event + * beyond targetSeq. Callers dispatch on event.type and own how output is fed. + */ +export function* iterateInRangeReplayEvents( + input: ReplayInput, + lastAppliedSeq: number, +): Generator { + let previousEventSeq = -1; + for (const event of input.events) { + invariant( + Number.isInteger(event.seq) && event.seq >= 0, + 'replay event seq must be a non-negative integer', + ); + invariant( + event.seq > previousEventSeq, + 'replay events must be ordered by strictly increasing seq values', + ); + previousEventSeq = event.seq; + + if (event.seq <= lastAppliedSeq) { + continue; + } + if (event.seq > input.targetSeq) { + return; + } + yield event; + } +} diff --git a/test/unit/renderer/replayEvents.test.ts b/test/unit/renderer/replayEvents.test.ts new file mode 100644 index 0000000..4c228de --- /dev/null +++ b/test/unit/renderer/replayEvents.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { iterateInRangeReplayEvents } from '../../../src/renderer/replayEvents.js'; +import type { ReplayInput } from '../../../src/renderer/types.js'; + +type ReplayEvent = ReplayInput['events'][number]; + +const TS = '2026-06-16T00:00:00.000Z'; + +// Build an `output` replay event at the given seq. `output` is the simplest +// event shape and is all these tests need — the helper only inspects `seq`. +function outputEvent(seq: number, data = `data-${String(seq)}`): ReplayEvent { + return { seq, ts: TS, type: 'output', payload: { data } }; +} + +// Construct a ReplayInput directly (not via the schema) so the tests can feed +// the helper deliberately malformed event streams — out-of-order or negative +// seqs the schema would reject — to prove the helper enforces the invariants +// itself at runtime. +function replayInput( + events: readonly ReplayEvent[], + targetSeq: number, +): ReplayInput { + return { + sessionId: 'session-1', + initialCols: 80, + initialRows: 24, + events: [...events], + targetSeq, + }; +} + +function seqs(input: ReplayInput, lastAppliedSeq: number): number[] { + return [...iterateInRangeReplayEvents(input, lastAppliedSeq)].map( + (event) => event.seq, + ); +} + +describe('iterateInRangeReplayEvents', () => { + it('yields all events in order when every event is in range', () => { + const events = [outputEvent(0), outputEvent(1), outputEvent(2)]; + const input = replayInput(events, 2); + + const yielded = [...iterateInRangeReplayEvents(input, -1)]; + + expect(yielded).toEqual(events); + expect(yielded.map((event) => event.seq)).toEqual([0, 1, 2]); + }); + + it('skips events with seq <= lastAppliedSeq (not yielded)', () => { + const input = replayInput( + [outputEvent(0), outputEvent(1), outputEvent(2), outputEvent(3)], + 3, + ); + + expect(seqs(input, 1)).toEqual([2, 3]); + }); + + it('stops at the first event with seq > targetSeq (it and the rest are not yielded)', () => { + const input = replayInput( + [outputEvent(0), outputEvent(1), outputEvent(2), outputEvent(3)], + 1, + ); + + // 2 and 3 are beyond targetSeq=1, so iteration stops at 2. + expect(seqs(input, -1)).toEqual([0, 1]); + }); + + it('combines skip and stop bounds into the half-open range (lastAppliedSeq, targetSeq]', () => { + const input = replayInput( + [ + outputEvent(0), + outputEvent(1), + outputEvent(2), + outputEvent(3), + outputEvent(4), + ], + 3, + ); + + expect(seqs(input, 1)).toEqual([2, 3]); + }); + + it('throws the strictly-increasing invariant on out-of-order seqs', () => { + const input = replayInput( + [outputEvent(0), outputEvent(2), outputEvent(1)], + 5, + ); + + expect(() => [...iterateInRangeReplayEvents(input, -1)]).toThrow( + 'replay events must be ordered by strictly increasing seq values', + ); + }); + + it('throws the non-negative-integer invariant on a negative seq', () => { + const input = replayInput([outputEvent(-1)], 5); + + expect(() => [...iterateInRangeReplayEvents(input, -1)]).toThrow( + 'replay event seq must be a non-negative integer', + ); + }); + + it('throws the non-negative-integer invariant on a non-integer seq', () => { + const input = replayInput([outputEvent(1.5)], 5); + + expect(() => [...iterateInRangeReplayEvents(input, -1)]).toThrow( + 'replay event seq must be a non-negative integer', + ); + }); + + it('enforces strictly-increasing even when the offending event would be skipped', () => { + // lastAppliedSeq is high enough that seq 5 and the repeated 5 are both in + // the skip range, but the helper checks ordering BEFORE the skip, so the + // repeated seq still throws. + const input = replayInput([outputEvent(5), outputEvent(5)], 10); + + expect(() => [...iterateInRangeReplayEvents(input, 7)]).toThrow( + 'replay events must be ordered by strictly increasing seq values', + ); + }); + + it('stops at the targetSeq boundary lazily and does not inspect events past it', () => { + // The duplicate (non-increasing) seq sits past targetSeq. The helper is a + // lazy generator: it validates each event as it reaches it, then returns at + // the first event beyond targetSeq. So it yields [0], stops at the first 5, + // and never reaches the second 5 to flag the ordering problem. + const input = replayInput( + [outputEvent(0), outputEvent(5), outputEvent(5)], + 1, + ); + + expect(seqs(input, -1)).toEqual([0]); + }); + + it('yields nothing when the event list is empty', () => { + expect(seqs(replayInput([], 5), -1)).toEqual([]); + }); +}); From b8f60e947c2e6640120e2958077f8506dce08b59 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 16 Jun 2026 17:49:44 +0200 Subject: [PATCH 5/5] docs: add advisor plans for the six implemented improvements Change-Id: Iacec0ce02995b6b64254c68d3c9301a52f7c7f89 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- plans/001-harden-local-state-permissions.md | 321 ++++++++++++++ plans/002-dependency-audit-gate.md | 261 +++++++++++ plans/003-fix-release-contract-version.md | 155 +++++++ plans/004-hostmain-characterization-tests.md | 272 ++++++++++++ plans/005-dedupe-replay-event-iteration.md | 437 +++++++++++++++++++ plans/006-split-ghostty-web-backend.md | 263 +++++++++++ plans/README.md | 121 +++++ 7 files changed, 1830 insertions(+) create mode 100644 plans/001-harden-local-state-permissions.md create mode 100644 plans/002-dependency-audit-gate.md create mode 100644 plans/003-fix-release-contract-version.md create mode 100644 plans/004-hostmain-characterization-tests.md create mode 100644 plans/005-dedupe-replay-event-iteration.md create mode 100644 plans/006-split-ghostty-web-backend.md create mode 100644 plans/README.md diff --git a/plans/001-harden-local-state-permissions.md b/plans/001-harden-local-state-permissions.md new file mode 100644 index 0000000..197360e --- /dev/null +++ b/plans/001-harden-local-state-permissions.md @@ -0,0 +1,321 @@ +# Plan 001: agent-tty restricts its local socket and state files to the owning user + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat c11e2e2..HEAD -- src/host/hostMain.ts src/host/rpcServer.ts src/storage/manifests.ts` +> If any in-scope file changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S–M +- **Risk**: LOW +- **Depends on**: none +- **Category**: security +- **Planned at**: commit `c11e2e2`, 2026-06-16 + +## Why this matters + +`agent-tty` gives a caller full control of a real PTY: the RPC server accepts +`type`, `paste`, `send-keys`, `run`, `resize`, and `signal` — i.e. arbitrary +input into your shell. That server listens on a Unix domain socket at a +**deterministic, world-traversable** path under `/tmp/agent-tty/`, and the +socket and the session state files are created with **no explicit permissions**, +so they inherit the process umask. On a shared machine (multi-user dev box, a +shared CI runner) with a permissive umask, another local user can connect to the +socket and drive your session — effectively arbitrary command execution as you — +or read your session manifests and Home Registry. The default umask (`022`) +happens to block _connecting_ (connect needs write on the socket file), but +relying on ambient umask for an authorization boundary is fragile. This plan +makes the boundary explicit: the per-Home socket directory becomes owner-only +(`0o700`), the socket file and persisted state files become owner-only +(`0o600`), regardless of umask. + +## Current state + +Files involved: + +- `src/host/hostMain.ts` — per-session host entrypoint (`runHost`); creates the + socket directory just before the RPC server listens. +- `src/host/rpcServer.ts` — `RpcServer.listen()` binds the Unix domain socket. +- `src/storage/manifests.ts` — `writeTextFileAtomic`, the single writer used for + the session manifest and the Home Registry (`homes.json`). +- `src/storage/sessionPaths.ts` — builds the socket path + `/tmp/agent-tty//` (read-only here; + do not change path construction — it is already traversal-guarded). + +**Socket directory creation — `src/host/hostMain.ts` around line 1077** (inside +`runHost`, `mkdir` is already imported from `node:fs/promises` on line 1): + +```ts +await mkdir(dirname(sPath), { recursive: true }); +``` + +`sPath` is the socket path (`const sPath = socketPath(sessDir);` earlier in +`runHost`, ~line 143). `dirname(sPath)` is the per-Home socket directory. The +`mkdir` passes no `mode`, so the directory inherits the umask. + +**Socket bind — `src/host/rpcServer.ts:190-229`** (`server.listen` sets no +permissions on the created socket file): + +```ts + public async listen(): Promise { + invariant(this.server === null, 'RPC server is already listening.'); + + await this.removeStaleSocketIfNeeded(); + // ... length + existence invariants ... + const server = net.createServer((socket) => { + this.handleConnection(socket); + }); + server.on('error', () => { /* ... */ }); + this.server = server; + + try { + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { reject(error); }; + server.once('error', onError); + server.listen(this.socketPath, () => { + server.off('error', onError); + resolve(); + }); + }); + } catch (error) { + this.server = null; + throw error; + } + } +``` + +`this.socketPath` is a private field set in the constructor. `net` is imported +at the top of the file; `node:fs/promises` is **not** yet imported there. + +**State-file writer — `src/storage/manifests.ts:100-120`** (no `mode` on +`writeFile`, so manifests and `homes.json` inherit the umask, typically `0o644` += world-readable): + +```ts +export async function writeTextFileAtomic( + options: WriteTextFileAtomicOptions, +): Promise { + assertAbsoluteStoragePath(options.path, options.pathLabel); + + const outputDirectory = dirname(options.path); + const temporaryPath = `${options.path}.tmp-${randomUUID()}`; + + try { + await mkdir(outputDirectory, { recursive: true }); + await writeFile(temporaryPath, options.contents, 'utf8'); + await rename(temporaryPath, options.path); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw makeCliError(ERROR_CODES.STORAGE_WRITE_ERROR, { + message: options.writeErrorMessage, + details: { path: options.path }, + cause: error, + }); + } +} +``` + +`mkdir`, `rename`, `rm`, `writeFile` are imported from `node:fs/promises` on +line 2 of this file. + +### Conventions to follow + +- This is strict TypeScript, NodeNext ESM. Imports from TypeScript source use + `.js` extensions. Prefer `import type` for type-only imports. +- Use the existing `invariant` helper (`src/util/assert.ts`) for preconditions; + match the surrounding small-helper, explicit-control-flow style. +- `chmod` is preferred over a `mkdir` `mode` option because **`mkdir`'s `mode` + is masked by the umask, but `chmod` is not** — `chmod` guarantees the final + mode. Octal literals like `0o700` are the standard Node idiom. +- 2-space indent, single quotes, trailing commas, semicolons (oxfmt enforces). + +### Design constraints (from CONTEXT.md / AGENTS.md — honor these) + +- The socket path is derived per **Home** then per **Session**; a single + per-Home socket directory holds one socket file per Session. Locking the + directory to `0o700` is per-Home and is correct for all sessions in that Home. +- Storage writes must stay inside validated helpers; do **not** add ad-hoc + `fs` permission logic in command code — change it in `writeTextFileAtomic` + (the single manifest/registry writer) and in the host socket setup only. + +## Commands you will need + +| Purpose | Command | Expected on success | +| --------------- | ------------------------------------------------ | ------------------- | +| Install deps | `aube install` | exit 0 | +| Typecheck | `npm run typecheck` | exit 0, no errors | +| Lint | `npm run lint` | exit 0 | +| Format (fix) | `npm run format` | exit 0 | +| Run one test | `npx vitest run test/integration/.test.ts` | all pass | +| Integration set | `npm run test:integration` | all pass | + +## Scope + +**In scope** (the only files you should modify): + +- `src/host/hostMain.ts` — chmod the socket directory after creating it. +- `src/host/rpcServer.ts` — chmod the socket file after `listen()` resolves. +- `src/storage/manifests.ts` — write state files with `mode: 0o600`. +- A new or existing test file under `test/integration/` (see Test plan). + +**Out of scope** (do NOT touch): + +- `src/storage/sessionPaths.ts` — path construction is already traversal-guarded + with `dirname(x) === root` invariants. Do not change it. +- `CHANGELOG.md` — automation-owned (Communique/release-please). Never edit it + in a feature change; a manual edit conflicts with `main` and breaks CI. +- The public CLI JSON envelopes / protocol schemas — this change is internal. +- Windows-specific permission behavior — Windows is tier-2 and Unix mode bits + do not apply; guard the new test to Unix only (see Test plan). + +## Git workflow + +- Branch: `advisor/001-harden-local-state-permissions` +- Commit message style: Conventional Commits (repo enforces this on PR titles). + Example from history: `fix: drop the component suffix from the release branch name`. + Use e.g. `fix: restrict agent-tty socket and state files to the owning user`. +- Do NOT push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Lock the per-Home socket directory to `0o700` + +In `src/host/hostMain.ts`: + +1. Add `chmod` to the existing `node:fs/promises` import on line 1 + (`import { chmod, mkdir } from 'node:fs/promises';`). +2. At the socket-directory creation site (~line 1077), after the existing + `mkdir`, add a `chmod` of that directory to `0o700`: + +```ts +const socketDirectory = dirname(sPath); +await mkdir(socketDirectory, { recursive: true }); +await chmod(socketDirectory, 0o700); +``` + +(If `sPath` / `dirname(sPath)` is already bound to a local variable nearby, +reuse it instead of recomputing — keep one `dirname(sPath)` expression.) + +**Verify**: `npm run typecheck` → exit 0, no errors. + +### Step 2: Lock the socket file to `0o600` after bind + +In `src/host/rpcServer.ts`: + +1. Add an import: `import { chmod } from 'node:fs/promises';` (place it with the + other `node:` imports at the top). +2. Inside `listen()`, immediately after the `await new Promise(...)` that + resolves when `server.listen(...)` succeeds (i.e. after the try/catch that + binds the socket, before the method returns), chmod the socket file: + +```ts +await chmod(this.socketPath, 0o600); +``` + +Place this **after** the bind succeeds (the socket file does not exist until +`listen` resolves). Do not place it inside the `catch`. + +**Verify**: `npm run typecheck` → exit 0. Then `npm run test:integration` +→ all pass (existing RPC/lifecycle integration tests still connect, because the +owner retains read/write). + +### Step 3: Write persisted state files as `0o600` + +In `src/storage/manifests.ts`, change the `writeFile` call in +`writeTextFileAtomic` to set an explicit mode: + +```ts +await writeFile(temporaryPath, options.contents, { + encoding: 'utf8', + mode: 0o600, +}); +``` + +The mode survives the subsequent `rename` to the final path (rename preserves +the inode and its mode). No other change in this function. + +**Verify**: `npm run typecheck` → exit 0. + +### Step 4: Format and full static check + +Run `npm run format` then `npm run lint` → both exit 0. + +## Test plan + +Add a focused integration test that creates a session and asserts the +permission bits. Model it on an existing integration test that already spins up +a session against an isolated `AGENT_TTY_HOME` — inspect `test/integration/` +for one that calls `create` then `destroy` (e.g. `test/integration/gc.test.ts` +or a lifecycle test) and copy its setup/teardown shape (isolated temp home, +absolute `AGENT_TTY_HOME`, never the real `~/.agent-tty`). + +New test file: `test/integration/socket-permissions.test.ts` (or add a case to +the closest existing lifecycle integration test if the maintainer prefers). +Cover: + +- **Socket directory is `0o700`**: after `create`, locate the per-Home socket + directory under `/tmp/agent-tty/` for the test's Home and assert + `(statSync(dir).mode & 0o777) === 0o700`. +- **Socket file is `0o600`**: assert the bound socket file's + `(mode & 0o777) === 0o600`. +- **Manifest is `0o600`**: after `create`, assert the session manifest file's + `(mode & 0o777) === 0o600`. +- **Owner can still drive the session**: a `run` or `inspect` against the + session still succeeds (proves the tightened perms didn't lock out the owner). + +Guard the whole suite to Unix: at the top, `if (process.platform === 'win32')` +skip (use vitest's `describe.skipIf(process.platform === 'win32')` or an early +`it.skip`). Mode bits are not meaningful on Windows. + +**Verification**: `npx vitest run test/integration/socket-permissions.test.ts` +→ all new cases pass. + +## Done criteria + +ALL must hold: + +- [ ] `npm run typecheck` exits 0. +- [ ] `npm run lint` exits 0. +- [ ] `npm run format:check` exits 0. +- [ ] `npm run test:integration` exits 0; the new socket-permissions test exists + and passes on this (Unix) machine. +- [ ] `grep -n "chmod" src/host/hostMain.ts src/host/rpcServer.ts` shows the two + new chmod calls. +- [ ] `grep -n "mode: 0o600" src/storage/manifests.ts` shows the manifest mode. +- [ ] No files outside the in-scope list are modified (`git status`). +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report back (do not improvise) if: + +- The code at the "Current state" locations doesn't match the excerpts (drift). +- An existing integration or e2e test that connects to the socket **fails** after + Step 2 — that would mean the owner is being locked out or a non-owner path + exists you weren't told about. Do not loosen the mode to make it pass. +- The socket-directory creation is not at/near `hostMain.ts:1077`, or `sPath` + is constructed differently than described. +- Setting `mode: 0o600` on the temp file changes behavior on `rename` (e.g. a + test reads the manifest as a different user) — report rather than reverting to + default mode. + +## Maintenance notes + +- A reviewer should confirm the chmod on the socket happens **after** `listen` + resolves (the file doesn't exist before then) and that the directory chmod + uses `chmod`, not the `mkdir` `mode` option (which umask would mask). +- If a future change moves the socket out of `/tmp/agent-tty/` or makes sockets + per-session-directory instead of per-Home, revisit the directory chmod. +- Deferred out of this plan: hardening the _parent_ `/tmp/agent-tty/` root mode + (left at default; per-Home `0o700` already prevents traversal into a Home's + sockets) and any audit-logging of rejected connections. Not needed for the + boundary this plan establishes. diff --git a/plans/002-dependency-audit-gate.md b/plans/002-dependency-audit-gate.md new file mode 100644 index 0000000..08abb85 --- /dev/null +++ b/plans/002-dependency-audit-gate.md @@ -0,0 +1,261 @@ +# Plan 002: CI fails on high-severity dependency advisories, and the current ones are cleared + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat c11e2e2..HEAD -- mise.toml .github/workflows/ci.yml package.json aube-lock.yaml` +> If any in-scope file changed since this plan was written, re-run +> `aube audit` (Step 1) and compare against the advisory list below before +> proceeding; on a large mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S–M +- **Risk**: LOW +- **Depends on**: none +- **Category**: security / dx +- **Planned at**: commit `c11e2e2`, 2026-06-16 + +## Why this matters + +This repo has no dependency-advisory gate: `mise.toml` has no `audit` task and +`.github/workflows/ci.yml` never audits. As of this writing, `aube audit` +reports **7 advisories (3 high, 3 moderate, 1 low)** that went unnoticed for +exactly that reason. None is a realistic exploit of the shipped CLI — they sit +in transitive build/tooling and non-attacker-facing runtime paths (e.g. +Playwright's own CDP WebSocket, build tools) — but they are trivial to clear and +should not be invisible. The durable win here is the **gate**: once CI runs +`aube audit --audit-level high`, any _future_ high/critical advisory in the +dependency tree fails the build instead of silently shipping. + +## Current state + +The advisories, from running `aube audit` at the repo root (you will re-run this +in Step 1 to get the live list): + +| Severity | Package | Vulnerable range | Advisory | +| -------- | ------------------ | ---------------- | ---------------------------------------------------------------- | +| high | esbuild | >=0.17.0 <0.28.1 | GHSA-gv7w-rqvm-qjhr (binary-integrity / NPM_CONFIG_REGISTRY RCE) | +| high | vite | >=8.0.0 <=8.0.15 | GHSA-fx2h-pf6j-xcff (`server.fs.deny` bypass, Windows) | +| high | ws | >=8.0.0 <8.21.0 | GHSA-96hv-2xvq-fx4p (memory-exhaustion DoS) | +| moderate | vite/launch-editor | >=8.0.0 <=8.0.15 | GHSA-v6wh-96g9-6wx3 (NTLMv2 hash disclosure, Windows) | +| moderate | ws | >=8.0.0 <8.20.1 | GHSA-58qx-3vcg-4xpx (uninitialized memory disclosure) | +| low | esbuild | >=0.27.3 <0.28.1 | GHSA-g7r4-m6w7-qqqr (dev-server arbitrary file read, Windows) | + +Installed versions (from `aube-lock.yaml`): `esbuild@0.27.7`, `vite@8.0.11`, +`ws@8.20.0`, `brace-expansion@5.0.5` (the brace-expansion moderate ReDoS, +GHSA-jxxr-4gwj-5jf2, also appears in the full audit). All are **transitive** — +none is listed directly in `package.json` `dependencies`/`devDependencies`. + +**`mise.toml`** defines tasks as `[tasks.]` with a `run = "..."`. The +aggregate CI task is: + +```toml +[tasks.ci] +description = "Run CI checks" +run = "mise run format-check && mise run workflow-lint && mise run lint && mise run typecheck && mise run test && mise run build && mise run install-smoke" +``` + +There is **no** `[tasks.audit]`. + +**`.github/workflows/ci.yml`** — the `linux-static` job runs a sequence of +`mise run …` steps (format-check, workflow-lint, lint, typecheck, +validate-bundles, build, install-smoke). It must stay hand-curated (per +`AGENTS.md`: "Keep `.github/workflows/ci.yml` hand-curated"). + +### The audit tooling (verified) + +`aube audit` supports: + +- `--audit-level ` — only fail/print at or above a + severity (default `low`). +- `--fix=update` — refresh the lockfile to patched versions allowed by existing + version ranges (no `package.json` changes). +- `--fix=override` — write `package.json` overrides forcing patched versions. +- `--dev` — audit only `devDependencies`. + +`aube audit` mutates `aube-lock.yaml` / `package.json` only when `--fix` is +passed; a bare `aube audit` is read-only. + +## Commands you will need + +| Purpose | Command | Expected on success | +| ----------------- | ------------------------------- | ------------------------------- | +| Audit (read-only) | `aube audit` | prints advisories | +| Audit, high+ only | `aube audit --audit-level high` | "0 vulnerabilities" at high+ | +| Fix in-range | `aube audit --fix=update` | lockfile updated | +| Fix via overrides | `aube audit --fix=override` | package.json + lockfile updated | +| Install | `aube install` | exit 0 | +| Typecheck | `npm run typecheck` | exit 0 | +| Build | `npm run build` | exit 0 | +| Unit tests | `npm run test:unit` | all pass | +| Lint workflows | `mise run workflow-lint` | exit 0 | +| Run a mise task | `mise run audit` | (after Step 3) | + +## Scope + +**In scope**: + +- `package.json` — only if `--fix=override` adds an `overrides`/`pnpm.overrides` + block to clear advisories. +- `aube-lock.yaml` — regenerated by `aube audit --fix` / `aube install`. +- `mise.toml` — add `[tasks.audit]` and reference it from `[tasks.ci]`. +- `.github/workflows/ci.yml` — add one audit step to the `linux-static` job. + +**Out of scope**: + +- Bumping the _direct_ dependency majors (`playwright`, `ink`, `vitest`, + `ghostty-web`) to chase a transitive — overrides are the surgical fix. If only + a direct-major bump can clear a high advisory, that is a STOP condition. +- `CHANGELOG.md` — automation-owned (Communique/release-please); never edit it. +- Any `src/` code change. This plan is dependency + CI config only. +- The macOS CI job (`quality-gates-macos`) — it intentionally omits release-only + tooling; do not add the audit step there. + +## Git workflow + +- Branch: `advisor/002-dependency-audit-gate` +- Conventional Commits. Example: `ci: gate CI on high-severity dependency advisories`. + If overrides are written, a second commit like + `chore(deps): override ws/vite/esbuild to patched versions` is fine. +- Do NOT push or open a PR unless instructed. + +## Steps + +### Step 1: Capture the current advisory baseline + +Run `aube audit` and save the output. Confirm it roughly matches the table +above (versions/advisories may have shifted slightly since planning — that's +fine; work from the live list). Then run `aube audit --audit-level high` and +note exactly which **high** advisories are reported — those are the ones the +gate (Step 3) will require to be clear. + +**Verify**: `aube audit` prints a non-empty advisory list including at least one +`high`. + +### Step 2: Clear the advisories + +1. Run `aube audit --fix=update` (patches reachable within existing ranges). +2. Re-run `aube audit --audit-level high`. If high advisories remain, run + `aube audit --fix=override` to force the patched versions (this writes an + overrides block to `package.json`). +3. Run `aube install` to ensure the lockfile is consistent. +4. Re-run `aube audit --audit-level high`. + +**Verify**: `aube audit --audit-level high` reports **0 high (and 0 critical) +vulnerabilities**. (Moderate/low may remain — see Maintenance notes.) + +### Step 3: Confirm nothing broke + +The overrides force newer transitive versions; confirm the toolchain still works: + +- `npm run typecheck` → exit 0. +- `npm run build` → exit 0. +- `npm run test:unit` → all pass. + +If feasible in this environment, also run `npm run test:e2e` (it exercises the +ghostty-web/Playwright path that pulls vite/esbuild/ws). If e2e can't run here, +note that in your report. + +**Verify**: typecheck, build, and unit tests all green. + +### Step 4: Add the `audit` mise task + +In `mise.toml`, add a task (place it near `[tasks.lint]`): + +```toml +[tasks.audit] +description = "Fail on high-severity dependency advisories" +run = "aube audit --audit-level high" +``` + +Then add `mise run audit` to the `[tasks.ci]` chain — put it right after +`mise run lint`: + +```toml +[tasks.ci] +description = "Run CI checks" +run = "mise run format-check && mise run workflow-lint && mise run lint && mise run audit && mise run typecheck && mise run test && mise run build && mise run install-smoke" +``` + +**Verify**: `mise run audit` → exit 0 (matches Step 2's clean high-level audit). + +### Step 5: Wire the audit into CI + +In `.github/workflows/ci.yml`, in the **`linux-static`** job, add a step after +the existing "Lint" step (`run: mise run lint`): + +```yaml +- name: Audit dependencies + run: mise run audit +``` + +Keep the file hand-curated (don't regenerate it). Do not touch any other job. + +**Verify**: `mise run workflow-lint` → exit 0 (actionlint + zizmor accept the +new step). + +## Test plan + +This change is config/dependency only; the "tests" are the audit and build +gates themselves: + +- `aube audit --audit-level high` → 0 high/critical. +- `mise run audit` → exit 0. +- `npm run typecheck && npm run build && npm run test:unit` → all green + (proves the forced transitive versions are compatible). +- `mise run workflow-lint` → exit 0 (proves the CI edit is valid). + +No new unit test file is required. + +## Done criteria + +ALL must hold: + +- [ ] `aube audit --audit-level high` reports 0 high and 0 critical advisories. +- [ ] `grep -n "tasks.audit" mise.toml` and `grep -n "mise run audit" mise.toml` + both match (task defined and in the `ci` chain). +- [ ] `grep -n "Audit dependencies" .github/workflows/ci.yml` matches, under the + `linux-static` job. +- [ ] `mise run workflow-lint` exits 0. +- [ ] `npm run typecheck`, `npm run build`, `npm run test:unit` all exit 0. +- [ ] No `src/` files modified; no `CHANGELOG.md` change (`git status`). +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report back (do not improvise) if: + +- Clearing a **high** advisory is impossible via `--fix=update` / `--fix=override` + and would require a major bump of a direct dependency (`playwright`, `ink`, + `vitest`, `ghostty-web`) — report the residual advisory and its reachability; + the maintainer decides whether to gate at `critical` instead or accept the risk. +- After overrides, `npm run build` or `npm run test:unit` fails and a quick, + in-range version adjustment doesn't fix it (a forced version is incompatible). +- `aube audit` is unavailable in your environment (e.g. `aube` not installed) — + do not substitute `npm audit` (the repo has no `package-lock.json`; `npm audit` + errors with ENOLOCK here). Report instead. +- The live advisory set is wildly different from the table above (e.g. a new + critical in a direct dependency) — surface it rather than silently fixing. + +## Maintenance notes + +- The gate is set at `high` deliberately: it blocks the genuinely actionable + advisories without making CI hostage to every low-signal transitive moderate. + If the team wants moderates gated too, change `--audit-level high` to + `moderate` once the current moderates (brace-expansion ReDoS, ws uninitialized + memory) are also cleared. +- `--fix=override` pins transitive versions in `package.json`. When the upstream + direct deps catch up to patched transitives, those overrides can be removed — + a reviewer should periodically check whether the overrides block is still + needed (`aube audit` after deleting it). +- A reviewer should confirm the audit step landed only in `linux-static`, not in + `quality-gates-macos` (which intentionally installs a reduced toolset). +- Reachability context for the PR description: these advisories are in + build/tooling and non-attacker-facing runtime paths; the value is the gate and + hygiene, not an active-exploit fix. State that honestly. diff --git a/plans/003-fix-release-contract-version.md b/plans/003-fix-release-contract-version.md new file mode 100644 index 0000000..9c0c982 --- /dev/null +++ b/plans/003-fix-release-contract-version.md @@ -0,0 +1,155 @@ +# Plan 003: RELEASE.md no longer claims a stale "0.2.x" release line + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat c11e2e2..HEAD -- RELEASE.md package.json` +> If `RELEASE.md` changed since this plan was written, compare the "Current +> state" excerpt against the live file before editing; on a mismatch, treat it +> as a STOP condition. + +## Status + +- **Priority**: P2 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: docs +- **Planned at**: commit `c11e2e2`, 2026-06-16 + +## Why this matters + +`RELEASE.md` is the user-facing **support contract** — `README.md` links to it +("The supported contract is in `RELEASE.md`"). Its opening still says the +document covers the "current `0.2.x` release line" and calls `0.2.0` "the first +stable cut", but the product is at `0.4.3` (`package.json`) and the project now +releases via release-please, which bumps the version automatically. A reader +checking what's supported sees a version line that is two minor releases stale. +The body of the contract is capability-based and still accurate; only the +version framing in the first two lines is wrong. Making that framing +**version-agnostic** fixes the drift and prevents it from recurring on the next +release-please bump. + +## Current state + +**`RELEASE.md:1-7`** (the only stale part — the rest of the file is +capability-based and correct): + +```markdown +# agent-tty release contract + +This document defines the supported product contract for the current `0.2.x` release line. +The `0.1.x` beta line established the baseline for isolated, reviewable terminal automation for real TUI workflows, and `0.2.0` is the first stable cut on top of that baseline; later `0.2.x` releases may add compatible fixes and features without widening this core support contract. +If a workflow depends on behavior outside this document, treat it as future-scope or best-effort rather than a guaranteed capability. + +For per-release changes, see [`CHANGELOG.md`](./CHANGELOG.md). For release mechanics, use [`docs/RELEASE-PROCESS.md`](./docs/RELEASE-PROCESS.md). For reviewer-facing proof bundles, start with [`dogfood/CATALOG.md`](./dogfood/CATALOG.md). +``` + +`package.json:3` is `"version": "0.4.3"`. The linked files +`docs/RELEASE-PROCESS.md`, `CHANGELOG.md`, and `dogfood/CATALOG.md` all exist +(verified) — **do not** change those links. + +The rest of `RELEASE.md` (the "Supported capabilities", "Explicitly out of +scope", "Known limitations", "Validation" sections, lines 9-39) is accurate and +**must not change** — note that line 20 already correctly references the shipped +`libghostty-vt` semantic renderer. + +### Conventions to follow + +- Markdown prose; oxfmt formats `*.md` (see `mise.toml` `format-check` sources), + so run the formatter after editing. +- Keep the contract **capability-based and version-agnostic** so release-please + version bumps don't re-stale it. Do not hardcode `0.4.x` (it would drift + again); describe the contract without pinning a release-line number. + +## Commands you will need + +| Purpose | Command | Expected on success | +| ------------ | ---------------------- | ------------------- | +| Format (fix) | `npm run format` | exit 0 | +| Format check | `npm run format:check` | exit 0 | + +## Scope + +**In scope**: + +- `RELEASE.md` — only lines 3-4 (the version framing). + +**Out of scope**: + +- The capability/limitation/validation sections of `RELEASE.md` (lines 9-39). +- The links on line 7 (all targets exist). +- `README.md`, `CHANGELOG.md` (automation-owned), `package.json`, and any + release workflow. + +## Git workflow + +- Branch: `advisor/003-fix-release-contract-version` +- Conventional Commits. Example: `docs: make the RELEASE.md support contract version-agnostic`. +- Do NOT push or open a PR unless instructed. + +## Steps + +### Step 1: Make the opening version-agnostic + +Replace lines 3-4 of `RELEASE.md` with version-agnostic phrasing. Target text +(keep line 5 — "If a workflow depends…" — and everything below unchanged): + +```markdown +This document defines the supported product contract for the current stable release line. +It builds on the `0.1.x` beta baseline for isolated, reviewable terminal automation of real TUI workflows; later stable releases may add compatible fixes and features without widening this core support contract. +``` + +(The exact wording can vary, but it must not name a specific `0.2.x`/`0.x` +"current" release line. The first stable baseline reference to `0.1.x` is +historically accurate and fine to keep.) + +**Verify**: `grep -n "0.2" RELEASE.md` → returns nothing (no remaining `0.2.x` +/ `0.2.0` references). + +### Step 2: Format + +Run `npm run format`, then `npm run format:check` → exit 0. + +**Verify**: `npm run format:check` → exit 0. + +## Test plan + +No code; the checks are: + +- `grep -n "0\.2\.[0-9x]" RELEASE.md` → no matches. +- `npm run format:check` → exit 0. +- Manual read: lines 9-39 are unchanged from the current file. + +## Done criteria + +ALL must hold: + +- [ ] `grep -nE "0\.2\.[0-9x]" RELEASE.md` returns no matches. +- [ ] `RELEASE.md` no longer contains the phrase "first stable cut" tied to a + version (or any "current `0.x.y` release line" claim). +- [ ] `npm run format:check` exits 0. +- [ ] Only `RELEASE.md` is modified (`git status`); no `CHANGELOG.md` change. +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report back if: + +- `RELEASE.md`'s opening no longer matches the excerpt above (it was already + edited). +- Any of the links on line 7 point to a file that no longer exists + (`ls docs/RELEASE-PROCESS.md CHANGELOG.md dogfood/CATALOG.md`) — that's a + separate doc-rot finding; report it, don't fix it here. + +## Maintenance notes + +- Keeping the contract version-agnostic means future release-please bumps won't + re-stale this file. If the team later wants an explicit version stamp, the + durable way is a release-please-managed marker (like the + `` comment used in `README.md`) rather than + hand-edited prose — that's a deliberate follow-up, not part of this plan. diff --git a/plans/004-hostmain-characterization-tests.md b/plans/004-hostmain-characterization-tests.md new file mode 100644 index 0000000..89c6a0f --- /dev/null +++ b/plans/004-hostmain-characterization-tests.md @@ -0,0 +1,272 @@ +# Plan 004: hostMain's pure decision helpers and the idle-timeout path are covered by tests + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat c11e2e2..HEAD -- src/host/hostMain.ts test/unit/host/hostMain.test.ts` +> If `src/host/hostMain.ts` changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P2 +- **Effort**: M +- **Risk**: LOW +- **Depends on**: none +- **Category**: tests +- **Planned at**: commit `c11e2e2`, 2026-06-16 + +## Why this matters + +`src/host/hostMain.ts` (1094 lines) is the per-session orchestration core — it +owns the PTY, event log, renderer polling, RPC dispatch, idle timeout, and +shutdown. Its entire unit test today is **9 lines** asserting one exported +constant (`test/unit/host/hostMain.test.ts`). The happy path is exercised +indirectly by integration/e2e tests (which run the real CLI), but the file's +**decision helpers** — exit-signal normalization, the commandability predicate +that gates every input/control RPC, and renderer-name resolution with its +env/default fallback — have no targeted tests, and one observable orchestration +branch (idle-timeout auto-exit) has no dedicated coverage. These are exactly the +small, branch-y functions where a regression slips through "the integration +test still passed". Characterizing them now pins the current behavior and makes +later refactors safe. + +## Current state + +`src/host/hostMain.ts` is one large `runHost(sessionId)` function with inner +closures, plus a handful of **module-level pure helpers** near the top. Only +`MAX_CONSECUTIVE_POLL_FAILURES` is currently exported: + +```ts +// src/host/hostMain.ts +export const MAX_CONSECUTIVE_POLL_FAILURES = 10; // line 77 + +function normalizeExitSignal(signal: number | null): string | null { + // line 87 + invariant( + signal === null || (Number.isInteger(signal) && signal >= 0), + 'PTY exit signal must be a non-negative integer or null', + ); + return signal === null || signal === 0 ? null : String(signal); +} + +function isSessionCommandable(state: SessionState): boolean { + // line 96 + return isCommandableSessionStatus(state.snapshot().status); +} + +function assertSessionCommandable(state: SessionState): void { + // line 100 + if (!isSessionCommandable(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } +} + +function resolveHostRendererName(input: string | undefined): RendererName { + // line 116 + const rawRenderer = + input ?? + process.env[HOST_RENDERER_ENV_KEY] ?? + process.env.AGENT_TTY_RENDERER ?? + DEFAULT_RENDERER_NAME; + try { + return resolveRendererName(rawRenderer); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Renderer must be one of: ghostty-web, libghostty-vt.', + details: { renderer: rawRenderer }, + cause: error, + }); + } +} +``` + +Relevant imports already in `hostMain.ts`: + +- `SessionState` from `./sessionState.js` (line 15) +- `isCommandableSessionStatus` from `../protocol/sessionStatusPolicy.js` (line 20) + — a pure predicate: `isCommandableSessionStatus(status: SessionStatus): boolean` + (`src/protocol/sessionStatusPolicy.ts:111`). Commandable statuses are the + `running`-family per `CONTEXT.md` ("A `running` Session is Commandable"; an + `exiting`/`destroying`/terminal Session is not). +- `resolveRendererName`, `DEFAULT_RENDERER_NAME`, `RendererName` (lines 42-44), + `HOST_RENDERER_ENV_KEY` (line 40), `ERROR_CODES`/`makeCliError` (line 19). + +### Conventions to follow + +- Tests use **vitest** (`describe`/`it`/`expect`). See the existing host tests + in `test/unit/host/` for structure — `runCompletionCoordinator.test.ts` and + `eventLog.test.ts` are substantial, idiomatic examples. +- To build a `SessionState` test double for the commandability tests, **model on + `test/unit/commands/gc.test.ts`**, which already constructs `SessionState` + instances — reuse that exact construction shape rather than inventing one. +- Asserting a thrown `CliError`: check the `.code` against `ERROR_CODES` (e.g. + `ERROR_CODES.SESSION_NOT_RUNNING`, `ERROR_CODES.INVALID_INPUT`). Look at an + existing test that asserts on a thrown `CliError` for the pattern. +- When a test mutates `process.env`, save and restore it (`beforeEach`/ + `afterEach`) so it doesn't leak into other tests. +- Strict TS, NodeNext ESM, `.js` import extensions, `import type` for types. +- Exporting an internal helper purely for testing is an accepted pattern here — + `MAX_CONSECUTIVE_POLL_FAILURES` is already exported for exactly that reason. + +## Commands you will need + +| Purpose | Command | Expected | +| --------------------- | ------------------------------------------------ | -------- | +| Typecheck | `npm run typecheck` | exit 0 | +| Lint | `npm run lint` | exit 0 | +| Run the new unit test | `npx vitest run test/unit/host/hostMain.test.ts` | all pass | +| Unit suite | `npm run test:unit` | all pass | +| Integration suite | `npm run test:integration` | all pass | + +## Scope + +**In scope**: + +- `src/host/hostMain.ts` — add `export` to the four pure helpers only + (`normalizeExitSignal`, `isSessionCommandable`, `assertSessionCommandable`, + `resolveHostRendererName`). No logic changes. +- `test/unit/host/hostMain.test.ts` — expand with the new unit tests. +- `test/integration/` — one new test for the idle-timeout path (Step 3), or a + case added to `test/integration/lifecycle.test.ts`. + +**Out of scope**: + +- Any behavior change in `hostMain.ts`. This plan only **adds `export`** and + **adds tests**. If you find yourself changing logic, stop. +- Refactoring `runHost` or extracting the inner closures (that is plan 006's + territory, and not required here). +- `CHANGELOG.md` (automation-owned). +- The protocol schemas / CLI envelopes. + +## Git workflow + +- Branch: `advisor/004-hostmain-characterization-tests` +- Conventional Commits. Example: `test: characterize hostMain decision helpers and idle-timeout exit`. +- Do NOT push or open a PR unless instructed. + +## Steps + +### Step 1: Export the four pure helpers + +In `src/host/hostMain.ts`, add the `export` keyword to `normalizeExitSignal` +(line 87), `isSessionCommandable` (96), `assertSessionCommandable` (100), and +`resolveHostRendererName` (116). Change nothing else. + +**Verify**: `npm run typecheck` → exit 0. `npm run lint` → exit 0. + +### Step 2: Unit-test the helpers + +Rewrite `test/unit/host/hostMain.test.ts` to keep the existing +`MAX_CONSECUTIVE_POLL_FAILURES` assertion and add `describe` blocks: + +- **`normalizeExitSignal`**: + - `null` → `null` + - `0` → `null` + - `9` → `'9'`, `15` → `'15'` + - a negative or non-integer signal → throws (invariant). Assert it throws. +- **`isSessionCommandable` / `assertSessionCommandable`** (build `SessionState` + per `test/unit/commands/gc.test.ts`): + - a `running` SessionState → `isSessionCommandable` is `true`; + `assertSessionCommandable` does not throw. + - a terminal/`exited` (and an `exiting`) SessionState → `isSessionCommandable` + is `false`; `assertSessionCommandable` throws a `CliError` with code + `ERROR_CODES.SESSION_NOT_RUNNING` and message `'Session is not running.'`. +- **`resolveHostRendererName`** (save/restore `process.env` around each case): + - explicit input `'libghostty-vt'` → resolves to that name. + - input `undefined` with `HOST_RENDERER_ENV_KEY` set → resolves from the env var. + - input `undefined`, no env → resolves to `DEFAULT_RENDERER_NAME`. + - an invalid name (e.g. `'nope'`) → throws a `CliError` with code + `ERROR_CODES.INVALID_INPUT`. + +**Verify**: `npx vitest run test/unit/host/hostMain.test.ts` → all pass +(the original constant test plus the new ones). + +### Step 3: Integration-test the idle-timeout exit branch + +`create` exposes `--idle-timeout-ms ` (`src/cli/main.ts:326`). Add a test +(model on `test/integration/lifecycle.test.ts`, which already drives `create`/ +`inspect`/`destroy` against an isolated absolute `AGENT_TTY_HOME`): + +- Create a session with a small idle timeout (pick a value comfortably above the + internal idle-check cadence — note `IDLE_CHECK_CAP_MS = 5_000` in + `hostMain.ts`, so the poll cadence is bounded at 5s; choose a timeout and a + wait that are robust to that, e.g. a timeout of a few hundred ms and then poll + `inspect` until the status is terminal, with a generous overall deadline). +- Assert the session reaches a terminal status (`exited`) via `inspect --json` + without any further input. +- Use the same isolated-home setup/teardown as the neighboring tests; never + touch the real `~/.agent-tty`. + +If the idle-timeout behavior is not cleanly observable via `inspect` within a +reasonable, non-flaky wait, **stop and report** (see STOP conditions) rather +than adding a sleep-and-hope test — the unit tests in Step 2 are the required +core; this integration test is the bonus branch. + +**Verify**: `npx vitest run test/integration/.test.ts` → passes. +Run it a second time to confirm it is not flaky. + +### Step 4: Full static + suites + +`npm run lint`, `npm run typecheck`, `npm run test:unit`, then +`npm run test:integration` → all green. + +## Test plan + +- New unit cases (Step 2): `normalizeExitSignal` (4+ cases incl. throw), + commandability predicate + assertion (running / exiting / terminal), + renderer-name resolution (explicit / env / default / invalid-throws). +- New integration case (Step 3): idle-timeout auto-exit observed via `inspect`. +- Structural patterns: unit → model on `test/unit/host/runCompletionCoordinator.test.ts` + and `test/unit/commands/gc.test.ts` (for `SessionState`); integration → model + on `test/integration/lifecycle.test.ts`. +- Verification: `npm run test:unit` and `npm run test:integration` both pass, + including the new cases. + +## Done criteria + +ALL must hold: + +- [ ] `grep -nE "^export function (normalizeExitSignal|isSessionCommandable|assertSessionCommandable|resolveHostRendererName)" src/host/hostMain.ts` → 4 matches. +- [ ] `npx vitest run test/unit/host/hostMain.test.ts` passes with the new cases + (and still asserts `MAX_CONSECUTIVE_POLL_FAILURES === 10`). +- [ ] `npm run test:unit` and `npm run test:integration` exit 0. +- [ ] `npm run typecheck` and `npm run lint` exit 0. +- [ ] `git diff src/host/hostMain.ts` shows **only** added `export` keywords (no + logic change). +- [ ] No `CHANGELOG.md` change; no files outside scope modified (`git status`). +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report back if: + +- The four helpers are no longer at the cited lines / no longer match the + excerpts (drift). +- Building a `SessionState` for the commandability tests requires more than the + shape used in `test/unit/commands/gc.test.ts` (e.g. a live PTY) — report and + scope those two cases out rather than constructing a heavy fake. +- The idle-timeout integration test (Step 3) can only be made to pass with a + fixed `sleep` and is flaky on a second run — drop Step 3, keep Steps 1–2, and + report that Step 3 needs a deterministic hook. +- Asserting a thrown `CliError`'s `.code` doesn't work as described (the error + shape differs) — report the actual shape. + +## Maintenance notes + +- These are characterization tests: they pin **current** behavior. If a future + change intentionally alters, say, commandability rules, the test should be + updated deliberately in the same change — a failure here on an unrelated PR is + a real regression signal. +- The deeper orchestration branches inside `runHost` (renderer-poll-failure + recovery, shutdown reconciliation, concurrent-wait handling) remain + unit-untestable without extracting them from the closure. That extraction is + deliberately **not** in this plan; it's a candidate follow-up that would pair + well with plan 006's refactoring approach. diff --git a/plans/005-dedupe-replay-event-iteration.md b/plans/005-dedupe-replay-event-iteration.md new file mode 100644 index 0000000..7eb9ffe --- /dev/null +++ b/plans/005-dedupe-replay-event-iteration.md @@ -0,0 +1,437 @@ +# Plan 005: Both renderer backends iterate replay events through one shared, tested helper + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat c11e2e2..HEAD -- src/renderer/ghosttyWeb/backend.ts src/renderer/libghosttyVt/backend.ts` +> If either backend changed since this plan was written, compare the "Current +> state" excerpts against the live code before proceeding; on a mismatch, treat +> it as a STOP condition. + +## Status + +- **Priority**: P2 +- **Effort**: M +- **Risk**: MED +- **Depends on**: none +- **Category**: tech-debt +- **Planned at**: commit `c11e2e2`, 2026-06-16 + +## Why this matters + +The two renderer backends — `ghosttyWeb` (visual/Playwright) and `libghosttyVt` +(native semantic) — each re-implement the **same** replay-event iteration: +validate each event's sequence is a non-negative integer, enforce strictly +increasing order, skip events already applied (`seq <= lastAppliedSeq`), stop at +the target (`seq > targetSeq`), then dispatch on event type. This logic is +correctness-critical: per the architecture's tiered-truth model, both backends +must converge on the **same** visible screen content and Screen Hash for the +same event log. The two copies have **already drifted** — different assertion +messages, and `ghosttyWeb` flushes its output batch before breaking while +`libghosttyVt` just breaks — which is exactly how a subtle divergence creeps in. +Extracting the shared iteration into one tested helper removes the duplication +and gives the seq/ordering invariants a single home. + +## Current state + +Both `replayTo` methods contain a near-identical loop. The shared part is the +**per-event validation + filtering scaffolding**; each backend keeps its own +_feed_ strategy (ghosttyWeb batches output and awaits async bridge calls; +libghosttyVt feeds synchronously). + +**`src/renderer/ghosttyWeb/backend.ts:1602-1664`** (async, batched output; +note the flush at 1618 before the targetSeq break and the unconditional flush at +1664 after the loop): + +```ts +for (const event of input.events) { + assertNonNegativeInteger( + event.seq, + 'replay event seq must be a non-negative integer', + ); + invariant( + event.seq > previousEventSeq, + 'replay events must be ordered by strictly increasing seq values', + ); + previousEventSeq = event.seq; + + if (event.seq <= this.lastAppliedSeq) { + continue; + } + if (event.seq > input.targetSeq) { + await flushOutputBatch(); + break; + } + + switch (event.type) { + case 'output': { + pendingOutputChunks.push(event.payload.data); + break; + } + case 'resize': { + await flushOutputBatch(); + /* assert + resizeBridge + set cols/rows */ break; + } + case 'marker': { + await flushOutputBatch(); + break; + } + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'input_run': + case 'run_complete': + case 'signal': + case 'exit': { + await flushOutputBatch(); + break; + } + default: { + unreachable(event, 'unsupported replay event type'); + } + } + highestProcessedSeq = event.seq; +} + +await flushOutputBatch(); // line 1664 — flushes any pending output after the loop +``` + +**`src/renderer/libghosttyVt/backend.ts:486-535`** (synchronous feed): + +```ts +for (const event of input.events) { + assertNonNegativeInteger(event.seq, 'replay event seq must be non-negative'); + invariant( + event.seq > previousEventSeq, + 'replay events must be ordered by strictly increasing seq values', + ); + previousEventSeq = event.seq; + + if (event.seq <= this.lastAppliedSeq) { + continue; + } + if (event.seq > input.targetSeq) { + break; + } + + switch (event.type) { + case 'output': + terminal.feed(event.payload.data); + break; + case 'resize': + /* assert + terminal.resize + set cols/rows */ break; + case 'marker': + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'input_run': + case 'run_complete': + case 'signal': + case 'exit': + break; + default: + unreachable(event, 'unsupported replay event type'); + } + highestProcessedSeq = event.seq; +} +``` + +Both surround the loop with: `let previousEventSeq = -1; let highestProcessedSeq += this.lastAppliedSeq;` before, and after the loop the +`if (highestProcessedSeq < 0) { highestProcessedSeq = input.targetSeq; }` + +`this.lastAppliedSeq = highestProcessedSeq;` sequence. + +**Critical detail — `previousEventSeq` is updated BEFORE the skip/stop checks** +in both, so the strictly-increasing invariant covers _every_ event, including +skipped ones. The shared helper must preserve that exact order. + +### Types and conventions + +- `ReplayInput` is exported from `src/renderer/types.ts`. The event element type + is `ReplayInput['events'][number]` — use that indexed type so you don't depend + on the exact exported name. +- `invariant` is in `src/util/assert.ts`. `unreachable(value, message)` (same + module) is used for the exhaustive `default` — leave each backend's `default: +unreachable(...)` in place; it belongs with the type switch, not the iterator. +- Strict TS, NodeNext ESM, `.js` import extensions, `import type` for types. +- 2-space indent, single quotes, trailing commas (oxfmt enforces). + +### Design constraint (honor this) + +From `design/ARCHITECTURE.md` (tiered-truth model): semantic-renderer truth and +reference-visual truth must agree on visible content. The `CONTEXT.md` Screen +Hash term states the Screen Hash is computed from the same canonical visible +text the stability check and text waits use, "so the three never disagree." Any +change to replay iteration must keep both backends producing identical visible +content for the same events — that is what `test/integration/screen-hash.test.ts` +guards. + +## Commands you will need + +| Purpose | Command | Expected | +| ------------------- | ----------------------------------------------------------------- | -------- | +| Typecheck | `npm run typecheck` | exit 0 | +| Lint | `npm run lint` | exit 0 | +| Replay unit tests | `npx vitest run test/unit/host/replay.test.ts test/unit/renderer` | all pass | +| New helper test | `npx vitest run test/unit/renderer/replayEvents.test.ts` | all pass | +| Screen-hash conv. | `npx vitest run test/integration/screen-hash.test.ts` | all pass | +| e2e (cross-backend) | `npm run test:e2e` | all pass | + +## Scope + +**In scope**: + +- `src/renderer/replayEvents.ts` (create) — the shared iterator helper. +- `src/renderer/ghosttyWeb/backend.ts` — use the helper in `replayTo`. +- `src/renderer/libghosttyVt/backend.ts` — use the helper in `replayTo`. +- `test/unit/renderer/replayEvents.test.ts` (create) — unit-test the helper. + +**Out of scope** (do NOT change behavior here): + +- The output-feed strategy in either backend (ghosttyWeb's batching + + `flushOutputBatch`; libghosttyVt's synchronous `terminal.feed`). Keep every + existing `await flushOutputBatch()` call site in ghosttyWeb intact. +- The `highestProcessedSeq` tracking and the `< 0 → targetSeq` fallback and + `this.lastAppliedSeq = …` assignment — leave these in each backend unchanged. +- The pre-loop input validation (`assertPositiveInteger` on initial dims, + `targetSeq` checks, the no-rewind invariant with its backend-specific message). +- `replayWithTiming` (ghosttyWeb) — only `replayTo` is in scope. +- `CHANGELOG.md` (automation-owned). + +## Git workflow + +- Branch: `advisor/005-dedupe-replay-event-iteration` +- Conventional Commits. Example: `refactor: share replay-event iteration across renderer backends`. +- Do NOT push or open a PR unless instructed. + +## Steps + +### Step 1: Create the shared iterator + +Create `src/renderer/replayEvents.ts`: + +```ts +import type { ReplayInput } from './types.js'; +import { invariant } from '../util/assert.js'; + +type ReplayEvent = ReplayInput['events'][number]; + +/** + * Yield the replay events that fall in the half-open range + * (lastAppliedSeq, targetSeq], in order. Enforces the seq invariants shared by + * every renderer backend: each event seq is a non-negative integer, seqs are + * strictly increasing across ALL events (including skipped ones), events at or + * below lastAppliedSeq are skipped, and iteration stops at the first event + * beyond targetSeq. Callers dispatch on event.type and own how output is fed. + */ +export function* iterateInRangeReplayEvents( + input: ReplayInput, + lastAppliedSeq: number, +): Generator { + let previousEventSeq = -1; + for (const event of input.events) { + invariant( + Number.isInteger(event.seq) && event.seq >= 0, + 'replay event seq must be a non-negative integer', + ); + invariant( + event.seq > previousEventSeq, + 'replay events must be ordered by strictly increasing seq values', + ); + previousEventSeq = event.seq; + + if (event.seq <= lastAppliedSeq) { + continue; + } + if (event.seq > input.targetSeq) { + return; + } + yield event; + } +} +``` + +**Verify**: `npm run typecheck` → exit 0. + +### Step 2: Use the helper in `libghosttyVt` (the simpler, synchronous backend first) + +In `src/renderer/libghosttyVt/backend.ts` `replayTo`, replace the +`for (const event of input.events) { …validation…; if skip; if break; switch }` +loop with: + +```ts +for (const event of iterateInRangeReplayEvents(input, this.lastAppliedSeq)) { + switch (event.type) { + case 'output': + terminal.feed(event.payload.data); + break; + case 'resize': + assertPositiveInteger( + event.payload.cols, + 'resize event cols must be positive', + ); + assertPositiveInteger( + event.payload.rows, + 'resize event rows must be positive', + ); + terminal.resize(event.payload.cols, event.payload.rows); + this.currentCols = event.payload.cols; + this.currentRows = event.payload.rows; + break; + case 'marker': + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'input_run': + case 'run_complete': + case 'signal': + case 'exit': + break; + default: + unreachable(event, 'unsupported replay event type'); + } + highestProcessedSeq = event.seq; +} +``` + +Keep `let previousEventSeq = -1;` removed only if it is now unused (the helper +owns it) — delete the now-dead `previousEventSeq` declaration and assignment. +Keep `highestProcessedSeq` and everything after the loop unchanged. Add the +import: `import { iterateInRangeReplayEvents } from '../replayEvents.js';`. + +**Verify**: `npm run typecheck` → exit 0; +`npx vitest run test/unit/host/replay.test.ts test/unit/renderer` → all pass. + +### Step 3: Use the helper in `ghosttyWeb` (preserve every flush) + +In `src/renderer/ghosttyWeb/backend.ts` `replayTo`, replace the loop the same +way, **keeping all `await flushOutputBatch()` calls inside the switch and the +one after the loop (current line 1664)**. The targetSeq break previously flushed +then broke (line 1618); the helper now stops iteration at that boundary and the +post-loop `await flushOutputBatch()` (1664) flushes any pending output — net +behavior identical. Result: + +```ts +for (const event of iterateInRangeReplayEvents(input, this.lastAppliedSeq)) { + switch (event.type) { + case 'output': { + pendingOutputChunks.push(event.payload.data); + break; + } + case 'resize': { + await flushOutputBatch(); + assertPositiveInteger( + event.payload.cols, + 'resize event cols must be a positive integer', + ); + assertPositiveInteger( + event.payload.rows, + 'resize event rows must be a positive integer', + ); + await this.resizeBridge(page, event.payload.cols, event.payload.rows); + this.currentCols = event.payload.cols; + this.currentRows = event.payload.rows; + break; + } + case 'marker': { + await flushOutputBatch(); + break; + } + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'input_run': + case 'run_complete': + case 'signal': + case 'exit': { + await flushOutputBatch(); + break; + } + default: { + unreachable(event, 'unsupported replay event type'); + } + } + highestProcessedSeq = event.seq; +} + +await flushOutputBatch(); +``` + +Delete the now-dead `previousEventSeq` declaration. Add the import: +`import { iterateInRangeReplayEvents } from '../replayEvents.js';`. Leave +`highestProcessedSeq`, the `< 0 → targetSeq` fallback, snapshot read, and return +unchanged. + +**Verify**: `npm run typecheck` → exit 0. + +### Step 4: Cross-backend behavior gates + +Run the convergence and visual suites — these are the real safety net: + +- `npx vitest run test/integration/screen-hash.test.ts` → all pass (both + backends still produce the same canonical visible text / Screen Hash). +- `npm run test:e2e` → all pass (rendered output, screenshots, casts unchanged). + If e2e cannot run in this environment, say so explicitly in your report. + +**Verify**: screen-hash convergence and e2e both green. + +## Test plan + +- New unit test `test/unit/renderer/replayEvents.test.ts` for + `iterateInRangeReplayEvents`, covering: + - all events in range → yields all, in order. + - events with `seq <= lastAppliedSeq` → skipped (not yielded). + - an event with `seq > targetSeq` → iteration stops there (it and everything + after are not yielded). + - out-of-order seq (e.g. `[0,2,1]`) → throws the strictly-increasing invariant. + - a negative / non-integer seq → throws the non-negative-integer invariant. + - the strictly-increasing check fires even when the offending event would be + skipped (e.g. lastAppliedSeq high, but a later event repeats an earlier seq). +- Regression coverage is the existing `test/unit/host/replay.test.ts`, + `test/unit/renderer/*`, `test/integration/screen-hash.test.ts`, and e2e — they + must stay green with no edits. + +## Done criteria + +ALL must hold: + +- [ ] `src/renderer/replayEvents.ts` exists and exports `iterateInRangeReplayEvents`. +- [ ] Both backends' `replayTo` import and use it; + `grep -n "iterateInRangeReplayEvents" src/renderer/ghosttyWeb/backend.ts src/renderer/libghosttyVt/backend.ts` → 2+ matches. +- [ ] `grep -n "previousEventSeq" src/renderer/ghosttyWeb/backend.ts src/renderer/libghosttyVt/backend.ts` → no matches (dead declarations removed). +- [ ] `npm run typecheck` and `npm run lint` exit 0. +- [ ] `npx vitest run test/unit/renderer/replayEvents.test.ts` passes (new cases). +- [ ] `npx vitest run test/unit/host/replay.test.ts test/unit/renderer` passes. +- [ ] `npx vitest run test/integration/screen-hash.test.ts` passes. +- [ ] `npm run test:e2e` passes (or its inability to run here is reported). +- [ ] No `CHANGELOG.md` change; no out-of-scope files modified (`git status`). +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report back (do not improvise) if: + +- Either backend's loop no longer matches the "Current state" excerpts (drift). +- `test/integration/screen-hash.test.ts` fails after the change — that means the + backends diverged; do NOT adjust the test to pass. Report the diff. +- Removing a flush or reordering one in ghosttyWeb seems necessary to make the + helper fit — it isn't; keep every flush. If you can't preserve them, stop. +- e2e output (screenshots/casts) changes — that's a behavior regression, not a + refactor; report it. + +## Maintenance notes + +- The helper is the single home for replay seq/ordering invariants. Future event + types must be added to each backend's `switch` (the exhaustive `default: +unreachable` will flag a missing case at type-check time) — the iterator does + not need changes for new event types. +- A reviewer should confirm: (1) `previousEventSeq` updates before the skip in + the helper (covers skipped events); (2) ghosttyWeb still flushes after the loop; + (3) `lastAppliedSeq`/`highestProcessedSeq` math is untouched in both backends. +- Deferred: the pre-loop input validation and the `highestProcessedSeq` fallback + are also duplicated but were intentionally left in place to bound this change's + risk; consolidating them is a possible follow-up once this lands cleanly. diff --git a/plans/006-split-ghostty-web-backend.md b/plans/006-split-ghostty-web-backend.md new file mode 100644 index 0000000..547bc84 --- /dev/null +++ b/plans/006-split-ghostty-web-backend.md @@ -0,0 +1,263 @@ +# Plan 006: Extract the harness HTML and harness-decoding layer out of the ghostty-web backend god file + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat c11e2e2..HEAD -- src/renderer/ghosttyWeb/backend.ts` +> If `backend.ts` changed since this plan was written, re-locate the symbols by +> name (line numbers below will have shifted) and confirm they still match the +> descriptions before proceeding; on a structural mismatch, treat it as a STOP +> condition. + +## Status + +- **Priority**: P3 +- **Effort**: L +- **Risk**: MED +- **Depends on**: none (the existing renderer unit + e2e tests are the safety net) +- **Category**: tech-debt +- **Planned at**: commit `c11e2e2`, 2026-06-16 + +## Why this matters + +`src/renderer/ghosttyWeb/backend.ts` is **2814 lines** — by far the largest file +in the repo — and mixes five unrelated concerns in one module: a ~790-line +embedded HTML/JS harness string, the harness-snapshot decoding/validation layer, +generic assertion helpers, a local HTTP server, and the renderer class itself. +That makes it hard to navigate, hard to test in isolation, and hard for an agent +to change safely. This plan removes the two **safe, high-value** chunks — the +embedded harness HTML and the harness-decoding free functions — into sibling +modules, cutting the file roughly in half. It deliberately does **not** touch the +HTTP-server/bridge class methods (which hold `this` state and need a more careful +refactor); those are a separate follow-up. No runtime behavior changes: this is a +pure move-and-reimport. + +## Current state + +`backend.ts` layout (line numbers approximate — locate by symbol name): + +- **48–110**: interfaces. Of these, the _snapshot_ interfaces move; the + _server/bridge_ interfaces stay (see Scope): + - move: `GhosttyHarnessVisibleLine` (48), `GhosttyHarnessSnapshotCell` (53), + `GhosttyHarnessRichLine` (63), `GhosttyHarnessSnapshot` (68). + - stay (server/bridge concern): `GhosttyRequestAsset` (79), + `GhosttyServedAsset` (84), `GhosttyBrowserBridge` (88), + `GhosttyBrowserGlobal` (97). +- **111–138**: constants (`DEFAULT_PAGE_VIEWPORT`, content-type strings, + `HARNESS_CONTENT_SECURITY_POLICY`, `MAX_REPLAY_BATCH_SIZE`, `RAF_TIMEOUT_MS`) — + **stay** (server/replay concern). +- **140–929**: `const EMBEDDED_HARNESS_HTML = \`…\`;`— the ~790-line embedded +harness document. **Move (Step 1).** Its only consumer is`loadHarnessHtml`(line 1065:`return EMBEDDED_HARNESS_HTML;`). Two comments (≈942, ≈972) + reference it by name; they remain accurate after the move. +- **931–1058**: decoding helpers + generic assertions. **Move (Step 2):** + `GhosttyDecodedColumn` (931, exported), `stripTrailingAsciiSpaces` (945, + exported), `assembleCanonicalLine` (974, exported), `assertNonNegativeInteger` + (998), `assertPositiveInteger` (1008), `assertPositiveNumber` (1018), + `assertHexColor` (1028), `normalizeError` (1036). +- **1061–1419**: harness loaders/validators. **Move (Step 2):** `loadHarnessHtml` + (1061), `validateHarnessLines` (1169), `validateHarnessSnapshotCells` (1217), + `validateHarnessSnapshot` (1337). +- **1421–2814**: `export class GhosttyWebBackend` — **stays** (boot, replayTo, + snapshot, screenshot, video, dispose, and the HTTP server / bridge methods). + +**External consumers** (must keep working): + +- `test/unit/renderer/ghosttyWebDecode.test.ts:4-7` imports + `assembleCanonicalLine`, `stripTrailingAsciiSpaces`, and the type + `GhosttyDecodedColumn` **from `ghosttyWeb/backend.js`**. After the move, + `backend.ts` must **re-export** these three so this import keeps resolving. +- `src/renderer/libghosttyVt/backend.ts:18` and `src/export/webm.ts:17` import + the `GhosttyWebBackend` **class** from `backend.js` — the class stays, so these + are unaffected. + +### Conventions to follow + +- Strict TS, NodeNext ESM, `.js` import extensions on every relative import, + `import type` for type-only imports (oxlint enforces `import type`). +- 2-space indent, single quotes, trailing commas, semicolons (oxfmt enforces); + run the formatter after moves. +- **No circular imports.** The new modules are leaves: `harnessDecoding.ts` + imports from `embeddedHarnessHtml.ts` and `src/util/*`, never from + `backend.ts`. `backend.ts` imports from both new modules. + +## Commands you will need + +| Purpose | Command | Expected | +| ------------------------- | ------------------------------------------ | ----------- | +| Typecheck | `npm run typecheck` | exit 0 | +| Lint | `npm run lint` | exit 0 | +| Format (fix) | `npm run format` | exit 0 | +| Decode/backend unit tests | `npx vitest run test/unit/renderer` | all pass | +| e2e (visual) | `npm run test:e2e` | all pass | +| Line count | `wc -l src/renderer/ghosttyWeb/backend.ts` | ~1500 after | + +## Scope + +**In scope** (create + modify): + +- `src/renderer/ghosttyWeb/embeddedHarnessHtml.ts` (create) — the HTML constant. +- `src/renderer/ghosttyWeb/harnessDecoding.ts` (create) — the snapshot interfaces, + decode helpers, generic assertion helpers, and validators listed above. +- `src/renderer/ghosttyWeb/backend.ts` (modify) — remove the moved symbols, add + imports, re-export the three externally-consumed names. + +**Out of scope** (do NOT change in this plan): + +- The `GhosttyWebBackend` class body and all its methods — especially the HTTP + server / bridge methods (`startServer`, `respondToRequest`, `buildHarnessUrl`, + `isAllowedBrowserRequest`, `writeBridge`, `writeBatchBridge`, `resizeBridge`, + `readHarnessSnapshot`, etc.). Extracting those is a deliberate follow-up. +- The server/bridge interfaces and the `111–138` constants (they belong with the + server methods that stay). +- Any behavior change. This is a move; logic must be byte-identical. +- `src/renderer/libghosttyVt/backend.ts`, `src/export/webm.ts` (only consume the + class, which doesn't move). +- `CHANGELOG.md` (automation-owned). + +## Git workflow + +- Branch: `advisor/006-split-ghostty-web-backend` +- Conventional Commits. Example: `refactor: split harness HTML and decoding out of the ghostty-web backend`. + One commit per step is fine. +- Do NOT push or open a PR unless instructed. + +## Steps + +### Step 1: Extract the embedded harness HTML + +1. Create `src/renderer/ghosttyWeb/embeddedHarnessHtml.ts` containing the moved + constant: + + ```ts + export const EMBEDDED_HARNESS_HTML = ` + …(the entire current value, verbatim)…`; + ``` + +2. Delete the `const EMBEDDED_HARNESS_HTML = …;` block (lines ~140–929) from + `backend.ts`. +3. In `backend.ts`, add `import { EMBEDDED_HARNESS_HTML } from './embeddedHarnessHtml.js';` + (with the other relative imports). `loadHarnessHtml` keeps using the name + unchanged. + +**Verify**: `npm run typecheck` → exit 0. `wc -l src/renderer/ghosttyWeb/backend.ts` +→ roughly 2020 lines (down ~790). `npx vitest run test/unit/renderer` → all pass. + +### Step 2: Extract the harness-decoding layer + +1. Create `src/renderer/ghosttyWeb/harnessDecoding.ts`. Move into it, verbatim, + these symbols from `backend.ts`: + - Interfaces: `GhosttyHarnessVisibleLine`, `GhosttyHarnessSnapshotCell`, + `GhosttyHarnessRichLine`, `GhosttyHarnessSnapshot`. + - `GhosttyDecodedColumn`, `stripTrailingAsciiSpaces`, `assembleCanonicalLine`. + - `assertNonNegativeInteger`, `assertPositiveInteger`, `assertPositiveNumber`, + `assertHexColor`, `normalizeError`. + - `loadHarnessHtml`, `validateHarnessLines`, `validateHarnessSnapshotCells`, + `validateHarnessSnapshot`. + - Keep the existing `export` keyword on whatever was already exported; export + everything `backend.ts` will need to import back. +2. Add the new module's imports at its top: `EMBEDDED_HARNESS_HTML` from + `./embeddedHarnessHtml.js`, and `invariant`/`unreachable` (whichever are used) + from `../../util/assert.js`. Let `npm run typecheck` tell you exactly which + util symbols and types are needed. +3. In `backend.ts`, delete the moved blocks and add a single import from + `./harnessDecoding.js` for every moved symbol the class still references + (the validators, the assertion helpers used in `replayTo`/`screenshot`, etc.). + Run `npm run typecheck` and add/remove imports until it is clean. + +**Verify**: `npm run typecheck` → exit 0; `npx vitest run test/unit/renderer` +→ all pass. + +### Step 3: Re-export the externally-consumed names and tidy + +1. In `backend.ts`, add a re-export so the existing decode test keeps resolving: + + ```ts + export { + assembleCanonicalLine, + stripTrailingAsciiSpaces, + } from './harnessDecoding.js'; + export type { GhosttyDecodedColumn } from './harnessDecoding.js'; + ``` + + (Do not edit `test/unit/renderer/ghosttyWebDecode.test.ts` — the re-export is + what keeps its `from '…/backend.js'` import valid.) + +2. Run `npm run format`, then `npm run lint` → both exit 0. + +**Verify**: `npx vitest run test/unit/renderer/ghosttyWebDecode.test.ts` +→ all pass (proves the re-export works). + +### Step 4: Full behavior gate + +- `npm run typecheck` → exit 0. +- `npm run lint` → exit 0. +- `npm run test:unit` → all pass. +- `npm run test:e2e` → all pass (this exercises the real ghostty-web rendering / + screenshot path end-to-end; it is the proof the move changed no behavior). If + e2e cannot run in this environment, say so explicitly. + +## Test plan + +This is a refactor with **no new behavior**, so the test plan is _regression_: + +- `test/unit/renderer/ghosttyWebDecode.test.ts` (decode helpers) — must pass + unchanged via the re-export. +- `test/unit/renderer/ghosttyWebBackend.test.ts`, `canonicalScreen.test.ts`, and + the rest of `test/unit/renderer/` — must pass unchanged. +- `npm run test:e2e` — must pass unchanged (visual/screenshot parity). +- No new test files are required. If you find a moved helper had **zero** + coverage and you want to add a focused unit test for it in + `test/unit/renderer/`, that's welcome but optional. + +## Done criteria + +ALL must hold: + +- [ ] `src/renderer/ghosttyWeb/embeddedHarnessHtml.ts` and + `src/renderer/ghosttyWeb/harnessDecoding.ts` exist. +- [ ] `grep -n "EMBEDDED_HARNESS_HTML = " src/renderer/ghosttyWeb/backend.ts` + → no match (constant moved out). +- [ ] `wc -l src/renderer/ghosttyWeb/backend.ts` → roughly 1500 lines (down from 2814). +- [ ] `npm run typecheck`, `npm run lint`, `npm run format:check` all exit 0. +- [ ] `npm run test:unit` exits 0, including `test/unit/renderer/ghosttyWebDecode.test.ts` + (proves the re-export). +- [ ] `npm run test:e2e` passes (or its inability to run here is reported). +- [ ] `git diff` shows only moves/imports/re-exports — no logic edits inside any + moved function, no change to the `GhosttyWebBackend` class methods. +- [ ] No `CHANGELOG.md` change; no out-of-scope files modified (`git status`). +- [ ] `plans/README.md` status row updated. + +## STOP conditions + +Stop and report back (do not improvise) if: + +- Moving a symbol creates a circular import that typecheck flags + (`harnessDecoding.ts` must never import from `backend.ts`). If a moved function + genuinely depends on something only the class has, leave that function in + `backend.ts` and report it. +- Any `test/unit/renderer/*` or e2e test fails after a move — that means a move + was not behavior-preserving (likely a missed import or an accidental edit). + Do not change the test; find the move error or report it. +- The `GhosttyWebBackend` class needs edits beyond import lines to compile — that + signals you've moved something that should have stayed; report it. +- `backend.ts` does not end up substantially smaller (e.g. still > 1800 lines) — + re-check that both Step 1 and Step 2 actually removed their blocks. + +## Maintenance notes + +- **Deferred to a follow-up plan**: extracting the HTTP server + browser-bridge + methods (`startServer`, `respondToRequest`, asset serving, `buildHarnessUrl`, + `isAllowedBrowserRequest`, the `*Bridge` methods) into a `server.ts` / a bridge + helper. Those touch `this` (the `server`, `serverOrigin`, `page` fields), so + they need dependency extraction or a small server class — higher risk, separate + change. This plan intentionally stops before that. +- A reviewer should confirm the diff is move-only: no function body changed, and + the re-exports preserve the public import surface (`ghosttyWebDecode.test.ts` + and any other importer of the three decode symbols still resolve). +- The two in-code comments that mention `EMBEDDED_HARNESS_HTML` (the canonical-line + helpers note they must stay in sync with the harness copy) remain correct and + should be left as-is. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..bc49923 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,121 @@ +# Implementation Plans + +Generated by the `improve` skill on 2026-06-16, against commit `c11e2e2`. +Execute in the order below unless dependencies say otherwise. Each executor: +read the plan fully before starting, honor its STOP conditions, and update your +row when done. + +These plans were written for an executor with **zero context** from the audit +session — every plan is self-contained (paths, excerpts, commands, conventions). +The advisor never edits source; only files under `plans/` were created. + +## Execution order & status + +| Plan | Title | Priority | Effort | Risk | Depends on | Status | +| ---- | ------------------------------------------------------------- | -------- | ------ | ---- | ---------- | ------ | +| 001 | Harden local socket + state-file permissions | P1 | S–M | LOW | — | DONE | +| 002 | CI dependency-audit gate + clear current advisories | P1 | S–M | LOW | — | DONE | +| 003 | Fix stale "0.2.x" claim in RELEASE.md | P2 | S | LOW | — | DONE | +| 004 | Characterize hostMain decision helpers + idle-timeout | P2 | M | LOW | — | DONE | +| 005 | Share replay-event iteration across renderer backends | P2 | M | MED | — | DONE | +| 006 | Extract harness HTML + decoding from the ghostty-web god file | P3 | L | MED | — | DONE | + +Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | +REJECTED (with one-line rationale). + +**All six implemented and verified on 2026-06-16** via 4 parallel isolated-worktree +lanes, merged into branch `advisor/implement-all-plans` (merge commit `5ac2a33`, +17 files, +1848/−1363). Lane commits (now merge parents): 001+004 `da4df0d`, +005+006 `a4bba8a`, 002 `9d50fa1`, 003 `462ebea`. Integrated gate: format-check +(tracked files), lint, typecheck, workflow-lint, the new `aube audit` gate (0 +vulns), build, unit (1329), e2e (33), and install-smoke all **green**; +integration tests 187/188. The single failure — `screen-hash.test.ts:150` +(structured-vs-text `screenHash` divergence) — is **pre-existing** (reproduces +on clean `c11e2e2` with byte-identical hashes `440ffd…`/`c23b75…`, 3/3 runs) and +plan-independent; lane 005/006 produce identical hashes, proving the renderer +refactor preserved behavior. The branch was not pushed and `main` was not +modified. + +## Recommended order & dependency notes + +All six plans are **independent** — none hard-depends on another, so they can be +done in any order or in parallel by different executors. The numbering reflects +recommended priority (security + quick wins first), not a dependency chain: + +- **001, 002, 003** first — highest leverage-per-effort. 003 is a minutes-long + doc fix; 001 and 002 are security/hygiene with clean verification. +- **004** is a low-risk test investment; doing it early is nice because it pins + hostMain behavior, but nothing blocks on it. +- **005** and **006** are the tech-debt refactors. Their safety net is the + **existing** test suite, not plan 004: + - 005's net is `test/integration/screen-hash.test.ts` (backend convergence) + + the renderer unit tests + e2e. + - 006's net is `test/unit/renderer/*` + e2e (visual parity). + Do 005 and 006 when you have the e2e suite runnable, since both require it as + the behavior gate. + +If running non-interactively, do them in numeric order. + +## Notes for executors + +- Repo gates (run the narrowest useful ones per plan; full gate before a PR): + `npm run typecheck`, `npm run lint`, `npm run format:check`, + `npm run test:unit` / `:integration` / `:e2e`, and `npm run verify` (or + `mise run ci`) for the whole thing. +- **Never edit `CHANGELOG.md`** — it is automation-owned (Communique/release-please); + a manual edit conflicts with `main` and silently breaks `pull_request` CI. +- Tests must use an isolated absolute `AGENT_TTY_HOME`; never touch the real + `~/.agent-tty`. +- PR titles must be Conventional Commits (CI enforces it). Do not push or open a + PR unless the operator asked. + +## Findings considered and rejected + +Recorded so they aren't re-audited next run. (The audit surfaced more; these are +the ones explicitly checked and dropped.) + +- **Event-log write-queue "poisoning" with no recovery** — by design: an + explicit "do not refactor this" comment in `eventLog.ts`, and `append()` + _throws_ on failure (not silent). Not a bug. +- **`afterSeq: -1` bypasses the wait baseline** — false. `matcher.ts:247` + already rejects `afterSeq < 0` at the public boundary; `-1` is only an internal + `getEventsSince` sentinel. +- **waitForRender concurrent-snapshot race** — false. `HostRendererManager` + serializes all backend access via `runExclusive`/`lifecyclePromise` + (`renderer.ts:266`). +- **Unawaited dashboard renderer dispose** — LOW, not worth a plan. React effect + cleanup can't be async; the dashboard uses libghostty-vt (no Playwright/process + leak as the audit claimed); fire-and-forget best-effort dispose is acceptable. +- **Redundant seq invariant / `Promise.withResolvers` Node support / idle-timeout + scope null check** — non-issues: the invariant runs _before_ the increment + (trivially true, not "always false"); Node ≥24 has `Promise.withResolvers`; + the scope path is defensively guarded. +- **PNG re-hash on every screenshot** — not a finding. The `sha256` is a + _required_ field of the public Screenshot Result, computed from a single + necessary read (no double-read to eliminate). +- **`batch --keep-going` missing** — false. The flag exists (`main.ts:563`, + threaded through to the executor and exit codes). +- **Extraneous `effect`/`vite`/`release-it` deps; `release-please` UNMET** — + false; `npm list` artifacts on an aube/pnpm-style `node_modules`. + `release-please` is in the lockfile + `package.json`; `vite` is a legitimate + transitive dep, not a removable direct one. +- **Routine dependency bumps** (commander 14→15, playwright/oxlint minor), + **`@coder/libghostty-vt-node` beta pin** (intentional), **invariant-vs-CliError + error layering** (deliberate: internal preconditions vs user-facing), + **bundle-tool duplication** (internal maintainer tooling), **no `CLAUDE.md`** + (`AGENTS.md` exists and is comprehensive), **`intent:validate` not in `verify`** + (it isn't in CI either; low value) — all low-leverage; not worth plans. + +## Direction findings (not selected for plans this round) + +Surfaced as options for the maintainer; no plan written (the user opted to plan +only the findings above). Grounded in `design/ARCHITECTURE.md` / `RELEASE.md`: + +- **Event-log integrity `validate`/`repair`** — the event log is canonical truth + but has no corruption-detection command or recovery guidance. +- **Renderer-adapter contributor guide** — the adapter interface + reserved + native-backend slots exist; no how-to for adding one. +- **Revisit the MCP wrapper** — explicitly deferred as a v1 non-goal; at 0.4.3 + with a stable JSON contract, worth re-weighing. (`RELEASE.md` lists it under + "Explicitly out of scope" — revisit means amending that decision, not ignoring + it.)