diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aff6eed..2479835a 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/RELEASE.md b/RELEASE.md index a7d900b1..85e9382b 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). diff --git a/aube-lock.yaml b/aube-lock.yaml index ddb445af..e16de45c 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 b42ee0e3..5f5a9dcb 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 880ad39b..e07ab411 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" } } diff --git a/plans/001-harden-local-state-permissions.md b/plans/001-harden-local-state-permissions.md new file mode 100644 index 00000000..197360e7 --- /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 00000000..08abb85b --- /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 00000000..9c0c9823 --- /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 00000000..89c6a0f2 --- /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 00000000..7eb9ffe5 --- /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 00000000..547bc840 --- /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 00000000..bc499234 --- /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.) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index bdd65592..a563054c 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 04831c0d..ee2e1553 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/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index 5acfad6c..7e0bde4d 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 00000000..6da41a03 --- /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 00000000..17f5fef4 --- /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 fc17adb1..3361ccd1 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 00000000..84ba155a --- /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/src/storage/manifests.ts b/src/storage/manifests.ts index a0e54aee..8e167790 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 00000000..9ecc20b2 --- /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 00000000..b39f3566 --- /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 ff2dd143..27379f53 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); + } + }); +}); diff --git a/test/unit/renderer/replayEvents.test.ts b/test/unit/renderer/replayEvents.test.ts new file mode 100644 index 00000000..4c228def --- /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([]); + }); +});