specpi 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,107 @@
1
+ # SpecPi Security Model
2
+
3
+ This document describes SpecPi's architecture-level security assumptions, enforcement boundaries, and residual risks. It complements the public vulnerability-reporting policy in [SECURITY.md](SECURITY.md). Exact third-party versions and licenses are maintained in [THIRD_PARTY.md](THIRD_PARTY.md).
4
+
5
+ ## Threat model
6
+
7
+ ### Assets
8
+
9
+ SpecPi aims to preserve:
10
+
11
+ - user files, Git work, and host stability;
12
+ - Pi configuration, credentials, sessions, history, and trust decisions;
13
+ - SpecPi-managed files, backups, manifests, checksums, and enforcement code;
14
+ - local wishlist evidence, experiment metadata and patches, session-branch workflow records, and browser artifacts;
15
+ - the confidentiality and integrity of reports, prompts, commands, and paths that SpecPi does not need to persist.
16
+
17
+ ### Untrusted inputs and actors
18
+
19
+ SpecPi expects model-generated tool calls, repository content, web content, downloaded package metadata, and browser page output to be potentially hostile or malformed. A dependency, extension, project configuration, approved script, or direct user command is more powerful than ordinary model output and may cross boundaries SpecPi cannot enforce.
20
+
21
+ ### Trusted components
22
+
23
+ SpecPi ultimately trusts the invoking user and operating-system account, the Pi runtime, explicitly installed packages and extensions, project and user configuration treated by Pi as trusted, package registries and upstream publishers used during installation, and commands or scripts the user approves. These are trust assumptions, not claims that every component is independently verified.
24
+
25
+ ### Security goals
26
+
27
+ SpecPi seeks to make installation explicit and reversible, avoid private Pi state it does not need, preserve unrelated configuration, prevent exact-provider policy from silently weakening, block confirmed catastrophic model operations at supported tool seams, keep local improvement evidence local, and isolate browser QA from the user's personal browser profile.
28
+
29
+ ### Non-goals
30
+
31
+ SpecPi is not an operating-system sandbox and does not claim to contain a compromised host, user account, Pi runtime, trusted extension, approved script, dependency, custom tool, or already-running process. It cannot prevent every credential read, data transfer, destructive operation, prompt-injection effect, time-of-check/time-of-use race, or action outside its documented interception points. Use operating-system permissions, least privilege, protected credentials, containers, or virtual machines when stronger isolation is required.
32
+
33
+ ## Installer and managed state
34
+
35
+ SpecPi is configuration and executable extension code for Pi and runs with the invoking user's permissions. Installing the public npm package adds the `specpi` CLI to npm-managed state but does not run SpecPi installation, download external tools, or mutate Pi state; the package defines no npm install lifecycle script. The installer prints a plan and requires confirmation unless `--yes` is supplied. `plan` is non-mutating. Installation and update merge documented settings leaves, preserve unrelated packages and configuration, and modify AGENTS and shell files only inside SpecPi marker blocks.
36
+
37
+ The canonical npm route is an installer-CLI distribution. Direct `pi install npm:specpi` loads packaged extensions, skills, and themes but bypasses the managed installer, so it does not provide managed instructions, supporting packages, browser runtime acquisition, optional tools, shell integration, backups, or ownership records. Pi-bundled runtime modules are optional peers and are not copied into the tarball. Release automation validates the packed artifact, requires a matching immutable version and release tag, publishes only from a protected environment, and requests npm provenance through short-lived GitHub OIDC credentials.
38
+
39
+ Before replacing managed resources, SpecPi creates bounded backups, stages writes, promotes files atomically where supported, records checksums and ownership in a private manifest, and rolls configuration files back when a core installation step fails. It does not persistently copy complete Pi settings or shell startup files. Symlink, lock, ownership, and malformed-state checks fail closed where the installer cannot prove a supported mutation.
40
+
41
+ SpecPi never reads Pi authentication files, provider credentials, sessions, history, missions, or trust decisions as part of installation or local improvement reporting. It does not commit, push, publish, or create remote resources.
42
+
43
+ When Pi is missing, a confirmed bootstrap can install the pinned Pi package globally with lifecycle scripts disabled. Existing compatible Pi installations are preserved. Pi packages are installed through Pi's package mechanism, and the browser runtime is installed from SpecPi's reviewed lockfile before atomic promotion and launch smoke testing. SpecPi does not install Playwright operating-system dependencies.
44
+
45
+ Global package installations, upstream package-manager effects, downloaded caches, and optional tools are external system state. A later SpecPi failure does not necessarily remove them, and uninstall preserves them. The optional DonSeTch package performs its own binary acquisition and remains outside SpecPi's rollback boundary. The canonical inventory is in [THIRD_PARTY.md](THIRD_PARTY.md).
46
+
47
+ ## Command Guard
48
+
49
+ Command Guard is a pre-execution policy layer for model-initiated Pi tool calls. It covers Pi's documented `bash`, `powershell`, `read`, `write`, and `edit` tools. Other extensions, custom or MCP tools, direct user `!command` and `!!command` escapes, approved scripts, and process execution outside these seams are not contained.
50
+
51
+ Interactive top-level sessions choose one session-only mode:
52
+
53
+ - **Guard** denies confirmed host-wide catastrophe and enforcement tampering, asks before destructive Git operations, and stays quiet for determinate non-catastrophic work.
54
+ - **Strict** additionally asks about mutation, execution, recognized sensitive reads, elevation, network activity, and uncatalogued tools.
55
+ - **Off** is a twice-confirmed direct-user escape hatch limited to the current session.
56
+
57
+ Guard is not general change control. Determinate calls may delete project or user data, publish, deploy, install software, transfer data, change services, or reach outside the workspace. Git work destruction is the deliberate broader approval boundary because it can discard or rewrite work that local rollback cannot recover.
58
+
59
+ The guard analyzes Bash/POSIX and cmd command structure conservatively and uses installed PowerShell parser APIs without evaluating the command. It resolves protected paths using lexical and canonical information where available and carries working-directory changes through supported command sequences. Confirmed catastrophic payloads remain immutable denials. Analysis, parser, payload, or path uncertainty asks for approval when interactive UI is available and denies without UI; uncertainty cannot silently approve a call.
60
+
61
+ Approvals are exact-call and session-scoped. Every call is reanalyzed before an approval can satisfy it, and approval never overrides a critical denial. Only a structurally proven lock-worthy critical mutation locks subsequent execution; parser uncertainty, syntax mismatch, fallback classification, and refused reads deny the current call without permanently stranding the session. Session lifecycle changes and guard-mode changes clear transient approval state.
62
+
63
+ The guard keeps mode, lock, counters, parser cache, and approval hashes only in memory. It does not persist or upload raw commands, arguments, paths, working directories, parser output, prompts, or approvals. Display excerpts are bounded and redact common secret-bearing forms.
64
+
65
+ Static analysis cannot resolve every alias, generated command, script body, plugin, encoding, runtime expansion, symlink race, or interpreter behavior. Recognized private-path reads receive narrow protection, but arbitrary scripts or shell syntax can still read credentials. An allowed or approved command runs with the user's full permissions. `specpi doctor` verifies installed checksums and deterministic policy smoke behavior, not universal command safety.
66
+
67
+ ## Local improvement state
68
+
69
+ Capability-gap collection is disabled until the user makes an explicit local on/off choice. When enabled, SpecPi stores bounded sanitized summaries and salted hashes used to measure distinct tasks, sessions, and projects. It does not read prompts, source files, sessions, history, credentials, provider authentication, or trust decisions to construct reports, and it never uploads wishlist state.
70
+
71
+ Improvement journals may contain sanitized acceptance evidence, verification gates, repository-relative changed-file names, version data, and bounded reopen context. Raw source content and complete settings snapshots are not part of those records.
72
+
73
+ Sanitization is defense in depth, not a guarantee that every plain-language identity or sensitive fact can be detected. Users should keep summaries general and inspect reports and local issue drafts before sharing them.
74
+
75
+ Wishlist evidence does not authorize implementation. `/harness-improvement` requires one explicit human selection, and retirement requires the repository check plus supported closed validators. Failed verification leaves the item selected. Validator identifiers resolve through a closed allowlist rather than being interpreted as commands.
76
+
77
+ Wishlist state, archives, and browser artifacts can survive uninstall so uninstall does not silently destroy user evidence. Stop Pi and review retained state before manual removal. Archive and reset operations use checksummed snapshots and recoverable transactions; unverified abandoned locks are not reclaimed automatically.
78
+
79
+ ## Workflow controls
80
+
81
+ The Scope Drift Monitor is task-scope governance, not a filesystem sandbox. It preflights direct `write` and `edit` paths and compares bounded Git worktree snapshots around tools. Shell commands, scripts, custom tools, concurrent processes, very large dirty sets, changes made and reverted within one call, filesystem timestamp behavior on oversized files, and non-Git sessions can reduce or bypass its observations. The snapshot a tool finishes on becomes the baseline for the next tool, so a change made outside any tool call is attributed to whichever tool runs next rather than going unseen; only `read`, which cannot mutate the worktree, is skipped. Interactive users choose deny, allow-once, or scope expansion for direct out-of-scope calls. Headless and post-hoc findings remain advisory and pending rather than fabricating human consent. `/scope accept` acknowledges a finding without widening the contract, `/scope add` is the only verb that widens it, and `/scope recheck` clears snapshot uncertainty only as a deliberate human act. Git reports paths verbatim under `-z`, so a filename may contain newlines or other control characters; every path SpecPi reports back through the system prompt, tool results, or the UI is percent-escaped first so a hostile filename cannot forge a line of guidance. Scope entries, changed relative paths, and pending findings are copied into the active Pi session branch so an appended record cannot be rewritten later; raw tool input, command text, output, and source are not copied into workflow state.
82
+
83
+ Guided Experiment Worktrees use explicit extension commands and fixed Git argv, so their internal `pi.exec` calls are outside Command Guard's model-tool interception. SpecPi therefore requires its own confirmations before creation, overwrite, and removal, revalidates the registered worktree and common Git directory before destructive removal, and never automatically reclaims an abandoned registry lock or missing worktree. A directory Git no longer tracks as a worktree can only have its registry record released; SpecPi leaves those files in place for the human rather than deleting them. Recovery re-reads `git worktree list` and revalidates the record's repository inside the registry lock immediately before it mutates state, so an external Git operation performed while a recovery prompt is open cannot cause a stale answer to drop a live record or adopt a replaced directory. Experiments are detached from a recorded `HEAD`; dirty base changes stay in place and are excluded. Patch export uses a temporary Git index and can invoke repository-configured Git clean filters through `git add -A`; trusted repository configuration remains part of the trust boundary. Status and export measure from the recorded base commit rather than the worktree's current HEAD, so work the human commits inside an experiment stays visible to status, reaches the patch, and triggers the discard confirmation. Git writes the patch file itself so its bytes are preserved exactly, including text that is not valid UTF-8. A patch output that appears after the existence check is claimed exclusively rather than overwritten, so an unapproved replacement fails closed. Ignored files are outside both Git status and `git add -A`, so they never appear in a patch; SpecPi counts and names them before an export or a discard, but a discarded ignored file is not recoverable. SpecPi does not launch a writer, create a branch or commit, merge, apply a patch, or alter remotes.
84
+
85
+ The private experiment registry stores canonical repository and worktree paths, base commit IDs, bounded user-authored hypotheses, acceptance checks, non-goals, lifecycle state, and timestamps under `<agent-dir>/specpi/experiments`. Exported patches contain source changes by design and may be sensitive. SpecPi requests `0700` directories and `0600` files, but Windows ignores POSIX modes, so on that platform these artifacts are protected by the user profile's own access control rather than by the requested mode. Experiment metadata and patches are user evidence and survive uninstall unless explicitly removed. Registry writes are atomic and ownership-locked, but a crash, filesystem failure, external `git worktree` operation, malicious Git configuration, symlink race, or manual state edit can require `/experiment recover` or direct human repair.
86
+
87
+ Completion Challenge activation and structured results are bounded to the current Pi session branch. An activation that the agent turn ends without answering expires with that turn, so its instruction never carries into later work. An expiry is recorded distinctly from `/challenge clear`, so an unanswered challenge never retires the last completed card. The extension records changed relative paths, active scope summaries, matching experiment acceptance metadata, and tool failure counts observed during the task; it does not scan unrelated historical sessions, prompts, source, or raw tool output. A deterministic gate rejects a ready verdict when its own submission reports unresolved requirements, contradictions, validation gaps, or pending scope drift. When the change snapshot was indeterminate the facts the review received may be incomplete, so a ready verdict must disclose that residual risk; the condition is permanent outside a Git worktree, so it is required disclosure rather than an unreachable verdict. The underlying analysis is still produced by the same model and context, so it can omit a requirement, misunderstand evidence, or rationalize a false positive. The card is a review aid, not independent validation, authorization, or a completion lock.
88
+
89
+ ## Browser isolation
90
+
91
+ SpecPi launches managed Chromium in a fresh Playwright context. It does not attach to a personal browser profile or load the user's cookies, saved passwords, or extensions. Browser pages still execute untrusted content and can reach URLs available to the host.
92
+
93
+ Snapshots, screenshots, page text, downloads, console output, and visual baselines may contain sensitive information. Default artifacts remain in SpecPi's private state directory. Explicit output publication is bounded and atomic, and existing artifacts or baselines are not replaced without explicit overwrite authorization.
94
+
95
+ The browser is not an operating-system or network sandbox. Use a container or VM for hostile applications and dedicated test accounts instead of personal authenticated sessions.
96
+
97
+ ## Website and automation
98
+
99
+ The GitHub Pages workflow publishes the checked-in `site/` directory. Its deploy job uses read-only repository contents access plus the Pages and identity-token permissions required for deployment. The local installer does not invoke that workflow or upload local configuration or state.
100
+
101
+ Repository checks, smoke tests, checksums, closed capability validators, and browser comparisons provide evidence for documented behavior. They reduce regression risk but do not prove the absence of vulnerabilities or establish cryptographic provenance for dependencies and releases.
102
+
103
+ ## Supply-chain assumptions
104
+
105
+ SpecPi pins reviewed executable package versions and the browser dependency graph. These controls improve repeatability and make version changes reviewable. They do not prove that a registry, publisher account, package artifact, downloaded browser, GitHub Action tag, or invoking host is uncompromised. Releases are not described as reproducible or cryptographically signed unless a future release adds and documents those mechanisms.
106
+
107
+ Users should review tagged source, installation plans, dependency changes, and security advisories; use protected branches and least-privilege automation when maintaining a fork; and upgrade promptly when SpecPi or an upstream project publishes relevant security guidance.
package/THIRD_PARTY.md ADDED
@@ -0,0 +1,61 @@
1
+ # Third-party components
2
+
3
+ When Pi is absent, SpecPi can install the reviewed `@earendil-works/pi-coding-agent@0.84.4` npm package globally after confirmation. The package provides the `pi` executable, is installed with lifecycle scripts disabled, retains its upstream license, and remains external system state after SpecPi uninstall.
4
+
5
+ SpecPi pins but does not vendor these Pi packages:
6
+
7
+ - `pi-web-access@0.25.0`
8
+ - `@juicesharp/rpiv-ask-user-question@2.7.1`
9
+ - `@llblab/pi-codex-usage@0.9.3`
10
+ - `@tunnckocore/pi-gpt-fast-mode@0.4.0`
11
+ - `@narumitw/pi-goal@0.54.3`
12
+
13
+ They retain their own copyright and license terms. Pi downloads them from npm when the installer runs `pi install`. SpecPi does not patch, fork, vendor, or use unsupported deep imports from these packages.
14
+
15
+ The published `specpi` npm package declares the Pi host runtime modules its extensions import as optional peer dependencies, each at Pi's documented `"*"` range:
16
+
17
+ - `@earendil-works/pi-coding-agent` — extension API, theme, markdown, and highlighting helpers
18
+ - `@earendil-works/pi-ai` — the `StringEnum` tool-schema helper
19
+ - `@earendil-works/pi-tui` — terminal component, key, and width primitives
20
+ - `typebox` — the unscoped TypeBox package Pi bundles, used for tool input schemas
21
+
22
+ They retain their own copyright and license terms. SpecPi never vendors, bundles, or installs them; the Pi host supplies them at extension load time through loader aliases. Pi disables peer resolution for managed package installs, so a peer range would not enforce the host version there. Marking the peers optional also keeps an ordinary npm CLI installation from adding a second copy beside or inside SpecPi. The full managed installation enforces its supported Pi floor through the installer's `MIN_PI_VERSION` compatibility check, and the limited direct Pi mode documents the same host prerequisite.
23
+
24
+ SpecPi also installs these exact browser-runtime packages from the reviewed `browser-runtime/package-lock.json`:
25
+
26
+ - `playwright@1.62.1` and `playwright-core@1.62.1` — Apache-2.0
27
+ - `pixelmatch@7.2.0` — ISC
28
+ - `pngjs@7.0.0` — MIT
29
+ - optional `fsevents@2.3.2` on macOS — MIT
30
+
31
+ Playwright downloads its matching Chromium build into SpecPi's private runtime directory. Chromium retains its upstream BSD and third-party component licenses. None of these executables are installed globally.
32
+
33
+ Repository development uses these exact, project-local formatting and linting packages:
34
+
35
+ - `prettier@3.9.6` — MIT
36
+ - `eslint@10.9.1` — MIT
37
+ - `@stylistic/eslint-plugin@5.10.0` — MIT
38
+ - `@typescript-eslint/parser@8.68.0` — MIT
39
+ - `typescript@6.0.3` — Apache-2.0
40
+
41
+ They are development-only dependencies, are not shipped by the SpecPi installer, and enforce the repository's JavaScript and TypeScript readability rules.
42
+
43
+ The GitHub Pages site vendors the Latin subsets of IBM Plex Sans and IBM Plex Mono. Copyright © 2017 IBM Corp. with Reserved Font Name "Plex". The font files are distributed under the SIL Open Font License 1.1; the required license text is included at `site/fonts/LICENSE.txt`.
44
+
45
+ The npm release workflow installs `npm@11.19.1` as its pinned publishing client. npm is distributed under the Artistic License 2.0 and runs only on the ephemeral GitHub-hosted release runner.
46
+
47
+ Repository automation uses these official GitHub Actions. General CI and Pages workflows track the listed major versions; the npm publishing workflow pins exact reviewed commit SHAs so the OIDC job does not execute mutable action tags:
48
+
49
+ - `actions/checkout@v4`
50
+ - `actions/setup-node@v4`
51
+ - `actions/configure-pages@v5`
52
+ - `actions/upload-pages-artifact@v4`
53
+ - `actions/deploy-pages@v4`
54
+ - `actions/upload-artifact@v4`
55
+ - `actions/download-artifact@v4`
56
+
57
+ They retain their own copyright and license terms. These actions receive only the permissions declared in their respective workflows.
58
+
59
+ The optional DonSeTch CLI is distributed under AGPL-3.0-only and is not bundled in this repository. When selected during installation, SpecPi installs `donsetch@3.4.0` globally through npm; that package downloads and verifies its platform binary. The included skill documents how to invoke it.
60
+
61
+ Git is not bundled and retains its own license. SpecPi's in-house `/files` extension uses Pi's built-in themed renderers and invokes Git directly when repository status or diffs are available.
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "specpi-browser-runtime",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "specpi-browser-runtime",
9
+ "version": "0.1.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "pixelmatch": "7.2.0",
13
+ "playwright": "1.62.1",
14
+ "pngjs": "7.0.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=22.19.0"
18
+ }
19
+ },
20
+ "node_modules/fsevents": {
21
+ "version": "2.3.2",
22
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
23
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
24
+ "hasInstallScript": true,
25
+ "license": "MIT",
26
+ "optional": true,
27
+ "os": [
28
+ "darwin"
29
+ ],
30
+ "engines": {
31
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
32
+ }
33
+ },
34
+ "node_modules/pixelmatch": {
35
+ "version": "7.2.0",
36
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
37
+ "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
38
+ "license": "ISC",
39
+ "dependencies": {
40
+ "pngjs": "^7.0.0"
41
+ },
42
+ "bin": {
43
+ "pixelmatch": "bin/pixelmatch"
44
+ }
45
+ },
46
+ "node_modules/playwright": {
47
+ "version": "1.62.1",
48
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
49
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
50
+ "license": "Apache-2.0",
51
+ "dependencies": {
52
+ "playwright-core": "1.62.1"
53
+ },
54
+ "bin": {
55
+ "playwright": "cli.js"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ },
60
+ "optionalDependencies": {
61
+ "fsevents": "2.3.2"
62
+ }
63
+ },
64
+ "node_modules/playwright-core": {
65
+ "version": "1.62.1",
66
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
67
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
68
+ "license": "Apache-2.0",
69
+ "bin": {
70
+ "playwright-core": "cli.js"
71
+ },
72
+ "engines": {
73
+ "node": ">=20"
74
+ }
75
+ },
76
+ "node_modules/pngjs": {
77
+ "version": "7.0.0",
78
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
79
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
80
+ "license": "MIT",
81
+ "engines": {
82
+ "node": ">=14.19.0"
83
+ }
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "specpi-browser-runtime",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "description": "Pinned local browser runtime managed by the SpecPi installer",
6
+ "license": "MIT",
7
+ "dependencies": {
8
+ "pixelmatch": "7.2.0",
9
+ "playwright": "1.62.1",
10
+ "pngjs": "7.0.0"
11
+ },
12
+ "engines": {
13
+ "node": ">=22.19.0"
14
+ }
15
+ }
@@ -0,0 +1,306 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ export const VIEWPORT_PRESETS = Object.freeze({
7
+ desktop: Object.freeze({ width: 1440, height: 900 }),
8
+ tablet: Object.freeze({ width: 834, height: 1112 }),
9
+ mobile: Object.freeze({ width: 390, height: 844 }),
10
+ });
11
+
12
+ export const DEFAULT_DIFF_THRESHOLD = 0.1;
13
+ export const DEFAULT_MAX_DIFF_PIXEL_RATIO = 0.01;
14
+ export const MAX_VIEWPORT_PIXELS = 8_000_000;
15
+ export const MAX_CAPTURE_PIXELS = 8_000_000;
16
+ export const MAX_CAPTURE_DIMENSION = 8_192;
17
+ export const MAX_PNG_BYTES = 25 * 1024 * 1024;
18
+ export const MAX_INLINE_IMAGE_BYTES = 5 * 1024 * 1024;
19
+
20
+ export function normalizeBrowserUrl(value) {
21
+ const raw = String(value ?? "").trim();
22
+ if (!raw) {
23
+ throw new Error("A browser URL is required.");
24
+ }
25
+
26
+ const candidate = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(raw) ? `http://${raw}` : raw;
27
+ let url;
28
+ try {
29
+ url = new URL(candidate);
30
+ } catch {
31
+ throw new Error(`Invalid browser URL: ${raw}`);
32
+ }
33
+
34
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
35
+ throw new Error("Browser URLs must use http: or https:.");
36
+ }
37
+
38
+ return url.href;
39
+ }
40
+
41
+ export function resolveViewport({ preset, width, height } = {}) {
42
+ if (width !== undefined || height !== undefined) {
43
+ if (
44
+ !Number.isInteger(width) ||
45
+ !Number.isInteger(height) ||
46
+ width < 200 ||
47
+ height < 200 ||
48
+ width > 4096 ||
49
+ height > 4096
50
+ ) {
51
+ throw new Error("Custom viewport width and height must both be integers from 200 to 4096.");
52
+ }
53
+
54
+ if (width * height > MAX_VIEWPORT_PIXELS) {
55
+ throw new Error(`Custom viewport exceeds the ${MAX_VIEWPORT_PIXELS.toLocaleString()} pixel limit.`);
56
+ }
57
+
58
+ return { width, height };
59
+ }
60
+
61
+ const name = preset ?? "desktop";
62
+ const viewport = VIEWPORT_PRESETS[name];
63
+ if (!viewport) {
64
+ throw new Error(`Unknown viewport preset: ${name}`);
65
+ }
66
+
67
+ return { ...viewport };
68
+ }
69
+
70
+ export function resolveUserPath(cwd, value, label = "path") {
71
+ const raw = String(value ?? "")
72
+ .trim()
73
+ .replace(/^@/, "");
74
+ if (!raw) {
75
+ throw new Error(`${label} is required.`);
76
+ }
77
+
78
+ return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(cwd, raw);
79
+ }
80
+
81
+ export function assertDistinctPaths(entries) {
82
+ const seen = new Map();
83
+ for (const [label, file] of entries) {
84
+ const normalized = path.resolve(file);
85
+ let canonical = normalized;
86
+ try {
87
+ canonical = fs.realpathSync(normalized);
88
+ } catch (error) {
89
+ if (error.code !== "ENOENT") {
90
+ throw error;
91
+ }
92
+
93
+ try {
94
+ canonical = path.join(fs.realpathSync(path.dirname(normalized)), path.basename(normalized));
95
+ } catch {}
96
+ }
97
+
98
+ let identity = canonical;
99
+ try {
100
+ const stat = fs.statSync(normalized);
101
+ identity = `inode:${stat.dev}:${stat.ino}`;
102
+ } catch {}
103
+
104
+ const previous = seen.get(identity) || seen.get(canonical);
105
+ if (previous) {
106
+ throw new Error(`${label} must not alias ${previous}: ${normalized}`);
107
+ }
108
+
109
+ seen.set(identity, label);
110
+ seen.set(canonical, label);
111
+ }
112
+ }
113
+
114
+ export function sanitizeArtifactSegment(value) {
115
+ const normalized = String(value ?? "session")
116
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
117
+ .replace(/^-+|-+$/g, "");
118
+
119
+ return normalized.slice(0, 80) || "session";
120
+ }
121
+
122
+ export function makeArtifactPath(agentDir, sessionId, kind, extension = "png") {
123
+ const dir = path.join(agentDir, "specpi", "browser-artifacts", sanitizeArtifactSegment(sessionId));
124
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ const stamp = new Date().toISOString().replaceAll(":", "").replaceAll(".", "-");
126
+ const suffix = Math.random().toString(16).slice(2, 10);
127
+
128
+ return path.join(dir, `${sanitizeArtifactSegment(kind)}-${stamp}-${suffix}.${extension}`);
129
+ }
130
+
131
+ export async function publishBuffer(file, data, { overwrite = false, signal } = {}) {
132
+ if (signal?.aborted) {
133
+ throw new Error("Browser operation aborted.");
134
+ }
135
+
136
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
137
+ const temporary = path.join(
138
+ path.dirname(file),
139
+ `.${path.basename(file)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`,
140
+ );
141
+ try {
142
+ await fs.promises.writeFile(temporary, data, { mode: 0o600, flag: "wx" });
143
+ if (signal?.aborted) {
144
+ throw new Error("Browser operation aborted.");
145
+ }
146
+
147
+ if (overwrite) {
148
+ await fs.promises.rename(temporary, file);
149
+ } else {
150
+ try {
151
+ await fs.promises.link(temporary, file);
152
+ } catch (error) {
153
+ if (error.code === "EEXIST") {
154
+ throw new Error(
155
+ `Output already exists: ${file}. Pass overwrite=true only when replacement is intended.`,
156
+ );
157
+ }
158
+
159
+ throw error;
160
+ }
161
+
162
+ await fs.promises.unlink(temporary);
163
+ }
164
+ } finally {
165
+ await fs.promises.rm(temporary, { force: true }).catch(() => {});
166
+ }
167
+ }
168
+
169
+ export function getAgentDir(extensionUrl) {
170
+ if (process.env.PI_CODING_AGENT_DIR) {
171
+ return path.resolve(process.env.PI_CODING_AGENT_DIR);
172
+ }
173
+
174
+ return path.resolve(path.dirname(fileURLToPath(extensionUrl)), "../..");
175
+ }
176
+
177
+ export async function loadBrowserRuntime(runtimeDir) {
178
+ const packageJson = path.join(runtimeDir, "package.json");
179
+ if (!fs.existsSync(packageJson)) {
180
+ throw new Error(
181
+ `SpecPi browser runtime is not installed at ${runtimeDir}. Run specpi update without --skip-browser-install.`,
182
+ );
183
+ }
184
+
185
+ const require = createRequire(packageJson);
186
+ const previousBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
187
+ process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(runtimeDir, "browsers");
188
+ try {
189
+ const playwright = require("playwright");
190
+ const { PNG } = require("pngjs");
191
+ const pixelmatchPath = require.resolve("pixelmatch");
192
+ const pixelmatchModule = await import(pathToFileURL(pixelmatchPath).href);
193
+
194
+ return { playwright, PNG, pixelmatch: pixelmatchModule.default };
195
+ } finally {
196
+ if (previousBrowsersPath === undefined) {
197
+ delete process.env.PLAYWRIGHT_BROWSERS_PATH;
198
+ } else {
199
+ process.env.PLAYWRIGHT_BROWSERS_PATH = previousBrowsersPath;
200
+ }
201
+ }
202
+ }
203
+
204
+ export function readPngDimensions(buffer, label = "PNG") {
205
+ if (
206
+ !Buffer.isBuffer(buffer) ||
207
+ buffer.length < 24 ||
208
+ buffer.toString("hex", 0, 8) !== "89504e470d0a1a0a" ||
209
+ buffer.toString("ascii", 12, 16) !== "IHDR"
210
+ ) {
211
+ throw new Error(`${label} is not a valid PNG.`);
212
+ }
213
+
214
+ const width = buffer.readUInt32BE(16);
215
+ const height = buffer.readUInt32BE(20);
216
+ if (
217
+ !width ||
218
+ !height ||
219
+ width > MAX_CAPTURE_DIMENSION ||
220
+ height > MAX_CAPTURE_DIMENSION ||
221
+ width * height > MAX_CAPTURE_PIXELS
222
+ ) {
223
+ throw new Error(`${label} dimensions ${width}x${height} exceed browser image limits.`);
224
+ }
225
+
226
+ return { width, height };
227
+ }
228
+
229
+ export function assertPngResourceBounds(buffer, label = "PNG") {
230
+ if (buffer.length > MAX_PNG_BYTES) {
231
+ throw new Error(`${label} exceeds the ${MAX_PNG_BYTES} byte compressed-size limit.`);
232
+ }
233
+
234
+ return readPngDimensions(buffer, label);
235
+ }
236
+
237
+ function boundedPngWrite(PNG, image, label) {
238
+ const buffer = PNG.sync.write(image);
239
+ if (buffer.length > MAX_PNG_BYTES) {
240
+ throw new Error(`${label} exceeds the ${MAX_PNG_BYTES} byte compressed-size limit.`);
241
+ }
242
+
243
+ return buffer;
244
+ }
245
+
246
+ export function comparePngBuffers(baselineBuffer, currentBuffer, { PNG, pixelmatch }, options = {}) {
247
+ const threshold = options.threshold ?? DEFAULT_DIFF_THRESHOLD;
248
+ const maxDiffPixelRatio = options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO;
249
+ if (typeof threshold !== "number" || threshold < 0 || threshold > 1) {
250
+ throw new Error("threshold must be between 0 and 1.");
251
+ }
252
+
253
+ if (typeof maxDiffPixelRatio !== "number" || maxDiffPixelRatio < 0 || maxDiffPixelRatio > 1) {
254
+ throw new Error("maxDiffPixelRatio must be between 0 and 1.");
255
+ }
256
+
257
+ assertPngResourceBounds(baselineBuffer, "Baseline PNG");
258
+ assertPngResourceBounds(currentBuffer, "Current PNG");
259
+
260
+ const baseline = PNG.sync.read(baselineBuffer);
261
+ const current = PNG.sync.read(currentBuffer);
262
+ const dimensionsMatch = baseline.width === current.width && baseline.height === current.height;
263
+ if (!dimensionsMatch) {
264
+ const width = Math.max(baseline.width, current.width);
265
+ const height = Math.max(baseline.height, current.height);
266
+ if (width * height > MAX_CAPTURE_PIXELS) {
267
+ throw new Error("Dimension-mismatch diff would exceed browser image limits.");
268
+ }
269
+
270
+ const diff = new PNG({ width, height });
271
+ diff.data.fill(255);
272
+ for (let offset = 1; offset < diff.data.length; offset += 4) {
273
+ diff.data[offset] = 0;
274
+ diff.data[offset + 1] = 0;
275
+ }
276
+
277
+ return {
278
+ pass: false,
279
+ dimensionsMatch,
280
+ baseline: { width: baseline.width, height: baseline.height },
281
+ current: { width: current.width, height: current.height },
282
+ diffPixels: Math.max(baseline.width * baseline.height, current.width * current.height),
283
+ diffPixelRatio: 1,
284
+ diffBuffer: boundedPngWrite(PNG, diff, "Diff PNG"),
285
+ threshold,
286
+ maxDiffPixelRatio,
287
+ };
288
+ }
289
+
290
+ const diff = new PNG({ width: current.width, height: current.height });
291
+ const diffPixels = pixelmatch(baseline.data, current.data, diff.data, current.width, current.height, { threshold });
292
+ const pixelCount = current.width * current.height;
293
+ const diffPixelRatio = pixelCount === 0 ? 0 : diffPixels / pixelCount;
294
+
295
+ return {
296
+ pass: diffPixelRatio <= maxDiffPixelRatio,
297
+ dimensionsMatch,
298
+ baseline: { width: baseline.width, height: baseline.height },
299
+ current: { width: current.width, height: current.height },
300
+ diffPixels,
301
+ diffPixelRatio,
302
+ diffBuffer: boundedPngWrite(PNG, diff, "Diff PNG"),
303
+ threshold,
304
+ maxDiffPixelRatio,
305
+ };
306
+ }