From 28a1ae0b01fb17a8c0391115c24a8549efbab0f9 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sun, 30 Aug 2026 21:08:52 -0400 Subject: [PATCH 01/12] feat(sandbox,proc,agent): move artipod-sync lib into @artipod/core Verbatim move of lib/sandbox (incl. git/git-auth/storage/table + commands), lib/proc, lib/agent (loop, ozwell + local ONNX clients, worker) with their vitest suites (90 tests). Decoupled git.ts from the app fs singleton (factory-only; NEXT_PUBLIC_GIT_CORS_PROXY guarded for non-Next bundles). New subpath exports: ./sandbox ./proc ./agent (+ agent/local/worker). isomorphic-git + diff join the optional exact-pinned peers. --- .eslintrc.cjs | 2 +- package-lock.json | 2588 +++++++++++++++++++++++++-- package.json | 30 + src/agent/agent.test.ts | 164 ++ src/agent/index.ts | 43 + src/agent/local/client.ts | 117 ++ src/agent/local/local.test.ts | 109 ++ src/agent/local/model-cache.ts | 61 + src/agent/local/model-registry.ts | 77 + src/agent/local/parse-tool-calls.ts | 65 + src/agent/local/protocol.ts | 45 + src/agent/local/worker.ts | 226 +++ src/agent/loop.ts | 132 ++ src/agent/ozwell-client.ts | 91 + src/agent/tools.ts | 161 ++ src/agent/types.ts | 134 ++ src/proc/README.md | 30 + src/proc/index.ts | 29 + src/proc/proc.test.ts | 241 +++ src/proc/reconcile.ts | 71 + src/proc/registry.ts | 88 + src/proc/snapshot.ts | 110 ++ src/proc/storage-provider.ts | 125 ++ src/sandbox/edit-command.ts | 18 + src/sandbox/git-auth.ts | 83 + src/sandbox/git-command.test.ts | 150 ++ src/sandbox/git-command.ts | 172 ++ src/sandbox/git.ts | 284 +++ src/sandbox/index.ts | 166 ++ src/sandbox/module-command.ts | 104 ++ src/sandbox/notes-command.ts | 51 + src/sandbox/sandbox.test.ts | 126 ++ src/sandbox/storage-command.test.ts | 144 ++ src/sandbox/storage-command.ts | 578 ++++++ src/sandbox/storage.test.ts | 41 + src/sandbox/storage.ts | 390 ++++ src/sandbox/table.ts | 14 + src/sandbox/types.ts | 44 + src/sandbox/zenfs-adapter.test.ts | 139 ++ src/sandbox/zenfs-adapter.ts | 195 ++ tsconfig.json | 2 +- vitest.config.ts | 4 +- 42 files changed, 7254 insertions(+), 190 deletions(-) create mode 100644 src/agent/agent.test.ts create mode 100644 src/agent/index.ts create mode 100644 src/agent/local/client.ts create mode 100644 src/agent/local/local.test.ts create mode 100644 src/agent/local/model-cache.ts create mode 100644 src/agent/local/model-registry.ts create mode 100644 src/agent/local/parse-tool-calls.ts create mode 100644 src/agent/local/protocol.ts create mode 100644 src/agent/local/worker.ts create mode 100644 src/agent/loop.ts create mode 100644 src/agent/ozwell-client.ts create mode 100644 src/agent/tools.ts create mode 100644 src/agent/types.ts create mode 100644 src/proc/README.md create mode 100644 src/proc/index.ts create mode 100644 src/proc/proc.test.ts create mode 100644 src/proc/reconcile.ts create mode 100644 src/proc/registry.ts create mode 100644 src/proc/snapshot.ts create mode 100644 src/proc/storage-provider.ts create mode 100644 src/sandbox/edit-command.ts create mode 100644 src/sandbox/git-auth.ts create mode 100644 src/sandbox/git-command.test.ts create mode 100644 src/sandbox/git-command.ts create mode 100644 src/sandbox/git.ts create mode 100644 src/sandbox/index.ts create mode 100644 src/sandbox/module-command.ts create mode 100644 src/sandbox/notes-command.ts create mode 100644 src/sandbox/sandbox.test.ts create mode 100644 src/sandbox/storage-command.test.ts create mode 100644 src/sandbox/storage-command.ts create mode 100644 src/sandbox/storage.test.ts create mode 100644 src/sandbox/storage.ts create mode 100644 src/sandbox/table.ts create mode 100644 src/sandbox/types.ts create mode 100644 src/sandbox/zenfs-adapter.test.ts create mode 100644 src/sandbox/zenfs-adapter.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e195d90..e41f76a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,7 +14,7 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], }, env: { node: true, diff --git a/package-lock.json b/package-lock.json index 465e681..0c4fe45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,22 +12,30 @@ "dockerode": "^4.0.9" }, "devDependencies": { + "@huggingface/transformers": "^4.2.0", + "@types/diff": "^7.0.2", "@types/dockerode": "^3.3.47", "@types/node": "^20.10.6", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "@vitest/coverage-v8": "^3.2.4", "@zenfs/core": "2.4.4", + "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", "eslint": "^8.56.0", + "isomorphic-git": "^1.25.0", + "just-bash": "3.2.0", "typescript": "^5.3.3", "vitest": "^3.2.4" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "@zenfs/core": "2.4.4", "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", + "isomorphic-git": "^1.25.0", "just-bash": "3.2.0" }, "peerDependenciesMeta": { @@ -37,6 +45,12 @@ "@zenfs/dom": { "optional": true }, + "diff": { + "optional": true + }, + "isomorphic-git": { + "optional": true + }, "just-bash": { "optional": true } @@ -112,6 +126,28 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "license": "Apache-2.0" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -690,6 +726,37 @@ "node": ">=6" } }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -752,194 +819,767 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "license": "MIT", + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mongodb-js/zstd": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/zstd/-/zstd-7.0.0.tgz", + "integrity": "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "node-addon-api": "^8.5.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { "node": ">= 8" @@ -1394,6 +2034,31 @@ "win32" ] }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1412,6 +2077,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -2058,6 +2730,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@zenfs/dom": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@zenfs/dom/-/dom-1.2.5.tgz", + "integrity": "sha512-DdQzmROGvgen3JmBVdovnaIyAexPPlCNJ5Tb0e48N8W9TQJA5IuVsBgohBKGwDiGEDPquEMxzoycFD4RC6kjzA==", + "dev": true, + "license": "LGPL-3.0-or-later", + "engines": { + "node": ">= 22" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/james-pre" + }, + "peerDependencies": { + "@zenfs/core": "^2.3.11", + "kerium": "^1.3.4", + "utilium": "^2.5.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2094,6 +2785,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2135,6 +2836,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2190,6 +2904,29 @@ "dev": true, "license": "MIT" }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2237,6 +2974,14 @@ "readable-stream": "^3.4.0" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2303,6 +3048,56 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2363,6 +3158,13 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2395,6 +3197,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2416,6 +3228,19 @@ "node": ">=10.0.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2448,6 +3273,22 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2458,6 +3299,17 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2465,6 +3317,76 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "dev": true, + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2524,6 +3446,21 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2546,6 +3483,26 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2553,6 +3510,26 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -2829,6 +3806,17 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2890,6 +3878,47 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2913,6 +3942,25 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2958,6 +4006,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -2965,6 +4020,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3023,6 +4094,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3032,6 +4113,53 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3091,6 +4219,24 @@ "node": "*" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -3107,6 +4253,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -3128,6 +4291,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3135,6 +4311,13 @@ "dev": true, "license": "MIT" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3145,6 +4328,61 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3227,6 +4465,29 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3279,6 +4540,42 @@ "node": ">=8" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3286,6 +4583,74 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-git": { + "version": "1.41.9", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.41.9.tgz", + "integrity": "sha512-WOh4ujm5mznHphzQvwj9bVj+pQpV0oZ8H4lAnh4dSaie+lN/lJ2rb6rNOOcP+2m4z8IOQp186TkEULD28NMBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/isomorphic-git/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3341,39 +4706,121 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/just-bash": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-3.2.0.tgz", + "integrity": "sha512-hRTLLWBXCKuosjaNFJR7uPYBza+T2vjG3NdPBz4wxlctlnxwrbLjtLQf6RtSKmy/jzNJ6/URCtvxrXjy6yFGeQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "diff": "^8.0.2", + "fast-xml-parser": "^5.7.3", + "file-type": "^21.2.0", + "ini": "^6.0.0", + "minimatch": "^10.1.1", + "modern-tar": "^0.7.3", + "papaparse": "^5.5.3", + "quickjs-emscripten": "^0.32.0", + "re2js": "^1.2.1", + "seek-bzip": "^2.0.0", + "smol-toml": "^1.6.0", + "sprintf-js": "^1.1.3", + "sql.js": "^1.13.0", + "turndown": "^7.2.2", + "undici": "^7.25.0", + "yaml": "^2.8.2" + }, + "bin": { + "just-bash": "dist/bin/just-bash.js", + "just-bash-shell": "dist/bin/shell/shell.js" + }, + "engines": { + "node": ">=20.18.1" + }, + "optionalDependencies": { + "@mongodb-js/zstd": "^7.0.0", + "node-liblzma": "^2.0.3" + } + }, + "node_modules/just-bash/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/just-bash/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "balanced-match": "^4.0.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "20 || >=22" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/just-bash/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/kerium": { "version": "1.4.2", @@ -3533,6 +4980,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memium": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/memium/-/memium-0.3.11.tgz", @@ -3572,6 +5042,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -3588,6 +5071,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3604,6 +5107,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3636,6 +5149,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3643,6 +5164,77 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-liblzma": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-liblzma/-/node-liblzma-2.2.0.tgz", + "integrity": "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==", + "dev": true, + "hasInstallScript": true, + "license": "LGPL-3.0", + "optional": true, + "dependencies": { + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "bin": { + "nxz": "lib/cli/nxz.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/oorabona" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3652,6 +5244,53 @@ "wrappy": "1" } }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3709,6 +5348,20 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3732,6 +5385,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3823,6 +5492,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -3852,6 +5548,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3906,35 +5631,105 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/re2js": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-1.3.3.tgz", + "integrity": "sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT" }, "node_modules/readable-stream": { @@ -3998,6 +5793,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -4094,6 +5907,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/seek-bzip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", + "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^6.0.0" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4107,6 +5934,126 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4137,6 +6084,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4147,6 +6141,19 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4163,6 +6170,20 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", "license": "ISC" }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql.js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", + "dev": true, + "license": "MIT" + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -4292,6 +6313,39 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4432,6 +6486,21 @@ "node": ">=14.0.0" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4445,6 +6514,25 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4458,6 +6546,42 @@ "typescript": ">=4.2.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -4490,6 +6614,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4504,6 +6643,29 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4793,6 +6955,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4862,6 +7046,22 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -4871,6 +7071,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 972a546..54a1cef 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,22 @@ "types": "./dist/prompts/index.d.ts", "default": "./dist/prompts/index.js" }, + "./sandbox": { + "types": "./dist/sandbox/index.d.ts", + "default": "./dist/sandbox/index.js" + }, + "./proc": { + "types": "./dist/proc/index.d.ts", + "default": "./dist/proc/index.js" + }, + "./agent": { + "types": "./dist/agent/index.d.ts", + "default": "./dist/agent/index.js" + }, + "./agent/local/worker": { + "types": "./dist/agent/local/worker.d.ts", + "default": "./dist/agent/local/worker.js" + }, "./docker": { "types": "./dist/docker/index.d.ts", "default": "./dist/docker/index.js" @@ -62,13 +78,19 @@ "author": "Medical Informatics Engineering, LLC", "license": "MIT", "devDependencies": { + "@huggingface/transformers": "^4.2.0", + "@types/diff": "^7.0.2", "@types/dockerode": "^3.3.47", "@types/node": "^20.10.6", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "@vitest/coverage-v8": "^3.2.4", "@zenfs/core": "2.4.4", + "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", "eslint": "^8.56.0", + "isomorphic-git": "^1.25.0", + "just-bash": "3.2.0", "typescript": "^5.3.3", "vitest": "^3.2.4" }, @@ -78,6 +100,8 @@ "peerDependencies": { "@zenfs/core": "2.4.4", "@zenfs/dom": "1.2.5", + "diff": "^8.0.2", + "isomorphic-git": "^1.25.0", "just-bash": "3.2.0" }, "peerDependenciesMeta": { @@ -87,6 +111,12 @@ "@zenfs/dom": { "optional": true }, + "diff": { + "optional": true + }, + "isomorphic-git": { + "optional": true + }, "just-bash": { "optional": true } diff --git a/src/agent/agent.test.ts b/src/agent/agent.test.ts new file mode 100644 index 0000000..9aba597 --- /dev/null +++ b/src/agent/agent.test.ts @@ -0,0 +1,164 @@ +/** + * Agent loop tests with a scripted fake LLM (injected fetchFn) executing real + * bash tool calls against a sandbox, plus truncation behavior. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox, type Sandbox } from '../sandbox/index.js'; +import { ToolCallingLoop } from './loop.js'; +import { OzwellClient } from './ozwell-client.js'; +import { createSandboxTools, toMcpToolDescriptors, truncateOutput, MAX_TOOL_OUTPUT_BYTES } from './tools.js'; +import type { ChatCompletionResponse, ChatMessage, ToolCall } from './types.js'; + +let sandbox: Sandbox; + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + sandbox = createSandbox({ zfs }); +}); + +function scriptedClient(script: Array>): { client: OzwellClient; requests: unknown[] } { + const requests: unknown[] = []; + let call = 0; + const fetchFn: typeof fetch = async (_url, init) => { + requests.push(JSON.parse(String(init?.body))); + const message = script[Math.min(call++, script.length - 1)]; + const response: ChatCompletionResponse = { + id: `fake-${call}`, + object: 'chat.completion', + created: Date.now() / 1000, + model: 'fake', + choices: [ + { + index: 0, + message: { role: 'assistant', content: null, ...message } as ChatMessage, + finish_reason: message.tool_calls ? 'tool_calls' : 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + return new Response(JSON.stringify(response), { status: 200 }); + }; + const client = new OzwellClient({ baseUrl: 'http://fake', apiKey: 'k', fetchFn }); + return { client, requests }; +} + +const bashCall = (id: string, command: string): ToolCall => ({ + id, + type: 'function', + function: { name: 'bash', arguments: JSON.stringify({ command }) }, +}); + +describe('ToolCallingLoop with sandbox tools', () => { + it('executes tool calls and feeds results back until final response', async () => { + const { client, requests } = scriptedClient([ + { tool_calls: [bashCall('c1', 'echo hello > greet.txt'), bashCall('c2', 'cat greet.txt')] }, + { content: 'The file says hello.' }, + ]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + + const calls: string[] = []; + const result = await loop.run('write then read a file', { + onToolCall: (c) => calls.push(JSON.parse(c.function.arguments).command), + }); + + expect(result.content).toBe('The file says hello.'); + expect(result.iterations).toBe(2); + expect(calls).toEqual(['echo hello > greet.txt', 'cat greet.txt']); + expect(result.usage.total_tokens).toBe(30); + + // Tool results made it back into the conversation with matching ids. + const toolMsgs = result.messages.filter((m) => m.role === 'tool'); + expect(toolMsgs.map((m) => m.tool_call_id)).toEqual(['c1', 'c2']); + expect(JSON.parse(toolMsgs[1].content ?? '')).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(toolMsgs[1].content ?? '').stdout).toBe('hello\n'); + + // The second LLM request contained the first tool result. + expect(JSON.stringify(requests[1])).toContain('"tool_call_id":"c1"'); + + // File really exists in the sandbox. + expect(await zfs.promises.readFile('/repo/greet.txt', 'utf8')).toBe('hello\n'); + }); + + it('reports unknown tools without crashing the loop', async () => { + const { client } = scriptedClient([ + { + tool_calls: [ + { id: 'x', type: 'function', function: { name: 'nope', arguments: '{}' } }, + ], + }, + { content: 'ok' }, + ]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + const result = await loop.run('use a bad tool'); + const toolMsg = result.messages.find((m) => m.role === 'tool'); + expect(toolMsg?.content).toMatch(/Unknown tool: nope/); + expect(result.content).toBe('ok'); + }); + + it('stops at maxIterations', async () => { + const { client } = scriptedClient([{ tool_calls: [bashCall('l', 'true')] }]); // never final + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + await expect(loop.run('loop forever', { maxIterations: 3 })).rejects.toThrow(/Maximum iterations \(3\)/); + }); + + it('aborts between steps via AbortSignal', async () => { + const controller = new AbortController(); + const { client } = scriptedClient([{ tool_calls: [bashCall('a', 'true')] }]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + controller.abort(); + await expect(loop.run('x', { signal: controller.signal })).rejects.toThrow(/abort/i); + }); + + it('truncates giant tool output to protect the context window', async () => { + const { client } = scriptedClient([ + { tool_calls: [bashCall('big', 'seq 1 20000')] }, + { content: 'done' }, + ]); + const loop = new ToolCallingLoop(client, createSandboxTools(sandbox)); + const result = await loop.run('flood'); + const toolMsg = result.messages.find((m) => m.role === 'tool'); + const parsed = JSON.parse(toolMsg?.content ?? '{}'); + expect(parsed.stdout.length).toBeLessThanOrEqual(MAX_TOOL_OUTPUT_BYTES + 100); + expect(parsed.stdout).toMatch(/characters truncated/); + // head and tail survive + expect(parsed.stdout.startsWith('1\n2\n')).toBe(true); + expect(parsed.stdout.trimEnd().endsWith('20000')).toBe(true); + }); +}); + +describe('tool surface serializers', () => { + it('exposes the same tools as OpenAI definitions and MCP descriptors', () => { + const tools = createSandboxTools(sandbox); + const mcp = toMcpToolDescriptors(tools); + expect(mcp.map((t) => t.name).sort()).toEqual(['bash', 'list_files', 'read_file', 'write_file']); + const bash = mcp.find((t) => t.name === 'bash'); + expect(bash?.inputSchema.required).toEqual(['command']); + expect(bash?.inputSchema.type).toBe('object'); + }); + + it('read_file/write_file/list_files operate relative to the shell cwd', async () => { + const tools = createSandboxTools(sandbox); + await tools.get('write_file')!.execute({ path: 'note.md', content: 'hi' }); + const read = await tools.get('read_file')!.execute({ path: 'note.md' }); + expect(read).toMatchObject({ success: true, content: 'hi' }); + const listing = await tools.get('list_files')!.execute({}); + expect(listing.content).toMatch(/note\.md/); + }); +}); + +describe('truncateOutput', () => { + it('keeps short output untouched and marks long output', () => { + expect(truncateOutput('short')).toBe('short'); + const long = 'x'.repeat(MAX_TOOL_OUTPUT_BYTES + 1000); + const out = truncateOutput(long); + expect(out.length).toBeLessThan(long.length); + expect(out).toMatch(/\[1000 characters truncated\]/); + }); +}); diff --git a/src/agent/index.ts b/src/agent/index.ts new file mode 100644 index 0000000..85afad3 --- /dev/null +++ b/src/agent/index.ts @@ -0,0 +1,43 @@ +/** + * @artipod/core/agent — tool-calling loop, clients, and sandbox tool bindings. + * + * Framework-free (browser + Node). The local ONNX worker is exported as its + * own entry (`@artipod/core/agent/local/worker`) so bundlers can target it + * with `new Worker(new URL(...))`. + */ +export { ToolCallingLoop } from './loop.js'; +export { OzwellClient } from './ozwell-client.js'; +export type { OzwellClientConfig } from './ozwell-client.js'; +export { + createSandboxTools, + toOpenAiToolDefinitions, + toMcpToolDescriptors, + truncateOutput, + MAX_TOOL_OUTPUT_BYTES, +} from './tools.js'; +export type { + ChatCompletionClient, + ChatCompletionOptions, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + McpToolDescriptor, + MessageRole, + TokenUsage, + ToolCall, + ToolCallingLoopOptions, + ToolCallingLoopResult, + ToolDefinition, + ToolHandler, + ToolResult, +} from './types.js'; +export { LocalModelClient, webGpuAvailable } from './local/client.js'; +export type { LocalModelClientOptions } from './local/client.js'; +export { + CURATED_MODELS, + listLocalModels, + modelInfo, + type LocalModelInfo, + type OnnxDtype, +} from './local/model-registry.js'; +export { formatBytes, listCachedModels, modelIdFromCacheKey } from './local/model-cache.js'; diff --git a/src/agent/local/client.ts b/src/agent/local/client.ts new file mode 100644 index 0000000..043a4a4 --- /dev/null +++ b/src/agent/local/client.ts @@ -0,0 +1,117 @@ +/** + * LocalModelClient — ChatCompletionClient over the ONNX worker, so + * ToolCallingLoop works identically with in-browser models. WebGPU only. + */ +import type { + ChatCompletionClient, + ChatCompletionOptions, + ChatCompletionResponse, + ChatMessage, +} from '../types.js'; +import type { ResultEvent, WorkerRequest, WorkerResponse } from './protocol.js'; +import type { OnnxDtype } from './model-registry.js'; + +export interface LocalModelClientOptions { + modelId: string; + dtype?: OnnxDtype; + /** Download / load progress for the UI. */ + onProgress?: (status: string, file: string, progress?: number) => void; +} + +export function webGpuAvailable(): boolean { + return typeof navigator !== 'undefined' && 'gpu' in navigator; +} + +interface Pending { + resolve: (r: ResultEvent) => void; + reject: (e: Error) => void; +} + +export class LocalModelClient implements ChatCompletionClient { + private worker: Worker | null = null; + private nextId = 1; + private readonly pending = new Map(); + + constructor(private readonly opts: LocalModelClientOptions) {} + + private getWorker(): Worker { + if (!this.worker) { + this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); + this.worker.onmessage = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'progress') { + this.opts.onProgress?.(msg.status, msg.file, msg.progress); + return; + } + const pending = msg.id !== undefined ? this.pending.get(msg.id) : undefined; + if (!pending) return; + this.pending.delete(msg.id as number); + if (msg.type === 'result') pending.resolve(msg); + else pending.reject(new Error(msg.message)); + }; + this.worker.onerror = (e) => { + this.pending.forEach((p) => p.reject(new Error(e.message || 'local model worker crashed'))); + this.pending.clear(); + }; + } + return this.worker; + } + + async createChatCompletion( + messages: ChatMessage[], + options: ChatCompletionOptions = {}, + ): Promise { + if (!webGpuAvailable()) { + throw new Error('WebGPU is not available in this browser — local ONNX models need it.'); + } + + const id = this.nextId++; + const request: WorkerRequest = { + type: 'generate', + id, + modelId: this.opts.modelId, + dtype: this.opts.dtype ?? 'q4', + messages, + tools: options.tools, + maxNewTokens: options.maxTokens ?? 1024, + }; + + const worker = this.getWorker(); + const onAbort = () => worker.postMessage({ type: 'abort', id } satisfies WorkerRequest); + options.signal?.addEventListener('abort', onAbort, { once: true }); + + try { + const result = await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + worker.postMessage(request); + }); + + const message: ChatMessage = { + role: 'assistant', + content: result.toolCalls.length && !result.content ? null : result.content, + ...(result.toolCalls.length ? { tool_calls: result.toolCalls } : {}), + }; + return { + id: `local-${id}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: this.opts.modelId, + choices: [ + { + index: 0, + message, + finish_reason: result.toolCalls.length ? 'tool_calls' : 'stop', + }, + ], + }; + } finally { + options.signal?.removeEventListener('abort', onAbort); + } + } + + dispose(): void { + this.worker?.terminate(); + this.worker = null; + this.pending.clear(); + } +} diff --git a/src/agent/local/local.test.ts b/src/agent/local/local.test.ts new file mode 100644 index 0000000..362ca41 --- /dev/null +++ b/src/agent/local/local.test.ts @@ -0,0 +1,109 @@ +/** + * Local-model plumbing: tool-call parsing for Qwen/Llama output shapes and + * the registry's curated fallback behavior. + */ +import { describe, expect, it } from 'vitest'; +import { parseGeneration } from './parse-tool-calls.js'; +import { CURATED_MODELS, listLocalModels, modelInfo } from './model-registry.js'; +import { formatBytes, modelIdFromCacheKey } from './model-cache.js'; + +describe('model cache key mapping', () => { + it('maps HF resolve URLs back to repo ids', () => { + expect( + modelIdFromCacheKey('https://huggingface.co/onnx-community/Qwen2.5-0.5B-Instruct/resolve/main/onnx/model_q4.onnx'), + ).toBe('onnx-community/Qwen2.5-0.5B-Instruct'); + expect( + modelIdFromCacheKey('https://huggingface.co/onnx-community/Qwen2.5-0.5B-Instruct/resolve/main/tokenizer.json'), + ).toBe('onnx-community/Qwen2.5-0.5B-Instruct'); + }); + + it('ignores runtime assets and junk', () => { + expect( + modelIdFromCacheKey('https://cdn.jsdelivr.net/npm/onnxruntime-web@1.26.0/dist/ort-wasm.wasm'), + ).toBeNull(); + expect(modelIdFromCacheKey('not a url')).toBeNull(); + expect(modelIdFromCacheKey('https://huggingface.co/onnx-community')).toBeNull(); + }); + + it('formats sizes for the picker', () => { + expect(formatBytes(1_800_000_000)).toBe('1.8 GB'); + expect(formatBytes(786_000_000)).toBe('786 MB'); + expect(formatBytes(7_000)).toBe('7 kB'); + }); +}); + +describe('parseGeneration', () => { + it('extracts Qwen-style blocks', () => { + const out = parseGeneration( + 'Let me check.\n\n{"name": "bash", "arguments": {"command": "ls /repo"}}\n', + ); + expect(out.content).toBe('Let me check.'); + expect(out.toolCalls).toHaveLength(1); + expect(out.toolCalls[0].function.name).toBe('bash'); + expect(JSON.parse(out.toolCalls[0].function.arguments)).toEqual({ command: 'ls /repo' }); + }); + + it('extracts multiple blocks in order', () => { + const out = parseGeneration( + '{"name": "a", "arguments": {}}{"name": "b", "arguments": {"x": 1}}', + ); + expect(out.toolCalls.map((c) => c.function.name)).toEqual(['a', 'b']); + expect(out.content).toBe(''); + }); + + it('parses Llama-style bare JSON with parameters', () => { + const out = parseGeneration('{"name": "read_file", "parameters": {"path": "README.md"}}'); + expect(out.toolCalls).toHaveLength(1); + expect(out.toolCalls[0].function.name).toBe('read_file'); + expect(JSON.parse(out.toolCalls[0].function.arguments)).toEqual({ path: 'README.md' }); + expect(out.content).toBe(''); + }); + + it('leaves plain text and JSON-looking prose untouched', () => { + expect(parseGeneration('The answer is 42.')).toEqual({ content: 'The answer is 42.', toolCalls: [] }); + const prose = parseGeneration('{"not": "a tool call"}'); + expect(prose.toolCalls).toHaveLength(0); + expect(prose.content).toBe('{"not": "a tool call"}'); + }); + + it('keeps malformed tool_call blocks visible as content', () => { + const out = parseGeneration('{oops'); + expect(out.toolCalls).toHaveLength(0); + expect(out.content).toContain(''); + }); + + it('assigns unique ids', () => { + const out = parseGeneration( + '{"name": "a", "arguments": {}}{"name": "a", "arguments": {}}', + ); + expect(out.toolCalls[0].id).not.toBe(out.toolCalls[1].id); + }); +}); + +describe('model registry', () => { + it('falls back to the curated list when the Hub is unreachable', async () => { + const failingFetch = (() => Promise.reject(new Error('offline'))) as unknown as typeof fetch; + expect(await listLocalModels(failingFetch)).toEqual(CURATED_MODELS); + }); + + it('merges instruct-style Hub models after the curated ones', async () => { + const hubFetch = (() => + Promise.resolve( + new Response( + JSON.stringify([ + { id: 'onnx-community/Qwen2.5-1.5B-Instruct' }, // duplicate — skipped + { id: 'onnx-community/SomeChat-2B-Instruct' }, + { id: 'onnx-community/turn-detector-GQA-ONNX' }, // not instruct — skipped + ]), + ), + )) as unknown as typeof fetch; + const models = await listLocalModels(hubFetch); + expect(models.slice(0, CURATED_MODELS.length)).toEqual(CURATED_MODELS); + expect(models.map((m) => m.id)).toContain('onnx-community/SomeChat-2B-Instruct'); + expect(models.map((m) => m.id)).not.toContain('onnx-community/turn-detector-GQA-ONNX'); + }); + + it('modelInfo defaults unknown ids to q4', () => { + expect(modelInfo('onnx-community/Custom', CURATED_MODELS)).toMatchObject({ dtype: 'q4', curated: false }); + }); +}); diff --git a/src/agent/local/model-cache.ts b/src/agent/local/model-cache.ts new file mode 100644 index 0000000..aed1368 --- /dev/null +++ b/src/agent/local/model-cache.ts @@ -0,0 +1,61 @@ +/** + * Which models are already in the OPFS weight cache (artipod-models/). + * Cache files are named encodeURIComponent(), so decoding the + * names and grouping by HF repo id tells us what's downloaded. + */ + +const MODELS_DIR = 'artipod-models'; + +/** huggingface.co///resolve/... → "/"; null for runtime assets. */ +export function modelIdFromCacheKey(decodedUrl: string): string | null { + try { + const url = new URL(decodedUrl); + if (url.hostname !== 'huggingface.co') return null; + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length < 4 || segments[2] !== 'resolve') return null; + return `${segments[0]}/${segments[1]}`; + } catch { + return null; + } +} + +const isWeightsFile = (decodedUrl: string) => /\.onnx(_data.*)?$/.test(decodedUrl); + +export interface CachedModel { + bytes: number; + /** True once an .onnx/.onnx_data file is present (config-only ≠ downloaded). */ + hasWeights: boolean; +} + +export async function listCachedModels(): Promise> { + const cached = new Map(); + if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return cached; + try { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle(MODELS_DIR); + const entries = (dir as unknown as { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }).entries(); + for (;;) { + const { done, value } = await entries.next(); + if (done) break; + const [name, handle] = value; + if (handle.kind !== 'file') continue; + const decoded = decodeURIComponent(name); + const modelId = modelIdFromCacheKey(decoded); + if (!modelId) continue; + const size = (await (handle as FileSystemFileHandle).getFile()).size; + const entry = cached.get(modelId) ?? { bytes: 0, hasWeights: false }; + entry.bytes += size; + entry.hasWeights = entry.hasWeights || isWeightsFile(decoded); + cached.set(modelId, entry); + } + } catch { + // no cache dir yet + } + return cached; +} + +export function formatBytes(bytes: number): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; + if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`; + return `${Math.round(bytes / 1e3)} kB`; +} diff --git a/src/agent/local/model-registry.ts b/src/agent/local/model-registry.ts new file mode 100644 index 0000000..6317838 --- /dev/null +++ b/src/agent/local/model-registry.ts @@ -0,0 +1,77 @@ +/** + * In-browser ONNX model registry: curated tool-capable defaults merged with + * a live query of the Hugging Face Hub (the de-facto ONNX model registry — + * CORS-enabled REST API), so the dropdown stays current without shipping a + * hardcoded list. + */ + +/** Quantizations Transformers.js accepts for ONNX models. */ +export type OnnxDtype = 'auto' | 'q4' | 'q4f16' | 'q8' | 'int8' | 'uint8' | 'fp16' | 'fp32'; + +export interface LocalModelInfo { + /** HF repo id, e.g. onnx-community/Qwen2.5-1.5B-Instruct */ + id: string; + label: string; + /** Approximate q4 download size, shown in the picker. */ + approxSize?: string; + /** ONNX quantization to request. */ + dtype: OnnxDtype; + curated: boolean; +} + +export const CURATED_MODELS: LocalModelInfo[] = [ + { + id: 'onnx-community/Qwen2.5-1.5B-Instruct', + label: 'Qwen2.5 1.5B Instruct (best small tool-caller)', + approxSize: '~1.8 GB', + dtype: 'q4', + curated: true, + }, + { + id: 'onnx-community/Qwen2.5-0.5B-Instruct', + label: 'Qwen2.5 0.5B Instruct (fastest, weakest)', + approxSize: '~0.8 GB', + dtype: 'q4', + curated: true, + }, + { + id: 'onnx-community/Llama-3.2-1B-Instruct-ONNX', + label: 'Llama 3.2 1B Instruct', + approxSize: '~1.7 GB', + dtype: 'q4', + curated: true, + }, +]; + +export const DEFAULT_LOCAL_MODEL = CURATED_MODELS[0].id; + +const HUB_QUERY = + 'https://huggingface.co/api/models?author=onnx-community&pipeline_tag=text-generation&sort=downloads&direction=-1&limit=30'; + +interface HubModel { + id: string; +} + +/** Curated list first, then popular Hub extras; Hub failure degrades to curated. */ +export async function listLocalModels(fetchFn: typeof fetch = fetch): Promise { + const models = [...CURATED_MODELS]; + try { + const res = await fetchFn(HUB_QUERY); + if (res.ok) { + const hub = (await res.json()) as HubModel[]; + const known = new Set(models.map((m) => m.id)); + for (const { id } of hub) { + // only instruct/chat-style repos are useful for tool calling + if (known.has(id) || !/instruct|chat|it-/i.test(id)) continue; + models.push({ id, label: id.replace('onnx-community/', ''), dtype: 'q4', curated: false }); + } + } + } catch { + // offline / rate-limited: curated list is the fallback + } + return models; +} + +export function modelInfo(id: string, models: LocalModelInfo[]): LocalModelInfo { + return models.find((m) => m.id === id) ?? { id, label: id, dtype: 'q4', curated: false }; +} diff --git a/src/agent/local/parse-tool-calls.ts b/src/agent/local/parse-tool-calls.ts new file mode 100644 index 0000000..97f7db9 --- /dev/null +++ b/src/agent/local/parse-tool-calls.ts @@ -0,0 +1,65 @@ +/** + * Parse tool calls out of small-model chat output into OpenAI shape. + * Tolerates the two formats our supported templates emit: + * - Qwen: {"name": "...", "arguments": {...}} + * - Llama: a bare JSON object {"name": "...", "parameters": {...}} + */ +import type { ToolCall } from '../types.js'; + +export interface ParsedGeneration { + content: string; + toolCalls: ToolCall[]; +} + +let counter = 0; +const nextId = () => `local_call_${++counter}_${Date.now().toString(36)}`; + +interface RawCall { + name?: unknown; + arguments?: unknown; + parameters?: unknown; +} + +function toToolCall(raw: RawCall): ToolCall | null { + if (typeof raw?.name !== 'string' || !raw.name) return null; + const args = raw.arguments ?? raw.parameters ?? {}; + return { + id: nextId(), + type: 'function', + function: { + name: raw.name, + arguments: typeof args === 'string' ? args : JSON.stringify(args), + }, + }; +} + +const TOOL_CALL_BLOCK = /\s*([\s\S]*?)\s*<\/tool_call>/g; + +export function parseGeneration(output: string): ParsedGeneration { + const toolCalls: ToolCall[] = []; + + const content = output + .replace(TOOL_CALL_BLOCK, (_match, body: string) => { + try { + const call = toToolCall(JSON.parse(body)); + if (call) toolCalls.push(call); + } catch { + // malformed block: keep it visible as content instead of dropping it + return _match; + } + return ''; + }) + .trim(); + + if (toolCalls.length === 0 && content.startsWith('{') && content.endsWith('}')) { + // Llama-style bare JSON tool call + try { + const call = toToolCall(JSON.parse(content)); + if (call) return { content: '', toolCalls: [call] }; + } catch { + // plain JSON-looking prose: fall through + } + } + + return { content, toolCalls }; +} diff --git a/src/agent/local/protocol.ts b/src/agent/local/protocol.ts new file mode 100644 index 0000000..956a671 --- /dev/null +++ b/src/agent/local/protocol.ts @@ -0,0 +1,45 @@ +/** + * Message protocol between LocalModelClient and the model worker. + */ +import type { ChatMessage, ToolCall, ToolDefinition } from '../types.js'; +import type { OnnxDtype } from './model-registry.js'; + +export interface GenerateRequest { + type: 'generate'; + id: number; + modelId: string; + dtype: OnnxDtype; + messages: ChatMessage[]; + tools?: ToolDefinition[]; + maxNewTokens: number; +} + +export interface AbortRequest { + type: 'abort'; + id: number; +} + +export type WorkerRequest = GenerateRequest | AbortRequest; + +export interface ProgressEvent { + type: 'progress'; + file: string; + /** 0..100 for determinate files. */ + progress?: number; + status: string; +} + +export interface ResultEvent { + type: 'result'; + id: number; + content: string; + toolCalls: ToolCall[]; +} + +export interface ErrorEvent { + type: 'error'; + id?: number; + message: string; +} + +export type WorkerResponse = ProgressEvent | ResultEvent | ErrorEvent; diff --git a/src/agent/local/worker.ts b/src/agent/local/worker.ts new file mode 100644 index 0000000..a3ead78 --- /dev/null +++ b/src/agent/local/worker.ts @@ -0,0 +1,226 @@ +/** + * Web Worker running in-browser ONNX inference (Transformers.js / ONNX + * Runtime Web, WebGPU only — WASM is unusably slow at 1B+ params). + * + * Transformers.js is dynamic-imported from the jsDelivr CDN at runtime: its + * bundled onnxruntime-web .mjs breaks Next 14's SWC when webpack tries to + * bundle it into the worker chunk, and the library fetches its wasm/model + * assets from CDNs anyway. The npm package stays as a devtime type source. + * + * Model weights are cached in OPFS under artipod-models/ via a custom + * Transformers.js cache. That directory is a SIBLING of the sandbox mount + * (artipod-fs/), so cached weights are invisible to agent bash/read_file + * calls and are never swept up by storage migration. + */ +import { parseGeneration } from './parse-tool-calls.js'; +import type { WorkerRequest, WorkerResponse, GenerateRequest } from './protocol.js'; +import type { OnnxDtype } from './model-registry.js'; +import type { ChatMessage } from '../types.js'; + +type Transformers = typeof import('@huggingface/transformers'); +type PreTrainedTokenizer = import('@huggingface/transformers').PreTrainedTokenizer; +type PreTrainedModel = import('@huggingface/transformers').PreTrainedModel; + +const TRANSFORMERS_CDN = + 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0/dist/transformers.min.js'; + +let transformersPromise: Promise | null = null; + +function loadTransformers(): Promise { + if (!transformersPromise) { + transformersPromise = (async () => { + // webpackIgnore: resolved by the browser at runtime, not bundled + const mod = (await import(/* webpackIgnore: true */ TRANSFORMERS_CDN)) as Transformers; + mod.env.useBrowserCache = false; + mod.env.useCustomCache = true; + mod.env.customCache = new OpfsModelCache(); + return mod; + })(); + } + return transformersPromise; +} + +const post = (msg: WorkerResponse) => (self as unknown as { postMessage(m: unknown): void }).postMessage(msg); + +// --------------------------------------------------------------------------- +// OPFS-backed model cache (Transformers.js custom cache interface) +// --------------------------------------------------------------------------- + +const MODELS_DIR = 'artipod-models'; + +class OpfsModelCache { + private dir: Promise | null = null; + + private getDir(): Promise { + if (!this.dir) { + this.dir = navigator.storage + .getDirectory() + .then((root) => root.getDirectoryHandle(MODELS_DIR, { create: true })); + } + return this.dir; + } + + // one flat file per URL; encodeURIComponent keeps it traversal-safe + private fileName(key: string): string { + return encodeURIComponent(key); + } + + async match(key: string): Promise { + try { + const dir = await this.getDir(); + const handle = await dir.getFileHandle(this.fileName(key)); + const file = await handle.getFile(); + return new Response(file); + } catch { + return undefined; + } + } + + async put(key: string, response: Response): Promise { + try { + const dir = await this.getDir(); + const handle = await dir.getFileHandle(this.fileName(key), { create: true }); + const writable = await handle.createWritable(); + await response.body?.pipeTo(writable); + } catch { + // cache write failure is non-fatal; the download still succeeded + } + } +} + +// --------------------------------------------------------------------------- +// Model lifecycle — one loaded model at a time +// --------------------------------------------------------------------------- + +interface Loaded { + modelId: string; + tokenizer: PreTrainedTokenizer; + model: PreTrainedModel; +} + +let loaded: Loaded | null = null; +let loading: Promise | null = null; + +async function ensureModel(modelId: string, dtype: OnnxDtype): Promise { + if (loaded?.modelId === modelId) return loaded; + if (loading) await loading.catch(() => undefined); + if (loaded?.modelId === modelId) return loaded; + + if (!('gpu' in navigator)) { + throw new Error('WebGPU is not available in this browser — local models need it.'); + } + + if (loaded) { + await loaded.model.dispose(); + loaded = null; + } + + const progress_callback = (info: { status: string; file?: string; progress?: number }) => { + post({ + type: 'progress', + status: info.status, + file: info.file ?? modelId, + progress: info.progress, + }); + }; + + loading = (async () => { + const { AutoTokenizer, AutoModelForCausalLM } = await loadTransformers(); + const tokenizer = await AutoTokenizer.from_pretrained(modelId, { progress_callback }); + const model = await AutoModelForCausalLM.from_pretrained(modelId, { + device: 'webgpu', + dtype, + progress_callback, + }); + loaded = { modelId, tokenizer, model }; + return loaded; + })(); + + try { + return await loading; + } finally { + loading = null; + } +} + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +/** Chat templates want tool-call arguments as objects, OpenAI carries strings. */ +function templateMessages(messages: ChatMessage[]): Record[] { + return messages.map((m) => { + if (m.role === 'assistant' && m.tool_calls?.length) { + return { + role: m.role, + content: m.content ?? '', + tool_calls: m.tool_calls.map((tc) => ({ + type: 'function', + function: { + name: tc.function.name, + arguments: safeParse(tc.function.arguments), + }, + })), + }; + } + return { role: m.role, content: m.content ?? '', ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}) }; + }); +} + +function safeParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +const stoppers = new Map(); + +async function generate(req: GenerateRequest): Promise { + const { TextStreamer, InterruptableStoppingCriteria } = await loadTransformers(); + const { tokenizer, model } = await ensureModel(req.modelId, req.dtype); + + const prompt = tokenizer.apply_chat_template(templateMessages(req.messages) as never, { + tools: (req.tools ?? null) as never, + add_generation_prompt: true, + tokenize: false, + }) as string; + + const inputs = tokenizer(prompt, { return_tensors: 'pt' } as never); + const stopper = new InterruptableStoppingCriteria(); + stoppers.set(req.id, stopper); + + try { + const output = (await model.generate({ + ...inputs, + max_new_tokens: req.maxNewTokens, + do_sample: false, // greedy: small models emit better-formed tool JSON + stopping_criteria: stopper, + streamer: new TextStreamer(tokenizer, { skip_prompt: true, skip_special_tokens: true }), + } as never)) as { slice(batch: null, seq: [number, null]): unknown }; + + const inputLength = (inputs as { input_ids: { dims: number[] } }).input_ids.dims[1]; + // Tensor.slice(batchDim, seqDim): keep all batches, drop the prompt tokens + const newTokens = output.slice(null, [inputLength, null]); + const text = (tokenizer.batch_decode(newTokens as never, { skip_special_tokens: true }) as string[])[0] ?? ''; + + const { content, toolCalls } = parseGeneration(text); + post({ type: 'result', id: req.id, content, toolCalls }); + } finally { + stoppers.delete(req.id); + } +} + +self.onmessage = async (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'abort') { + stoppers.get(msg.id)?.interrupt(); + return; + } + try { + await generate(msg); + } catch (e) { + post({ type: 'error', id: msg.id, message: e instanceof Error ? e.message : String(e) }); + } +}; diff --git a/src/agent/loop.ts b/src/agent/loop.ts new file mode 100644 index 0000000..59917ec --- /dev/null +++ b/src/agent/loop.ts @@ -0,0 +1,132 @@ +/** + * ToolCallingLoop — ported from ozwell-artipod, browser-safe, with abort. + * + * 1. Send messages to the LLM + * 2. If it returns tool calls, execute them (against the sandbox) + * 3. Feed tool results back + * 4. Repeat until a final text response (or maxIterations / abort) + */ +import type { + ChatCompletionClient, + ChatMessage, + ToolCall, + ToolCallingLoopOptions, + ToolCallingLoopResult, + ToolDefinition, + ToolHandler, + ToolResult, +} from './types.js'; + +const DEFAULT_MAX_ITERATIONS = 10; + +export class ToolCallingLoop { + private readonly toolDefinitions: ToolDefinition[]; + + constructor( + private readonly client: ChatCompletionClient, + private readonly tools: Map, + ) { + this.toolDefinitions = Array.from(tools.values()).map((t) => t.definition); + } + + async run(userMessage: string, options: ToolCallingLoopOptions = {}): Promise { + const messages: ChatMessage[] = []; + if (options.systemPrompt) { + messages.push({ role: 'system', content: options.systemPrompt }); + } + messages.push({ role: 'user', content: userMessage }); + return this.runWithHistory(messages, options); + } + + /** Run the loop over an existing conversation (multi-turn). Mutates nothing; returns the new message list. */ + async runWithHistory( + history: ChatMessage[], + options: ToolCallingLoopOptions = {}, + ): Promise { + const { maxIterations = DEFAULT_MAX_ITERATIONS, signal, onAssistantMessage, onToolCall, onToolResult } = options; + + const messages = [...history]; + let iterations = 0; + const usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; + + while (iterations < maxIterations) { + if (signal?.aborted) throw new Error('Agent run aborted'); + iterations++; + + const response = await this.client.createChatCompletion(messages, { + tools: this.toolDefinitions, + toolChoice: 'auto', + signal, + }); + + if (response.usage) { + usage.prompt_tokens += response.usage.prompt_tokens; + usage.completion_tokens += response.usage.completion_tokens; + usage.total_tokens += response.usage.total_tokens; + } + + const choice = response.choices[0]; + if (!choice) throw new Error('No response choice returned from LLM'); + + const assistantMessage = choice.message; + messages.push(assistantMessage); + + const toolCalls = assistantMessage.tool_calls; + if (!toolCalls || toolCalls.length === 0) { + const content = assistantMessage.content ?? ''; + onAssistantMessage?.(content); + return { content, messages, iterations, usage }; + } + + // Content alongside tool calls is still worth surfacing. + if (assistantMessage.content) onAssistantMessage?.(assistantMessage.content); + + for (const toolCall of toolCalls) { + if (signal?.aborted) throw new Error('Agent run aborted'); + onToolCall?.(toolCall); + const result = await this.executeToolCall(toolCall, signal); + onToolResult?.(toolCall, result); + messages.push({ + role: 'tool', + content: result.success ? result.content : `Error: ${result.error}`, + tool_call_id: toolCall.id, + }); + } + } + + throw new Error(`Maximum iterations (${maxIterations}) reached without a final response`); + } + + private async executeToolCall(toolCall: ToolCall, signal?: AbortSignal): Promise { + const toolName = toolCall.function.name; + const handler = this.tools.get(toolName); + if (!handler) { + return { + success: false, + content: '', + error: `Unknown tool: ${toolName}. Available tools: ${Array.from(this.tools.keys()).join(', ')}`, + }; + } + try { + const args = JSON.parse(toolCall.function.arguments || '{}'); + return await handler.execute(args, signal); + } catch (error) { + if (error instanceof SyntaxError) { + return { + success: false, + content: '', + error: `Invalid JSON arguments for tool ${toolName}: ${toolCall.function.arguments}`, + }; + } + return { + success: false, + content: '', + error: error instanceof Error ? error.message : String(error), + }; + } + } + + getToolDefinitions(): ToolDefinition[] { + return this.toolDefinitions; + } +} diff --git a/src/agent/ozwell-client.ts b/src/agent/ozwell-client.ts new file mode 100644 index 0000000..783e67e --- /dev/null +++ b/src/agent/ozwell-client.ts @@ -0,0 +1,91 @@ +/** + * Trimmed OpenAI-compatible client (Ozwell, ozwellai-api reference server, + * OpenAI, …). Plain fetch — works in browser and Node. Streaming can come + * later; the loop only needs non-streaming completions. + */ +import type { + ChatCompletionClient, + ChatCompletionOptions, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, +} from './types.js'; + +export type { ChatCompletionOptions } from './types.js'; + +export interface OzwellClientConfig { + baseUrl: string; + apiKey: string; + defaultModel?: string; + /** Request timeout in ms (default 120000). */ + timeout?: number; + /** Injectable for tests; defaults to global fetch. */ + fetchFn?: typeof fetch; +} + +export class OzwellClient implements ChatCompletionClient { + private readonly config: Required> & { + fetchFn: typeof fetch; + }; + + constructor(config: OzwellClientConfig) { + this.config = { + baseUrl: config.baseUrl.replace(/\/$/, ''), + apiKey: config.apiKey, + defaultModel: config.defaultModel ?? 'gpt-4o-mini', + timeout: config.timeout ?? 120_000, + // Wrap: an unbound window.fetch throws "Illegal invocation" in browsers. + fetchFn: config.fetchFn ?? ((...args: Parameters) => fetch(...args)), + }; + } + + async createChatCompletion( + messages: ChatMessage[], + options: ChatCompletionOptions = {}, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + const onOuterAbort = () => controller.abort(); + options.signal?.addEventListener('abort', onOuterAbort, { once: true }); + + const request: ChatCompletionRequest = { + model: options.model ?? this.config.defaultModel, + messages, + temperature: options.temperature ?? 0.7, + max_tokens: options.maxTokens ?? 4096, + stream: false, + }; + if (options.tools && options.tools.length > 0) { + request.tools = options.tools; + request.tool_choice = options.toolChoice ?? 'auto'; + } + + try { + const response = await this.config.fetchFn(`${this.config.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify(request), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`); + } + return (await response.json()) as ChatCompletionResponse; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + options.signal?.aborted ? 'Request aborted' : `Request timed out after ${this.config.timeout}ms`, + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + options.signal?.removeEventListener('abort', onOuterAbort); + } + } +} diff --git a/src/agent/tools.ts b/src/agent/tools.ts new file mode 100644 index 0000000..dc47371 --- /dev/null +++ b/src/agent/tools.ts @@ -0,0 +1,161 @@ +/** + * Sandbox-bound tools for the agent loop: `bash` plus read/write/list + * conveniences (cheaper in tokens than cat/echo round-trips). + * + * One source of truth for the tool surface, two serializers: + * OpenAI function-call shape (ToolDefinition, used by the loop) and an + * MCP-style descriptor (toMcpToolDescriptors, for MCPToolCall-style UIs). + */ +import type { Sandbox } from '../sandbox/types.js'; +import type { McpToolDescriptor, ToolDefinition, ToolHandler, ToolResult } from './types.js'; + +/** Cap tool output to protect the model's context window (head + tail). */ +export const MAX_TOOL_OUTPUT_BYTES = 16 * 1024; + +export function truncateOutput(text: string, maxBytes = MAX_TOOL_OUTPUT_BYTES): string { + if (text.length <= maxBytes) return text; + const half = Math.floor(maxBytes / 2); + const omitted = text.length - half * 2; + return `${text.slice(0, half)}\n…[${omitted} characters truncated]…\n${text.slice(-half)}`; +} + +const okResult = (content: string): ToolResult => ({ success: true, content }); +const errResult = (error: string): ToolResult => ({ success: false, content: '', error }); + +export function createSandboxTools(sandbox: Sandbox): Map { + const tools = new Map(); + + tools.set('bash', { + definition: { + type: 'function', + function: { + name: 'bash', + description: + 'Execute a bash command in the persistent sandbox (pipes, globs, redirects, ~90 coreutils, plus git and edit). cwd, variables and aliases persist between calls. Output is truncated to 16 KiB.', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: 'The bash command line to execute' }, + }, + required: ['command'], + }, + }, + }, + execute: async (args, signal) => { + const command = String(args.command ?? ''); + if (!command) return errResult('missing "command"'); + const r = await sandbox.exec(command, { signal }); + const body = JSON.stringify({ + stdout: truncateOutput(r.stdout), + stderr: truncateOutput(r.stderr), + exitCode: r.exitCode, + }); + return okResult(body); + }, + }); + + tools.set('read_file', { + definition: { + type: 'function', + function: { + name: 'read_file', + description: 'Read a text file from the sandbox filesystem (cheaper than bash cat).', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path (absolute, or relative to the shell cwd)' }, + }, + required: ['path'], + }, + }, + }, + execute: async (args) => { + const path = resolveArgPath(sandbox, args.path); + if (!path) return errResult('missing "path"'); + try { + return okResult(truncateOutput(await sandbox.fs.readFile(path))); + } catch (e) { + return errResult((e as Error).message); + } + }, + }); + + tools.set('write_file', { + definition: { + type: 'function', + function: { + name: 'write_file', + description: 'Write (create or overwrite) a text file in the sandbox filesystem.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path (absolute, or relative to the shell cwd)' }, + content: { type: 'string', description: 'Full file content' }, + }, + required: ['path', 'content'], + }, + }, + }, + execute: async (args) => { + const path = resolveArgPath(sandbox, args.path); + if (!path) return errResult('missing "path"'); + try { + await sandbox.fs.writeFile(path, String(args.content ?? '')); + return okResult(`wrote ${path}`); + } catch (e) { + return errResult((e as Error).message); + } + }, + }); + + tools.set('list_files', { + definition: { + type: 'function', + function: { + name: 'list_files', + description: 'List directory entries (directories get a trailing slash).', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'Directory path; defaults to the shell cwd' }, + }, + }, + }, + }, + execute: async (args) => { + const path = resolveArgPath(sandbox, args.path ?? '.') ?? sandbox.getCwd(); + try { + const entries = await sandbox.fs.readdirWithFileTypes(path); + const listing = entries + .map((e) => (e.isDirectory ? `${e.name}/` : e.name)) + .sort() + .join('\n'); + return okResult(truncateOutput(listing)); + } catch (e) { + return errResult((e as Error).message); + } + }, + }); + + return tools; +} + +function resolveArgPath(sandbox: Sandbox, value: unknown): string | null { + const raw = String(value ?? ''); + if (!raw) return null; + return sandbox.fs.resolvePath(sandbox.getCwd(), raw); +} + +/** OpenAI function-call JSON shape (what the loop / ozwellChat speak). */ +export function toOpenAiToolDefinitions(tools: Map): ToolDefinition[] { + return Array.from(tools.values()).map((t) => t.definition); +} + +/** MCP-style descriptors (what MCPToolCall-style components render). */ +export function toMcpToolDescriptors(tools: Map): McpToolDescriptor[] { + return Array.from(tools.values()).map(({ definition: { function: fn } }) => ({ + name: fn.name, + description: fn.description, + inputSchema: fn.parameters, + })); +} diff --git a/src/agent/types.ts b/src/agent/types.ts new file mode 100644 index 0000000..8a5f462 --- /dev/null +++ b/src/agent/types.ts @@ -0,0 +1,134 @@ +/** + * OpenAI-compatible tool-calling types, ported from ozwell-artipod + * (transport-clean; browser + server). Framework-free like lib/sandbox/. + */ + +export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface ChatMessage { + role: MessageRole; + content: string | null; + name?: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +export interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +export interface ToolParameterProperty { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description: string; + enum?: string[]; + items?: ToolParameterProperty; +} + +/** OpenAI function-call tool definition. */ +export interface ToolDefinition { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; + }; +} + +/** MCP-style tool descriptor (what MCPToolCall-style UIs render). */ +export interface McpToolDescriptor { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +export interface ToolResult { + success: boolean; + content: string; + error?: string; +} + +export interface ToolHandler { + definition: ToolDefinition; + execute: (args: Record, signal?: AbortSignal) => Promise; +} + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + tools?: ToolDefinition[]; + tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }; + temperature?: number; + max_tokens?: number; + stream?: boolean; +} + +export interface ChatCompletionChoice { + index: number; + message: ChatMessage; + finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null; +} + +export interface ChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: ChatCompletionChoice[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface TokenUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface ChatCompletionOptions { + model?: string; + tools?: ToolDefinition[]; + toolChoice?: 'auto' | 'none'; + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; +} + +/** Anything the loop can talk to: the remote OzwellClient or an in-browser model. */ +export interface ChatCompletionClient { + createChatCompletion( + messages: ChatMessage[], + options?: ChatCompletionOptions, + ): Promise; +} + +export interface ToolCallingLoopOptions { + maxIterations?: number; + systemPrompt?: string; + signal?: AbortSignal; + onAssistantMessage?: (content: string) => void; + onToolCall?: (toolCall: ToolCall) => void; + onToolResult?: (toolCall: ToolCall, result: ToolResult) => void; +} + +export interface ToolCallingLoopResult { + content: string; + messages: ChatMessage[]; + iterations: number; + usage: TokenUsage; +} diff --git a/src/proc/README.md b/src/proc/README.md new file mode 100644 index 0000000..c2924c0 --- /dev/null +++ b/src/proc/README.md @@ -0,0 +1,30 @@ +# `lib/proc` — host state as files + +`/proc` is a snapshot mount. Before every command the sandbox throws the mount +away and rebuilds it from the registered **providers**; after the command, files +under `rw` providers that changed are handed back to their provider. + +| File | Role | +| --- | --- | +| [registry.ts](registry.ts) | what a provider is, and who is registered/enabled | +| [snapshot.ts](snapshot.ts) | mounts `/proc`, writes provider trees, records hashes | +| [reconcile.ts](reconcile.ts) | write-back for `rw` providers | +| [storage-provider.ts](storage-provider.ts) | the one built-in provider: raw IndexedDB / OPFS | + +A provider is shaped like a kernel module on purpose, so `lsmod`, `modinfo` and +`modprobe` (in [../sandbox/module-command.ts](../sandbox/module-command.ts)) are +the natural UI: + +```ts +registerProcProvider({ + name: 'route', + description: 'the current hash route', + mode: 'ro', + root: '', // writes /proc/route.json + read: async () => ({ 'route.json': JSON.stringify(route) }), +}); +``` + +Enable the framework with `createSandbox({ proc: true })`; nothing else needs +wiring. artipod-sync ships only `storage` — every other projection belongs to +the app embedding the sandbox. diff --git a/src/proc/index.ts b/src/proc/index.ts new file mode 100644 index 0000000..8c27aa6 --- /dev/null +++ b/src/proc/index.ts @@ -0,0 +1,29 @@ +/** + * The proc framework: host state projected into `/proc` as files. + * + * Apps register providers; the sandbox refreshes the snapshot before every + * command and reconciles writes back afterwards. See ./README.md. + */ +export { + clearProcProviders, + enabledProviders, + getProvider, + listProviders, + providerRoot, + registerProcProvider, + setProviderEnabled, +} from './registry.js'; +export type { ProcModule, ProcProvider, ProcTree } from './registry.js'; +export { + hashContent, + mkdirp, + PROC_MOUNT, + procEntries, + procPathOf, + refreshProc, + unmountProc, + writeTree, +} from './snapshot.js'; +export type { ProcEntry } from './snapshot.js'; +export { reconcileProc } from './reconcile.js'; +export { registerBuiltinProviders, storageProvider } from './storage-provider.js'; diff --git a/src/proc/proc.test.ts b/src/proc/proc.test.ts new file mode 100644 index 0000000..aa4d6f0 --- /dev/null +++ b/src/proc/proc.test.ts @@ -0,0 +1,241 @@ +/** + * The proc framework: registry, snapshot rebuild, rw write-back — plus the + * shell surface (`lsmod`/`modprobe`, `mount`/`umount`) that exposes them. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox, type Sandbox } from '../sandbox/index.js'; +import { + clearProcProviders, + listProviders, + registerProcProvider, + setProviderEnabled, +} from './registry.js'; +import { procEntries, refreshProc, unmountProc, writeTree } from './snapshot.js'; +import { reconcileProc } from './reconcile.js'; + +let sandbox: Sandbox; + +beforeEach(async () => { + await unmountProc(); + clearProcProviders(); + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + sandbox = createSandbox({ zfs, proc: true, cwd: '/repo' }); +}); + +const provider = (name: string, tree: Record) => ({ + name, + description: `${name} test provider`, + version: '1', + mode: 'ro' as const, + read: async () => tree, +}); + +describe('registry', () => { + it('unregisters through the returned function', () => { + const off = registerProcProvider(provider('a', {})); + expect(listProviders()).toHaveLength(2); // 'a' plus the built-in storage + off(); + expect(listProviders().map((m) => m.provider.name)).toEqual(['storage']); + }); + + it('refuses a duplicate name and an rw provider with no write()', () => { + registerProcProvider(provider('a', {})); + expect(() => registerProcProvider(provider('a', {}))).toThrow(/already registered/); + expect(() => + registerProcProvider({ name: 'b', mode: 'rw', read: async () => ({}) }), + ).toThrow(/no write/); + }); +}); + +describe('snapshot', () => { + it('writes each provider tree under /proc/', async () => { + registerProcProvider(provider('zustand', { 'form.json': '{"a":1}' })); + await refreshProc(zfs); + expect(await zfs.promises.readFile('/proc/zustand/form.json', 'utf8')).toBe('{"a":1}'); + }); + + it("writes a root-less provider straight into /proc", async () => { + registerProcProvider({ ...provider('route', {}), root: '', read: async () => ({ 'route.json': '"#/"' }) }); + await refreshProc(zfs); + expect(await zfs.promises.readFile('/proc/route.json', 'utf8')).toBe('"#/"'); + }); + + it('rebuilds from scratch, so stale files do not survive', async () => { + let tree: Record = { 'a.json': '1', 'b.json': '2' }; + registerProcProvider({ ...provider('x', {}), read: async () => tree }); + await refreshProc(zfs); + tree = { 'a.json': '3' }; + await refreshProc(zfs); + expect(await zfs.promises.readdir('/proc/x')).toEqual(['a.json']); + expect(await zfs.promises.readFile('/proc/x/a.json', 'utf8')).toBe('3'); + }); + + it('reports a throwing provider without losing the others', async () => { + registerProcProvider({ + ...provider('bad', {}), + read: async () => { + throw new Error('nope'); + }, + }); + registerProcProvider(provider('good', { 'ok.json': '1' })); + const errors = await refreshProc(zfs); + expect(errors).toEqual(['proc: bad: nope']); + expect(await zfs.promises.exists('/proc/good/ok.json')).toBe(true); + }); + + it('mkdir -p writeTree also works on the persistent tree', async () => { + await writeTree(zfs, '/artipods/demo', { 'forms/case.yaml': 'id: case\n' }); + expect(await zfs.promises.readFile('/artipods/demo/forms/case.yaml', 'utf8')).toBe('id: case\n'); + }); +}); + +describe('reconcile', () => { + it('hands a changed rw file back to its provider, and ignores untouched ones', async () => { + const writes: [string, string][] = []; + let stored = 'value: 1\n'; + registerProcProvider({ + name: 'cases', + mode: 'rw', + read: async () => ({ 'responses.yaml': stored }), + write: async (rel, content) => { + writes.push([rel, new TextDecoder().decode(content)]); + stored = new TextDecoder().decode(content); + }, + }); + + await refreshProc(zfs); + expect(await reconcileProc(zfs)).toEqual([]); + expect(writes).toHaveLength(0); + + await zfs.promises.writeFile('/proc/cases/responses.yaml', 'value: 2\n'); + await reconcileProc(zfs); + expect(writes).toEqual([['responses.yaml', 'value: 2\n']]); + }); + + it('surfaces a provider write error and reverts on the next refresh', async () => { + registerProcProvider({ + name: 'cases', + mode: 'rw', + read: async () => ({ 'responses.yaml': 'value: 1\n' }), + write: async () => { + throw new Error('malformed'); + }, + }); + await refreshProc(zfs); + await zfs.promises.writeFile('/proc/cases/responses.yaml', 'garbage'); + expect(await reconcileProc(zfs)).toEqual(['proc: cases: responses.yaml: malformed']); + await refreshProc(zfs); + expect(await zfs.promises.readFile('/proc/cases/responses.yaml', 'utf8')).toBe('value: 1\n'); + }); + + it('runs around each command, so the shell sees fresh state and edits land', async () => { + let stored = 'value: 1\n'; + registerProcProvider({ + name: 'cases', + mode: 'rw', + read: async () => ({ 'responses.yaml': stored }), + write: async (_rel, content) => { + stored = new TextDecoder().decode(content); + }, + }); + const write = await sandbox.exec("echo 'value: 2' > /proc/cases/responses.yaml"); + expect(write.exitCode).toBe(0); + expect(stored).toBe('value: 2\n'); + expect((await sandbox.exec('cat /proc/cases/responses.yaml')).stdout).toBe('value: 2\n'); + }); +}); + +describe('module commands', () => { + beforeEach(() => { + registerProcProvider(provider('zustand', { 'form.json': '{}' })); + }); + + it('lsmod lists the providers with their file counts', async () => { + const r = await sandbox.exec('lsmod'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/^Module\s+Mode\s+Files\s+State$/m); + expect(r.stdout).toMatch(/^zustand\s+ro\s+1\s+Live$/m); + }); + + it('modinfo prints the provider metadata', async () => { + const r = await sandbox.exec('modinfo zustand'); + expect(r.stdout).toContain('description: zustand test provider'); + expect(r.stdout).toContain('file: /proc/zustand/form.json'); + expect((await sandbox.exec('modinfo nope')).exitCode).toBe(1); + }); + + it('modprobe -r hides the provider from /proc, and modprobe brings it back', async () => { + expect((await sandbox.exec('ls /proc')).stdout).toContain('zustand'); + await sandbox.exec('modprobe -r zustand'); + expect(setProviderEnabled('zustand', false)).toBe(true); + expect((await sandbox.exec('ls /proc')).stdout).not.toContain('zustand'); + await sandbox.exec('modprobe zustand'); + expect((await sandbox.exec('ls /proc')).stdout).toContain('zustand'); + }); +}); + +describe('mount / umount', () => { + it('mounts an in-memory filesystem anywhere and shows it in the storage views', async () => { + expect((await sandbox.exec('mount -t memory /scratch')).exitCode).toBe(0); + await sandbox.exec('echo hi > /scratch/note.txt'); + expect((await sandbox.exec('cat /scratch/note.txt')).stdout).toBe('hi\n'); + + for (const view of ['mount', 'df', 'findmnt', 'lsblk']) { + expect((await sandbox.exec(view)).stdout, view).toContain('/scratch'); + } + }); + + it('rejects an unknown type and a mount point already in use', async () => { + expect((await sandbox.exec('mount -t nfs /scratch')).stderr).toMatch(/unknown filesystem type/); + await sandbox.exec('mount -t memory /scratch'); + expect((await sandbox.exec('mount -t memory /scratch')).stderr).toMatch(/already in use/); + }); + + it('unmounts a memory device but refuses / and /proc', async () => { + await sandbox.exec('mount -t memory /scratch'); + expect((await sandbox.exec('umount /scratch')).exitCode).toBe(0); + expect((await sandbox.exec('umount /scratch')).stderr).toMatch(/not mounted/); + expect((await sandbox.exec('umount /')).stderr).toMatch(/cannot unmount/); + expect((await sandbox.exec('umount /proc')).stderr).toMatch(/cannot unmount/); + }); +}); + +describe('the snapshot is only refreshed for real commands', () => { + it('leaves the entry table alone for transient execs', async () => { + registerProcProvider(provider('zustand', { 'form.json': '{}' })); + await sandbox.exec('true'); + const before = [...procEntries().keys()]; + await sandbox.complete('ls /pr'); + expect([...procEntries().keys()]).toEqual(before); + }); +}); + +describe('host hooks', () => { + it('run around each command and report on stderr', async () => { + const calls: string[] = []; + const hooked = createSandbox({ + zfs, + cwd: '/repo', + hooks: { + beforeExec: () => { + calls.push('before'); + }, + afterExec: () => { + calls.push('after'); + return ['cases: responses.yaml: bad indentation']; + }, + }, + }); + const r = await hooked.exec('echo hi'); + expect(calls).toEqual(['before', 'after']); + expect(r.stdout).toBe('hi\n'); + expect(r.stderr).toContain('cases: responses.yaml: bad indentation'); + }); +}); diff --git a/src/proc/reconcile.ts b/src/proc/reconcile.ts new file mode 100644 index 0000000..a06a68e --- /dev/null +++ b/src/proc/reconcile.ts @@ -0,0 +1,71 @@ +/** + * Write-back for `rw` proc providers. + * + * After a command, every file under an `rw` provider whose content hash moved + * away from the refresh baseline is handed to `provider.write()`. The provider + * decides how — and whether — the write applies; a throw surfaces on stderr and + * the file reverts on the next refresh, because the snapshot is rebuilt from + * `read()` regardless. + */ +import { enabledProviders, providerRoot, type ProcProvider } from './registry.js'; +import { hashContent, PROC_MOUNT, procEntries } from './snapshot.js'; +import type { ZenFsLike } from '../sandbox/types.js'; + +export async function reconcileProc(zfs: ZenFsLike): Promise { + const errors: string[] = []; + for (const provider of enabledProviders()) { + if (provider.mode !== 'rw' || !provider.write) continue; + for (const [path, rel] of await currentFiles(zfs, provider)) { + let bytes: Uint8Array; + try { + bytes = (await zfs.promises.readFile(path)) as Uint8Array; + } catch { + continue; // removed by the command; the next refresh restores it + } + if (procEntries().get(path)?.hash === hashContent(bytes)) continue; + try { + await provider.write(rel, bytes); + } catch (e) { + errors.push(`proc: ${provider.name}: ${rel}: ${(e as Error).message}`); + } + } + } + return errors; +} + +/** + * Absolute path → provider-relative path for everything the provider currently + * owns. Providers rooted at `/proc` itself cannot be walked (the directory is + * shared), so only the files the last refresh wrote are considered. + */ +async function currentFiles( + zfs: ZenFsLike, + provider: ProcProvider, +): Promise> { + const root = providerRoot(provider); + const files = new Map(); + if (!root) { + for (const [path, entry] of procEntries()) { + if (entry.provider === provider) files.set(path, entry.rel); + } + return files; + } + + const base = `${PROC_MOUNT}/${root}`; + const walk = async (dir: string): Promise => { + let names: string[]; + try { + names = await zfs.promises.readdir(dir); + } catch { + return; + } + for (const name of names) { + const path = `${dir}/${name}`; + const stat = await zfs.promises.lstat(path); + if (stat.isDirectory()) await walk(path); + else files.set(path, path.slice(base.length + 1)); + } + }; + await walk(base); + return files; +} diff --git a/src/proc/registry.ts b/src/proc/registry.ts new file mode 100644 index 0000000..9fb0cc7 --- /dev/null +++ b/src/proc/registry.ts @@ -0,0 +1,88 @@ +/** + * The proc provider registry. + * + * A *provider* projects host state into the `/proc` tree. It is deliberately + * shaped like a kernel module — name, description, version, load state — so + * `lsmod` / `modinfo` / `modprobe` fall out naturally. + * + * App-agnostic: artipod-sync ships only the built-in `storage` provider; every + * other projection is registered by whatever app embeds the sandbox. + */ + +/** A provider's projection: path relative to the provider root → file content. */ +export type ProcTree = Record; + +export interface ProcProvider { + /** Module name — also the default directory under `/proc`. No slashes. */ + name: string; + description?: string; + version?: string; + /** `rw` providers must supply `write()`; their files are reconciled after each command. */ + mode: 'ro' | 'rw'; + /** + * Directory under `/proc` that this provider owns, defaulting to `name`. + * `''` writes straight into `/proc` (for single-file projections such as + * `/proc/route.json`) and gives up new-file detection. + */ + root?: string; + /** Called on every snapshot refresh. */ + read(): Promise; + /** `rw` only. A throw surfaces on stderr and the file reverts on next refresh. */ + write?(relPath: string, content: Uint8Array): Promise; +} + +export interface ProcModule { + provider: ProcProvider; + enabled: boolean; +} + +const modules = new Map(); + +/** Registers a provider (enabled). Returns the unregister function. */ +export function registerProcProvider(provider: ProcProvider): () => void { + if (provider.name.includes('/') || !provider.name) { + throw new Error(`proc provider name must be a single path segment: '${provider.name}'`); + } + if (modules.has(provider.name)) { + throw new Error(`proc provider '${provider.name}' is already registered`); + } + if (provider.mode === 'rw' && !provider.write) { + throw new Error(`proc provider '${provider.name}' is rw but has no write()`); + } + modules.set(provider.name, { provider, enabled: true }); + return () => { + if (modules.get(provider.name)?.provider === provider) modules.delete(provider.name); + }; +} + +export function listProviders(): ProcModule[] { + return [...modules.values()].sort((a, b) => a.provider.name.localeCompare(b.provider.name)); +} + +export function getProvider(name: string): ProcModule | undefined { + return modules.get(name); +} + +/** `modprobe` / `modprobe -r`. Returns false when there is no such provider. */ +export function setProviderEnabled(name: string, enabled: boolean): boolean { + const module = modules.get(name); + if (!module) return false; + module.enabled = enabled; + return true; +} + +export function enabledProviders(): ProcProvider[] { + return listProviders() + .filter((m) => m.enabled) + .map((m) => m.provider); +} + +/** The `/proc` subdirectory a provider owns, `''` meaning `/proc` itself. */ +export function providerRoot(provider: ProcProvider): string { + return provider.root ?? provider.name; +} + +/** Test helper — drops every registration. */ +export function clearProcProviders(): void { + modules.clear(); +} diff --git a/src/proc/snapshot.ts b/src/proc/snapshot.ts new file mode 100644 index 0000000..6c19b14 --- /dev/null +++ b/src/proc/snapshot.ts @@ -0,0 +1,110 @@ +/** + * The `/proc` snapshot. + * + * `/proc` is a plain in-memory ZenFS mount that is thrown away and rebuilt from + * the enabled providers before every command — a snapshot, not a live procfs. + * Implementing a real ZenFS `FileSystem` would mean 24 abstract methods in + * sync+async pairs for no gain: nothing observes the tree between commands. + */ +import { enabledProviders, providerRoot, type ProcProvider, type ProcTree } from './registry.js'; +import type { ZenFsLike } from '../sandbox/types.js'; + +export const PROC_MOUNT = '/proc'; + +/** One file as it stood at the last refresh — the reconcile baseline. */ +export interface ProcEntry { + provider: ProcProvider; + /** Path relative to the provider root. */ + rel: string; + hash: string; +} + +const entries = new Map(); + +/** Absolute path → what the last refresh wrote there. */ +export function procEntries(): ReadonlyMap { + return entries; +} + +export function procPathOf(provider: ProcProvider, rel: string): string { + const root = providerRoot(provider); + return root ? `${PROC_MOUNT}/${root}/${rel}` : `${PROC_MOUNT}/${rel}`; +} + +/** + * `mkdir -p` + write for a whole tree. Pure with respect to the mount table, so + * apps reuse it to materialize persistent directories too. + */ +export async function writeTree(zfs: ZenFsLike, root: string, files: ProcTree): Promise { + await mkdirp(zfs, root); + for (const [rel, content] of Object.entries(files)) { + const path = root === '/' ? `/${rel}` : `${root}/${rel}`; + const slash = path.lastIndexOf('/'); + if (slash > 0) await mkdirp(zfs, path.slice(0, slash)); + await zfs.promises.writeFile(path, content as Parameters[1]); + } +} + +export async function mkdirp(zfs: ZenFsLike, dir: string): Promise { + if (dir === '' || dir === '/') return; + try { + await zfs.promises.mkdir(dir, { recursive: true }); + } catch (e) { + if ((e as { code?: string }).code !== 'EEXIST') throw e; + } +} + +/** + * Rebuilds `/proc` from the enabled providers. A provider that throws is + * skipped with its message returned, rather than taking the whole tree down. + */ +export async function refreshProc(zfs: ZenFsLike): Promise { + const { mount, mounts, umount, resolveMountConfig, InMemory } = await import('@zenfs/core'); + if (mounts.has(PROC_MOUNT)) umount(PROC_MOUNT); + // ZenFS only exposes a mount point that exists as a directory underneath, and + // the mkdir has to happen before the mount or it lands inside it. + await mkdirp(zfs, PROC_MOUNT); + mount(PROC_MOUNT, await resolveMountConfig({ backend: InMemory })); + entries.clear(); + + const errors: string[] = []; + for (const provider of enabledProviders()) { + let tree: ProcTree; + try { + tree = await provider.read(); + } catch (e) { + errors.push(`proc: ${provider.name}: ${(e as Error).message}`); + continue; + } + const root = providerRoot(provider); + await writeTree(zfs, root ? `${PROC_MOUNT}/${root}` : PROC_MOUNT, tree); + for (const [rel, content] of Object.entries(tree)) { + entries.set(procPathOf(provider, rel), { provider, rel, hash: hashContent(content) }); + } + } + return errors; +} + +export async function unmountProc(): Promise { + const { mounts, umount } = await import('@zenfs/core'); + if (mounts.has(PROC_MOUNT)) umount(PROC_MOUNT); + entries.clear(); +} + +const encoder = new TextEncoder(); + +export function toBytes(content: string | Uint8Array): Uint8Array { + return typeof content === 'string' ? encoder.encode(content) : content; +} + +/** Change detection only — a fast non-cryptographic 64-bit FNV-1a pair. */ +export function hashContent(content: string | Uint8Array): string { + const bytes = toBytes(content); + let a = 0x811c9dc5; + let b = 0x01000193; + for (const byte of bytes) { + a = Math.imul(a ^ byte, 0x01000193) >>> 0; + b = Math.imul(b + byte, 0x85ebca6b) >>> 0; + } + return `${a.toString(16)}${b.toString(16)}-${bytes.length}`; +} diff --git a/src/proc/storage-provider.ts b/src/proc/storage-provider.ts new file mode 100644 index 0000000..f2764ea --- /dev/null +++ b/src/proc/storage-provider.ts @@ -0,0 +1,125 @@ +/** + * The one provider artipod-sync ships itself: a snapshot of the raw browser + * storage under `/proc/storage`. + * + * This is how the *backing devices* are inspected without mounting them into + * the very filesystem they back — a snapshot cannot recurse into itself. IDB + * database names containing `/` (Yjs rooms are named that way) become + * subdirectories, so `ls /proc/storage/idb/case` enumerates case docs. + * + * Metadata only. Dumping records is a targeted operation, not something every + * refresh should pay for. + */ +import { getProvider, registerProcProvider, type ProcProvider, type ProcTree } from './registry.js'; +import { getStorageUsage, OPFS_FS_DIR } from '../sandbox/storage.js'; + +const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; + +export const storageProvider: ProcProvider = { + name: 'storage', + description: 'raw browser storage: IndexedDB databases and the OPFS tree', + version: '1', + mode: 'ro', + async read(): Promise { + const tree: ProcTree = {}; + const usage = await getStorageUsage(); + if (usage) tree['origin.json'] = json(usage); + for (const [name, info] of Object.entries(await probeIndexedDb())) { + tree[`idb/${name}.json`] = json(info); + } + tree['opfs.json'] = json(await probeOpfs()); + return tree; + }, +}; + +/** Idempotent — the sandbox calls it on every `createSandbox({ proc: true })`. */ +export function registerBuiltinProviders(): void { + if (!getProvider(storageProvider.name)) registerProcProvider(storageProvider); +} + +interface IdbInfo { + database: string; + version: number | null; + stores: { name: string; records: number | null }[]; +} + +async function probeIndexedDb(): Promise> { + if (typeof indexedDB === 'undefined' || !indexedDB.databases) return {}; + const out: Record = {}; + for (const { name } of await indexedDB.databases()) { + if (!name) continue; + out[name] = await describeDatabase(name); + } + return out; +} + +function describeDatabase(name: string): Promise { + return new Promise((resolve) => { + const request = indexedDB.open(name); + request.onerror = () => resolve({ database: name, version: null, stores: [] }); + request.onsuccess = async () => { + const db = request.result; + const stores = [...db.objectStoreNames]; + const counted = await Promise.all(stores.map((store) => countRecords(db, store))); + resolve({ + database: name, + version: db.version, + stores: stores.map((store, i) => ({ name: store, records: counted[i] })), + }); + db.close(); + }; + }); +} + +function countRecords(db: IDBDatabase, store: string): Promise { + return new Promise((resolve) => { + try { + const request = db.transaction(store, 'readonly').objectStore(store).count(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +interface OpfsEntry { + path: string; + kind: 'file' | 'directory'; + size: number | null; +} + +async function probeOpfs(): Promise<{ available: boolean; entries: OpfsEntry[] }> { + if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) { + return { available: false, entries: [] }; + } + try { + const root = await navigator.storage.getDirectory(); + return { available: true, entries: await listTree(root, '') }; + } catch { + return { available: false, entries: [] }; + } +} + +/** The sandbox subtree is already browsable as `/`; only its shape is listed. */ +async function listTree(dir: FileSystemDirectoryHandle, prefix: string): Promise { + const out: OpfsEntry[] = []; + const iterator = ( + dir as unknown as { entries(): AsyncIterableIterator<[string, FileSystemHandle]> } + ).entries(); + for (;;) { + const { done, value } = await iterator.next(); + if (done) break; + const [name, handle] = value; + const path = `${prefix}${name}`; + if (handle.kind === 'file') { + out.push({ path, kind: 'file', size: (await (handle as FileSystemFileHandle).getFile()).size }); + continue; + } + out.push({ path, kind: 'directory', size: null }); + if (name !== OPFS_FS_DIR) { + out.push(...(await listTree(handle as FileSystemDirectoryHandle, `${path}/`))); + } + } + return out; +} diff --git a/src/sandbox/edit-command.ts b/src/sandbox/edit-command.ts new file mode 100644 index 0000000..f036654 --- /dev/null +++ b/src/sandbox/edit-command.ts @@ -0,0 +1,18 @@ +/** + * `edit` as a just-bash custom command: resolves the argument against the + * shell cwd and hands the path to the host (Monaco in the browser). + */ +import { defineCommand } from 'just-bash/browser'; + +export const makeEditCommand = (onEdit?: (path: string) => void) => + defineCommand('edit', async (args, ctx) => { + if (!args[0]) { + return { stdout: '', stderr: 'usage: edit \n', exitCode: 1 }; + } + if (!onEdit) { + return { stdout: '', stderr: 'edit: no editor attached in this environment\n', exitCode: 1 }; + } + const path = ctx.fs.resolvePath(ctx.cwd, args[0]); + onEdit(path); + return { stdout: `Opening editor for ${path}...\n`, stderr: '', exitCode: 0 }; + }); diff --git a/src/sandbox/git-auth.ts b/src/sandbox/git-auth.ts new file mode 100644 index 0000000..8caaf36 --- /dev/null +++ b/src/sandbox/git-auth.ts @@ -0,0 +1,83 @@ +/** + * Git credentials for push/fetch over HTTPS. + * + * Tokens live OUTSIDE the sandbox filesystem (agents can read anything in + * ZenFS — see plan §5/§8): per-origin, in memory by default, localStorage + * only when the user opts in via `git config credential.persist true`. + */ + +const LS_PREFIX = 'artipod-sync-git-token:'; +const LS_PERSIST_FLAG = 'artipod-sync-git-credential-persist'; + +const memTokens = new Map(); + +type AuthPrompt = (origin: string) => Promise; +let promptFn: AuthPrompt | null = null; + +/** Host registers how to ask the user for a PAT (e.g. a dialog in the browser). */ +export function setAuthPrompt(fn: AuthPrompt | null): void { + promptFn = fn; +} + +function hasLocalStorage(): boolean { + return typeof localStorage !== 'undefined'; +} + +export function persistenceEnabled(): boolean { + return hasLocalStorage() && localStorage.getItem(LS_PERSIST_FLAG) === 'true'; +} + +export function setPersistence(enabled: boolean): void { + if (!hasLocalStorage()) return; + if (enabled) { + localStorage.setItem(LS_PERSIST_FLAG, 'true'); + } else { + localStorage.removeItem(LS_PERSIST_FLAG); + // drop previously persisted tokens as well + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key?.startsWith(LS_PREFIX)) localStorage.removeItem(key); + } + } +} + +export function setToken(origin: string, token: string): void { + memTokens.set(origin, token); + if (persistenceEnabled()) localStorage.setItem(LS_PREFIX + origin, token); +} + +export function getToken(origin: string): string | null { + const mem = memTokens.get(origin); + if (mem) return mem; + if (hasLocalStorage()) { + const stored = localStorage.getItem(LS_PREFIX + origin); + if (stored) { + memTokens.set(origin, stored); + return stored; + } + } + return null; +} + +export function clearToken(origin: string): void { + memTokens.delete(origin); + if (hasLocalStorage()) localStorage.removeItem(LS_PREFIX + origin); +} + +/** isomorphic-git onAuth callback for a repo URL. */ +export async function onAuthForUrl(url: string): Promise<{ username: string; password: string } | { cancel: true }> { + const origin = new URL(url).origin; + let token = getToken(origin); + if (!token && promptFn) { + token = await promptFn(origin); + if (token) setToken(origin, token); + } + if (!token) return { cancel: true }; + // GitHub/GitLab accept a PAT as the password with any username. + return { username: 'x-access-token', password: token }; +} + +/** isomorphic-git onAuthFailure: drop the bad token so the next try re-prompts. */ +export function onAuthFailureForUrl(url: string): void { + clearToken(new URL(url).origin); +} diff --git a/src/sandbox/git-command.test.ts b/src/sandbox/git-command.test.ts new file mode 100644 index 0000000..4f6dfd1 --- /dev/null +++ b/src/sandbox/git-command.test.ts @@ -0,0 +1,150 @@ +/** + * git command tests over a fixture repo built with isomorphic-git APIs + * (no network) — exercised through the sandbox's `git` custom command. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import git from 'isomorphic-git'; +import { createGitOps } from './git.js'; +import { createSandbox, type Sandbox } from './index.js'; + +const AUTHOR = { name: 'Test', email: 'test@example.com' }; + +let sandbox: Sandbox; + +async function initRepoFixture() { + const dir = '/repo'; + await zfs.promises.mkdir(dir); + await git.init({ fs: zfs, dir, defaultBranch: 'main' }); + await zfs.promises.writeFile(`${dir}/readme.md`, 'v1\n'); + await git.add({ fs: zfs, dir, filepath: 'readme.md' }); + await git.commit({ fs: zfs, dir, message: 'initial', author: AUTHOR }); +} + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await initRepoFixture(); + sandbox = createSandbox({ zfs }); +}); + +describe('git custom command (Phase 3 surface)', () => { + it('status shows branch and short-status codes', async () => { + const clean = await sandbox.exec('git status'); + expect(clean.stdout).toMatch(/On branch main/); + expect(clean.stdout).toMatch(/working tree clean/); + + await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n'); + await zfs.promises.writeFile('/repo/new.txt', 'n\n'); + const dirty = await sandbox.exec('git status'); + expect(dirty.stdout).toMatch(/ M readme\.md/); + expect(dirty.stdout).toMatch(/\?\? new\.txt/); + }); + + it('add ., commit -m, log --oneline complete the loop', async () => { + await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n'); + await zfs.promises.writeFile('/repo/new.txt', 'n\n'); + + expect((await sandbox.exec('git add .')).exitCode).toBe(0); + const staged = await sandbox.exec('git status'); + expect(staged.stdout).toMatch(/M {2}readme\.md/); + expect(staged.stdout).toMatch(/A {2}new\.txt/); + + const commit = await sandbox.exec('git commit -m "second change"'); + expect(commit.exitCode).toBe(0); + expect(commit.stdout).toMatch(/second change/); + + const log = await sandbox.exec('git log --oneline'); + expect(log.stdout.trim().split('\n')).toHaveLength(2); + expect(log.stdout).toMatch(/second change/); + expect(log.stdout).toMatch(/initial/); + }); + + it('commit refuses when nothing is staged', async () => { + const r = await sandbox.exec('git commit -m "empty"'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/nothing to commit/); + }); + + it('reset unstages and rm removes', async () => { + await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n'); + await sandbox.exec('git add readme.md'); + await sandbox.exec('git reset readme.md'); + const st = await sandbox.exec('git status'); + expect(st.stdout).toMatch(/ M readme\.md/); + + await sandbox.exec('git rm readme.md'); + expect(await zfs.promises.exists('/repo/readme.md')).toBe(false); + const st2 = await sandbox.exec('git status'); + expect(st2.stdout).toMatch(/D {2}readme\.md|D. readme\.md/); + }); + + it('diff variants: file, all, --staged', async () => { + await zfs.promises.writeFile('/repo/readme.md', 'v2 with more bytes\n'); + + const one = await sandbox.exec('git diff readme.md'); + expect(one.stdout).toMatch(/-v1/); + expect(one.stdout).toMatch(/\+v2 with more bytes/); + + const all = await sandbox.exec('git diff'); + expect(all.stdout).toMatch(/readme\.md/); + + const emptyStaged = await sandbox.exec('git diff --staged'); + expect(emptyStaged.stdout.trim()).toBe(''); + + await sandbox.exec('git add readme.md'); + const staged = await sandbox.exec('git diff --staged'); + expect(staged.stdout).toMatch(/\+v2 with more bytes/); + }); + + it('branch and checkout -b work', async () => { + const main = await sandbox.exec('git branch'); + expect(main.stdout).toMatch(/\* main/); + + const co = await sandbox.exec('git checkout -b feature'); + expect(co.exitCode).toBe(0); + const branches = await sandbox.exec('git branch'); + expect(branches.stdout).toMatch(/\* feature/); + expect(branches.stdout).toMatch(/ {2}main/); + + await sandbox.exec('git checkout main'); + expect((await sandbox.exec('git branch')).stdout).toMatch(/\* main/); + }); + + it('config stores author identity', async () => { + await sandbox.exec('git config user.name Alice'); + await sandbox.exec('git config user.email alice@example.com'); + expect((await sandbox.exec('git config user.name')).stdout.trim()).toBe('Alice'); + expect((await sandbox.exec('git config user.email')).stdout.trim()).toBe('alice@example.com'); + + await zfs.promises.writeFile('/repo/x.txt', 'x\n'); + await sandbox.exec('git add x.txt'); + await sandbox.exec('git commit -m "by alice"'); + const log = await sandbox.exec('git log -n 1'); + expect(log.stdout).toMatch(/Alice /); + }); + + it('rejects non-https clone URLs', async () => { + const r = await sandbox.exec('git clone http://example.com/a.git'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/only https/); + + const r2 = await sandbox.exec("git clone 'javascript:alert(1)'"); + expect(r2.exitCode).not.toBe(0); + }); +}); + +describe('createGitOps direct API', () => { + it('diffAll covers deletions and additions', async () => { + const ops = createGitOps(() => zfs); + await zfs.promises.rm('/repo/readme.md'); + await zfs.promises.writeFile('/repo/added.txt', 'add\n'); + const diff = await ops.diffAll('/repo'); + expect(diff).toMatch(/-v1/); + expect(diff).toMatch(/\+add/); + }); +}); diff --git a/src/sandbox/git-command.ts b/src/sandbox/git-command.ts new file mode 100644 index 0000000..9f3fa91 --- /dev/null +++ b/src/sandbox/git-command.ts @@ -0,0 +1,172 @@ +/** + * `git` as a just-bash custom command. + * + * Runs TRUSTED, in-page, delegating to lib/git.ts (isomorphic-git). Its + * network goes through the CORS proxy — deliberately outside just-bash's + * network firewall (see plan §5 Security Notes). Keep arguments validated. + */ +import { defineCommand } from 'just-bash/browser'; +import type { GitOps, GitStatusResult } from './git.js'; +import { getAuthor, setAuthor } from './git.js'; +import { persistenceEnabled, setPersistence } from './git-auth.js'; + +const ok = (stdout: string) => ({ stdout, stderr: '', exitCode: 0 }); +const err = (stderr: string, exitCode = 1) => ({ stdout: '', stderr, exitCode }); + +const USAGE = + 'usage: git [args]\n'; + +function renderStatus({ branch, changes }: GitStatusResult): string { + const head = `On branch ${branch ?? '(detached)'}\n`; + if (changes.length === 0) return head + 'nothing to commit, working tree clean\n'; + return head + changes.map((c) => `${c.code} ${c.filepath}`).join('\n') + '\n'; +} + +function renderLog( + commits: { oid: string; message: string; author: string; email: string; timestamp: number }[], + oneline: boolean, +): string { + if (oneline) { + return commits.map((c) => `${c.oid.slice(0, 7)} ${c.message.split('\n')[0]}`).join('\n') + '\n'; + } + return commits + .map((c) => { + const date = new Date(c.timestamp * 1000).toISOString(); + return `commit ${c.oid}\nAuthor: ${c.author} <${c.email}>\nDate: ${date}\n\n ${c.message.trim().replace(/\n/g, '\n ')}\n`; + }) + .join('\n'); +} + +/** Only https:// git URLs are permitted (host-hook escape hatch stays narrow). */ +function assertHttpsUrl(url: string | undefined): string { + if (!url) throw new Error('missing repository URL'); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`invalid URL: ${url}`); + } + if (parsed.protocol !== 'https:') { + throw new Error(`only https:// repository URLs are supported (got ${parsed.protocol}//)`); + } + return url; +} + +function parseFlags(args: string[], flagsWithValue: string[] = []): { flags: Map; positional: string[] } { + const flags = new Map(); + const positional: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith('-')) { + if (flagsWithValue.includes(a)) { + flags.set(a, args[++i] ?? ''); + } else { + flags.set(a, ''); + } + } else { + positional.push(a); + } + } + return { flags, positional }; +} + +async function handleConfig(rest: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const [key, ...valueParts] = rest; + const value = valueParts.join(' '); + switch (key) { + case 'user.name': + if (!value) return ok(getAuthor().name + '\n'); + setAuthor({ name: value }); + return ok(''); + case 'user.email': + if (!value) return ok(getAuthor().email + '\n'); + setAuthor({ email: value }); + return ok(''); + case 'credential.persist': + if (!value) return ok(String(persistenceEnabled()) + '\n'); + setPersistence(value === 'true'); + return ok(''); + default: + return err(`git config: supported keys: user.name, user.email, credential.persist\n`); + } +} + +export const makeGitCommand = (gitOps: GitOps) => + defineCommand('git', async (args, ctx) => { + const [sub, ...rest] = args; + const dir = ctx.cwd; + try { + switch (sub) { + case 'clone': + await gitOps.clone(assertHttpsUrl(rest[0]), dir); + return ok('Cloned.\n'); + case 'status': + return ok(renderStatus(await gitOps.status(dir))); + case 'files': + return ok((await gitOps.listFiles(dir)).join('\n') + '\n'); + case 'diff': { + const { flags, positional } = parseFlags(rest); + if (flags.has('--staged') || flags.has('--cached')) { + return ok(await gitOps.diffStaged(dir)); + } + if (positional[0]) return ok(await gitOps.diff(positional[0], dir)); + return ok(await gitOps.diffAll(dir)); + } + case 'add': { + if (!rest[0]) return err('usage: git add |.\n'); + const staged = await gitOps.add(rest[0], dir); + return ok(staged.length ? '' : 'nothing to add\n'); + } + case 'reset': + if (!rest[0]) return err('usage: git reset \n'); + await gitOps.reset(rest[0], dir); + return ok(''); + case 'rm': + if (!rest[0]) return err('usage: git rm \n'); + await gitOps.rm(rest[0], dir); + return ok(''); + case 'commit': { + const { flags } = parseFlags(rest, ['-m']); + const message = flags.get('-m'); + if (!message) return err('usage: git commit -m "message"\n'); + const oid = await gitOps.commit(message, dir); + return ok(`[${oid.slice(0, 7)}] ${message}\n`); + } + case 'log': { + const { flags } = parseFlags(rest, ['-n']); + const depth = flags.has('-n') ? parseInt(flags.get('-n') ?? '', 10) || 10 : undefined; + const commits = await gitOps.log(dir, { depth }); + return ok(renderLog(commits, flags.has('--oneline'))); + } + case 'branch': { + const { flags } = parseFlags(rest); + const { current, branches } = await gitOps.branch(dir, { all: flags.has('-a') }); + return ok(branches.map((b) => (b === current ? `* ${b}` : ` ${b}`)).join('\n') + '\n'); + } + case 'checkout': { + const { flags, positional } = parseFlags(rest, ['-b']); + const create = flags.get('-b'); + const ref = create || positional[0]; + if (!ref) return err('usage: git checkout | git checkout -b \n'); + await gitOps.checkout(ref, dir, { create: Boolean(create) }); + return ok(`Switched to ${create ? 'a new branch ' : ''}'${ref}'\n`); + } + case 'fetch': + await gitOps.fetch(dir); + return ok('Fetched.\n'); + case 'pull': + await gitOps.pull(dir); + return ok('Already up to date or fast-forwarded.\n'); + case 'push': + return ok((await gitOps.push(dir)) + '\n'); + case 'config': + return handleConfig(rest); + case undefined: + return err(USAGE); + default: + return err(`git: '${sub}' is not supported.\n${USAGE}`); + } + } catch (e) { + return err(`git: ${(e as Error).message}\n`); + } + }); diff --git a/src/sandbox/git.ts b/src/sandbox/git.ts new file mode 100644 index 0000000..cceb1c8 --- /dev/null +++ b/src/sandbox/git.ts @@ -0,0 +1,284 @@ +import git from 'isomorphic-git'; +import http from 'isomorphic-git/http/web'; +import { createTwoFilesPatch } from 'diff'; +import type { ZenFsLike } from './types.js'; +import { onAuthForUrl, onAuthFailureForUrl } from './git-auth.js'; + +/** + * Git operations over a ZenFS-like fs (browser singleton by default; tests + * and the server pass their own via createGitOps). Network goes through a + * configurable CORS proxy — self-hostable since Phase 5. + */ + +// Browser: a self-hosted git CORS proxy (the public cors.isomorphic-git.org +// instance stalls indefinitely). Server: none — fetch there is not CORS-bound. +// NEXT_PUBLIC_GIT_CORS_PROXY kept for artipod-sync compat; guarded so plain +// browser bundles without a `process` shim don't crash at import. +const DEFAULT_CORS_PROXY = + (typeof process !== 'undefined' ? process.env?.NEXT_PUBLIC_GIT_CORS_PROXY : undefined) || + (typeof window === 'undefined' ? undefined : '/api/git'); + +let corsProxy: string | undefined = DEFAULT_CORS_PROXY; +export function setCorsProxy(url: string): void { + corsProxy = url; +} +export function getCorsProxy(): string | undefined { + return corsProxy; +} + +// --- author configuration (localStorage in browser, memory elsewhere) ------- + +const AUTHOR_KEY = 'artipod-sync-git-author'; +const memAuthor: { name: string; email: string } = { + name: 'artipod-sync', + email: 'artipod-sync@localhost', +}; + +export function getAuthor(): { name: string; email: string } { + if (typeof localStorage !== 'undefined') { + try { + const raw = localStorage.getItem(AUTHOR_KEY); + if (raw) return JSON.parse(raw); + } catch { + // fall through to memory default + } + } + return { ...memAuthor }; +} + +export function setAuthor(patch: Partial<{ name: string; email: string }>): void { + const next = { ...getAuthor(), ...patch }; + memAuthor.name = next.name; + memAuthor.email = next.email; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(AUTHOR_KEY, JSON.stringify(next)); + } +} + +// --- status ------------------------------------------------------------------ + +export interface StatusEntry { + filepath: string; + /** Two-letter short-status code (index/workdir), '??' for untracked. */ + code: string; +} + +export interface GitStatusResult { + branch: string | null; + changes: StatusEntry[]; +} + +/** [head, workdir, stage] → short-status code. */ +const STATUS_CODES: Record = { + '020': '??', + '022': 'A ', + '023': 'AM', + '003': 'AD', + '121': ' M', + '122': 'M ', + '123': 'MM', + '101': ' D', + '100': 'D ', + '110': 'D?', +}; + +/** All ops take the repo dir; fs comes from the factory's getter. */ +export function createGitOps(getFs: () => ZenFsLike) { + const common = (dir: string) => ({ fs: getFs(), dir }); + const network = (dir: string, url?: string) => ({ + ...common(dir), + http, + corsProxy, + onAuth: (u: string) => onAuthForUrl(url ?? u), + onAuthFailure: (u: string) => { + onAuthFailureForUrl(url ?? u); + }, + }); + + async function readHeadContent(dir: string, filepath: string): Promise { + try { + const commitOid = await git.resolveRef({ ...common(dir), ref: 'HEAD' }); + const { blob } = await git.readBlob({ ...common(dir), oid: commitOid, filepath }); + return new TextDecoder().decode(blob); + } catch { + return ''; // new file + } + } + + async function readWorkContent(dir: string, filepath: string): Promise { + try { + return (await getFs().promises.readFile(`${dir}/${filepath}`, 'utf8')) as string; + } catch { + return ''; // deleted file + } + } + + async function diffOne(dir: string, filepath: string): Promise { + const headContent = await readHeadContent(dir, filepath); + const workContent = await readWorkContent(dir, filepath); + return createTwoFilesPatch(`a/${filepath}`, `b/${filepath}`, headContent, workContent); + } + + const ops = { + clone: async (url: string, dir: string) => { + await git.clone({ ...network(dir, url), url, singleBranch: true, depth: 1 }); + }, + + status: async (dir: string): Promise => { + const branch = (await git.currentBranch({ ...common(dir), fullname: false })) ?? null; + const matrix = await git.statusMatrix(common(dir)); + const changes = matrix + .filter(([, head, workdir, stage]) => !(head === 1 && workdir === 1 && stage === 1)) + .map(([filepath, head, workdir, stage]) => ({ + filepath, + code: STATUS_CODES[`${head}${workdir}${stage}`] ?? '??', + })); + return { branch, changes }; + }, + + listFiles: async (dir: string) => git.listFiles(common(dir)), + + diff: async (filepath: string, dir: string) => diffOne(dir, filepath), + + /** All working-tree changes vs HEAD (git diff without args). */ + diffAll: async (dir: string): Promise => { + const { changes } = await ops.status(dir); + const parts: string[] = []; + for (const { filepath } of changes) { + parts.push(await diffOne(dir, filepath)); + } + return parts.join(''); + }, + + /** Staged changes vs HEAD (git diff --staged). */ + diffStaged: async (dir: string): Promise => { + const decoder = new TextDecoder(); + // STAGE walker entries expose oid but not content() — read blobs by oid. + const blobText = async (oid: string | undefined): Promise => { + if (!oid) return ''; + const { blob } = await git.readBlob({ ...common(dir), oid }); + return decoder.decode(blob); + }; + const results = await git.walk({ + ...common(dir), + trees: [git.TREE({ ref: 'HEAD' }), git.STAGE()], + map: async (filepath, [head, stage]) => { + if (filepath === '.') return undefined; + if ((await head?.type()) === 'tree' || (await stage?.type()) === 'tree') return undefined; + const headOid = await head?.oid(); + const stageOid = await stage?.oid(); + if (headOid === stageOid) return undefined; + const headText = await blobText(headOid); + const stageText = await blobText(stageOid); + return createTwoFilesPatch(`a/${filepath}`, `b/${filepath}`, headText, stageText); + }, + }); + return (results as (string | undefined)[]).filter(Boolean).join(''); + }, + + /** git add |. — stages modifications, additions and deletions. */ + add: async (path: string, dir: string): Promise => { + const staged: string[] = []; + if (path === '.' || path === '-A' || path === '--all') { + const matrix = await git.statusMatrix(common(dir)); + for (const [filepath, head, workdir] of matrix) { + if (head === 1 && workdir === 0) { + await git.remove({ ...common(dir), filepath }); + staged.push(filepath); + } else if (workdir === 2) { + await git.add({ ...common(dir), filepath }); + staged.push(filepath); + } + } + } else { + const exists = await getFs() + .promises.stat(`${dir}/${path}`) + .then(() => true) + .catch(() => false); + if (exists) { + await git.add({ ...common(dir), filepath: path }); + } else { + await git.remove({ ...common(dir), filepath: path }); // stage a deletion + } + staged.push(path); + } + return staged; + }, + + /** git reset — unstage (restore index entry to HEAD). */ + reset: async (path: string, dir: string) => { + await git.resetIndex({ ...common(dir), filepath: path }); + }, + + /** git rm — remove from index and working tree. */ + rm: async (path: string, dir: string) => { + await git.remove({ ...common(dir), filepath: path }); + await getFs() + .promises.rm(`${dir}/${path}`) + .catch(() => undefined); // already gone is fine + }, + + commit: async (message: string, dir: string): Promise => { + const matrix = await git.statusMatrix(common(dir)); + const hasStaged = matrix.some(([, head, , stage]) => !(head === 1 && stage === 1) && stage !== 0) + || matrix.some(([, head, , stage]) => head === 1 && stage === 0); + if (!hasStaged) throw new Error('nothing to commit (use "git add")'); + return git.commit({ ...common(dir), message, author: getAuthor() }); + }, + + log: async (dir: string, opts: { depth?: number } = {}) => { + const commits = await git.log({ ...common(dir), depth: opts.depth }); + return commits.map((c) => ({ + oid: c.oid, + message: c.commit.message, + author: c.commit.author.name, + email: c.commit.author.email, + timestamp: c.commit.author.timestamp, + })); + }, + + branch: async (dir: string, opts: { all?: boolean } = {}) => { + const current = (await git.currentBranch({ ...common(dir), fullname: false })) ?? null; + const local = await git.listBranches(common(dir)); + let remote: string[] = []; + if (opts.all) { + remote = await git + .listBranches({ ...common(dir), remote: 'origin' }) + .then((refs) => refs.filter((r) => r !== 'HEAD').map((r) => `remotes/origin/${r}`)) + .catch(() => []); + } + return { current, branches: [...local, ...remote] }; + }, + + checkout: async (ref: string, dir: string, opts: { create?: boolean } = {}) => { + if (opts.create) await git.branch({ ...common(dir), ref }); + await git.checkout({ ...common(dir), ref }); + }, + + fetch: async (dir: string) => { + const res = await git.fetch({ ...network(dir), singleBranch: false }); + return res.fetchHead; + }, + + /** Fast-forward-only pull (merge commits are out of scope — documented). */ + pull: async (dir: string) => { + await git.pull({ + ...network(dir), + fastForwardOnly: true, + singleBranch: true, + author: getAuthor(), + }); + }, + + push: async (dir: string): Promise => { + const res = await git.push(network(dir)); + if (res.ok) return 'Push complete.'; + throw new Error(res.error ?? 'push failed'); + }, + }; + return ops; +} + +export type GitOps = ReturnType; +// NOTE: the app-level singleton (`gitOps` over a live fs binding) stays in the +// consuming app — the package only ships the factory. diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts new file mode 100644 index 0000000..2fc572b --- /dev/null +++ b/src/sandbox/index.ts @@ -0,0 +1,166 @@ +/** + * createSandbox() — the only public entry of the sandbox core. + * + * Wraps a just-bash Bash instance over a ZenFsAdapter. just-bash isolates + * state per exec() BY DESIGN (each call is "a new shell"); we reconstruct the + * session host-side: + * - cwd → recovered from result.env.PWD, passed as ExecOptions.cwd + * - vars / exports / aliases (BASH_ALIAS_) → full env replay with + * replaceEnv, so `unset` also sticks + * - functions → known v1 limitation (just-bash does not retain function + * source); documented in the terminal help text + * + * Framework-free: no React, no Next, no window. Works in browser and Node. + */ +import { Bash } from 'just-bash/browser'; +import type { BashOptions, CustomCommand } from 'just-bash/browser'; +import { createGitOps } from './git.js'; +import { reconcileProc } from '../proc/reconcile.js'; +import { refreshProc } from '../proc/snapshot.js'; +import { registerBuiltinProviders } from '../proc/storage-provider.js'; +import { makeEditCommand } from './edit-command.js'; +import { makeGitCommand } from './git-command.js'; +import { makeModuleCommands } from './module-command.js'; +import { makeNotesCommand } from './notes-command.js'; +import { makeStorageCommands } from './storage-command.js'; +import type { CompletionResult, Sandbox, SandboxExecOptions, ZenFsLike } from './types.js'; +import { ZenFsAdapter } from './zenfs-adapter.js'; + +export type { CompletionResult, Sandbox, SandboxExecOptions, SandboxExecResult } from './types.js'; +export { SHELL_NOTES } from './notes-command.js'; +export { ZenFsAdapter } from './zenfs-adapter.js'; + +export interface CreateSandboxOptions { + /** The ZenFS node-like fs object backing the sandbox. */ + zfs: ZenFsLike; + /** Host hook for the `edit` command (opens Monaco in the browser). */ + onEdit?: (path: string) => void; + /** Initial working directory. Default: /repo */ + cwd?: string; + /** Additional custom commands (server may add more; agents reuse as-is). */ + extraCommands?: CustomCommand[]; + /** + * Mount `/proc` and add lsmod/modinfo/modprobe: the snapshot is rebuilt from + * the registered providers before every command and reconciled after it. + */ + proc?: boolean; + /** + * Host work to run around each non-transient command, e.g. materializing + * state into the filesystem. Returned messages are appended to stderr, so a + * hook reports a problem without taking the command down. + */ + hooks?: { + beforeExec?: () => Promise | string[] | void; + afterExec?: () => Promise | string[] | void; + }; + /** Tighter limits for server / agent use (defaults are sane for humans). */ + executionLimits?: BashOptions['executionLimits']; + executionLimitProfile?: BashOptions['executionLimitProfile']; +} + +const DEFAULT_CWD = '/repo'; + +export function createSandbox(opts: CreateSandboxOptions): Sandbox { + const adapter = new ZenFsAdapter(opts.zfs); + const initialCwd = opts.cwd ?? DEFAULT_CWD; + if (opts.proc) registerBuiltinProviders(); + + const bash = new Bash({ + fs: adapter, + cwd: initialCwd, + env: { HOME: initialCwd, USER: 'user', TERM: 'xterm-256color' }, + executionLimits: opts.executionLimits, + executionLimitProfile: opts.executionLimitProfile, + customCommands: [ + // git shares the sandbox's zfs — shell view and git view stay coherent + makeGitCommand(createGitOps(() => opts.zfs)), + makeEditCommand(opts.onEdit), + makeNotesCommand(), + ...makeStorageCommands(() => opts.zfs), + ...(opts.proc ? makeModuleCommands() : []), + ...(opts.extraCommands ?? []), + ], + }); + + let cwd = initialCwd; + // null until the first exec; afterwards the full env of the last line. + let carriedEnv: Record | null = null; + // Mirrored into BASH_HISTORY (JSON array) so the `history` builtin works. + const history: string[] = []; + + // Interactive-shell semantics: aliases only expand with expand_aliases on, + // and shopt state is not carried in result.env — so prepend it every line. + const PRELUDE = 'shopt -s expand_aliases\n'; + + const runLine = (line: string, execOpts?: SandboxExecOptions) => + bash.exec(PRELUDE + line, { + cwd, + env: { ...(carriedEnv ?? {}), BASH_HISTORY: JSON.stringify(history) }, + ...(carriedEnv ? { replaceEnv: true } : {}), + ...(execOpts?.signal ? { signal: execOpts.signal } : {}), + }); + + const sandbox: Sandbox = { + async exec(line: string, execOpts?: SandboxExecOptions) { + // Transient execs (tab completion) must not disturb the snapshot. + const live = !execOpts?.transient; + const notices: string[] = []; + if (live) { + notices.push(...((await opts.hooks?.beforeExec?.()) ?? [])); + if (opts.proc) notices.push(...(await refreshProc(opts.zfs))); + history.push(line); + } + const r = await runLine(line, execOpts); + if (live) { + if (r.env) { + const { BASH_HISTORY: _history, ...rest } = r.env; + carriedEnv = rest; + } + if (r.env?.PWD) cwd = r.env.PWD; + if (opts.proc) notices.push(...(await reconcileProc(opts.zfs))); + notices.push(...((await opts.hooks?.afterExec?.()) ?? [])); + } + const stderr = notices.length ? `${r.stderr}${notices.join('\n')}\n` : r.stderr; + return { stdout: r.stdout, stderr, exitCode: r.exitCode }; + }, + + getCwd: () => cwd, + getEnv: () => carriedEnv ?? {}, + + async complete(line: string): Promise { + const tokenMatch = line.match(/[^\s]*$/); + const token = tokenMatch ? tokenMatch[0] : ''; + const replaceStart = line.length - token.length; + if (!token) return { candidates: [], replaceStart }; + + const before = line.slice(0, replaceStart).trimEnd(); + const isCommandPos = + before === '' || /[|;&(]$|\$\($|&&$|\|\|$/.test(before) || before.endsWith('`'); + + const q = shQuote(token); + const script = + isCommandPos && !token.includes('/') + ? `compgen -A command ${q}; compgen -A alias ${q}; compgen -d ${q}` + : `compgen -f ${q}; compgen -d ${q}`; + + const r = await this.exec(script, { transient: true }); + const dirCheck = await this.exec(`compgen -d ${q}`, { transient: true }); + const dirs = new Set(splitLines(dirCheck.stdout)); + + const candidates = Array.from(new Set(splitLines(r.stdout))) + .sort() + .map((c) => (dirs.has(c) ? `${c}/` : c)); + return { candidates, replaceStart }; + }, + + fs: adapter, + }; + return sandbox; +} + +const splitLines = (s: string) => s.split('\n').filter(Boolean); + +/** Single-quote a string for safe embedding in a bash line. */ +function shQuote(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/sandbox/module-command.ts b/src/sandbox/module-command.ts new file mode 100644 index 0000000..23957a4 --- /dev/null +++ b/src/sandbox/module-command.ts @@ -0,0 +1,104 @@ +/** + * `lsmod`, `modinfo`, `modprobe` — the proc providers presented as kernel + * modules, because that is exactly what they are: named, versioned projections + * that can be loaded and unloaded while the shell runs. + * + * Registry-scoped: `modprobe` toggles an already-registered provider, it does + * not fetch code. + */ +import { defineCommand } from 'just-bash/browser'; +import type { ExecResult } from 'just-bash/browser'; +import { listProviders, getProvider, setProviderEnabled } from '../proc/registry.js'; +import { procEntries, procPathOf } from '../proc/snapshot.js'; +import { renderTable } from './table.js'; + +const USAGE: Record = { + lsmod: `usage: lsmod + +List the registered /proc providers: name, mode, file count and load state. +`, + modinfo: `usage: modinfo + +Print a provider's description, version, mode and the files it projects. +`, + modprobe: `usage: modprobe [-r] + +Load (-r: unload) a /proc provider. An unloaded provider's files disappear +from /proc at the next refresh; the provider itself stays registered. +`, +}; + +const help = (name: string, args: string[]): ExecResult | null => + args.includes('--help') || args.includes('-h') + ? { stdout: USAGE[name], stderr: '', exitCode: 0 } + : null; + +const err = (message: string): ExecResult => ({ stdout: '', stderr: `${message}\n`, exitCode: 1 }); + +/** Files the last snapshot refresh wrote for a provider. */ +function filesOf(name: string): string[] { + const paths: string[] = []; + for (const [path, entry] of procEntries()) { + if (entry.provider.name === name) paths.push(path); + } + return paths.sort(); +} + +const makeLsmodCommand = () => + defineCommand('lsmod', async (args) => { + const h = help('lsmod', args); + if (h) return h; + const rows = listProviders().map(({ provider, enabled }) => [ + provider.name, + provider.mode, + String(filesOf(provider.name).length), + enabled ? 'Live' : 'Unloaded', + ]); + return { + stdout: renderTable(['Module', 'Mode', 'Files', 'State'], rows, [2]), + stderr: '', + exitCode: 0, + }; + }); + +const makeModinfoCommand = () => + defineCommand('modinfo', async (args) => { + const h = help('modinfo', args); + if (h) return h; + const name = args[0]; + if (!name) return err("modinfo: missing module name (try 'modinfo --help')"); + const module = getProvider(name); + if (!module) return err(`modinfo: ERROR: Module ${name} not found.`); + + const { provider, enabled } = module; + const files = filesOf(name); + const lines = [ + `name: ${provider.name}`, + `description: ${provider.description ?? '-'}`, + `version: ${provider.version ?? '-'}`, + `mode: ${provider.mode}`, + `state: ${enabled ? 'Live' : 'Unloaded'}`, + `root: ${procPathOf(provider, '').replace(/\/$/, '') || '/proc'}`, + ...files.map((path) => `file: ${path}`), + ]; + return { stdout: `${lines.join('\n')}\n`, stderr: '', exitCode: 0 }; + }); + +const makeModprobeCommand = () => + defineCommand('modprobe', async (args) => { + const h = help('modprobe', args); + if (h) return h; + const remove = args.includes('-r') || args.includes('--remove'); + const name = args.find((a) => !a.startsWith('-')); + if (!name) return err("modprobe: missing module name (try 'modprobe --help')"); + if (!setProviderEnabled(name, !remove)) { + return err(`modprobe: FATAL: Module ${name} not found.`); + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + +export const makeModuleCommands = () => [ + makeLsmodCommand(), + makeModinfoCommand(), + makeModprobeCommand(), +]; diff --git a/src/sandbox/notes-command.ts b/src/sandbox/notes-command.ts new file mode 100644 index 0000000..7cf4e0a --- /dev/null +++ b/src/sandbox/notes-command.ts @@ -0,0 +1,51 @@ +/** + * `notes` — artipod-sync shell notes. The just-bash builtin `help` (command + * list) cannot be shadowed by a custom command, so session semantics live + * here and the terminal banner points at it. + */ +import { defineCommand } from 'just-bash/browser'; + +export const SHELL_NOTES = `artipod-sync shell notes +========================= +This is just-bash (a bash interpreter in TypeScript) over a persistent +in-browser filesystem (ZenFS). Files survive reloads. + +Session semantics — each line runs in a fresh shell; the host carries state: + * cwd, variables, exports and aliases persist across lines + * shell FUNCTIONS do NOT persist across lines (define and use them on + one line, or in a script file you run with 'bash file.sh') + * shopt/set -o changes do not persist across lines + +Interactive limits (no TTY): read -p/-s prompts, pagers (less) and editors +(vim) do not work — use 'edit ' to open Monaco. gzip/zcat need +node:zlib and are unavailable in the browser; plain 'tar' works. + +Extras: + edit open the Monaco editor + git clone/status/diff/files (isomorphic-git; https:// only) + df [-hTv] [--scan] + storage usage. Size/Avail exist only on the 'origin' row: + the browser gives ONE quota to the whole origin, shared by + IndexedDB and OPFS, so per-device capacity is '-'. Used is + a browser estimate (Chromium only); --scan walks the tree + for exact bytes. + mount, findmnt, lsblk [-f], fdisk -l, diskutil list + the same storage facts in other shapes. The origin quota + is the 'disk'; IndexedDB and OPFS are its partitions. + Each takes --help. + mount -t , umount [-f] + attach or detach a backing device anywhere in the tree: + -t memory, -t idb [-o store=NAME], -t opfs [-o dir=PATH]. + lsmod, modinfo , modprobe [-r] + the /proc providers, which project host state into files. + /proc is a snapshot: it is rebuilt before every command, + and edits to a read-write provider's files are handed back + to it after the command. + help list all available commands + history shell history (mirrors the terminal's arrow-key history) + Tab complete commands, aliases and paths + Ctrl+C cancel the running command +`; + +export const makeNotesCommand = () => + defineCommand('notes', async () => ({ stdout: SHELL_NOTES, stderr: '', exitCode: 0 })); diff --git a/src/sandbox/sandbox.test.ts b/src/sandbox/sandbox.test.ts new file mode 100644 index 0000000..2dbfc9b --- /dev/null +++ b/src/sandbox/sandbox.test.ts @@ -0,0 +1,126 @@ +/** + * Sandbox session tests: bash semantics over ZenFS plus the host-side session + * reconstruction (cwd via result.env.PWD, env replay, BASH_ALIAS_ pinning). + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox, type Sandbox } from './index.js'; + +let sandbox: Sandbox; + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + sandbox = createSandbox({ zfs }); +}); + +describe('sandbox bash semantics over ZenFS', () => { + it('runs pipelines, globs, vars and loops', async () => { + await zfs.promises.writeFile('/repo/a.md', 'one\ntwo\n'); + await zfs.promises.writeFile('/repo/b.md', 'three\n'); + await zfs.promises.writeFile('/repo/c.txt', 'x\n'); + + expect((await sandbox.exec('ls | wc -l')).stdout.trim()).toBe('3'); + expect((await sandbox.exec('echo *.md')).stdout.trim()).toBe('a.md b.md'); + + const loop = await sandbox.exec('for f in *.md; do wc -l < "$f"; done'); + expect(loop.stdout.replace(/\s+/g, ' ').trim()).toBe('2 1'); + + const redirect = await sandbox.exec('echo hello > out.txt && cat out.txt'); + expect(redirect.stdout.trim()).toBe('hello'); + expect(await zfs.promises.readFile('/repo/out.txt', 'utf8')).toBe('hello\n'); + }); + + it('reports sensible errors for missing files', async () => { + const r = await sandbox.exec('cat missing.txt'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/missing\.txt/); + }); +}); + +describe('session state reconstruction across exec calls', () => { + it('persists cwd via result.env.PWD', async () => { + await zfs.promises.mkdir('/repo/sub'); + await sandbox.exec('cd sub'); + expect(sandbox.getCwd()).toBe('/repo/sub'); + expect((await sandbox.exec('pwd')).stdout.trim()).toBe('/repo/sub'); + await sandbox.exec('cd ..'); + expect(sandbox.getCwd()).toBe('/repo'); + }); + + it('persists variables and exports via env replay', async () => { + await sandbox.exec('X=5'); + expect((await sandbox.exec('echo $X')).stdout.trim()).toBe('5'); + + await sandbox.exec('export Y=exported'); + expect((await sandbox.exec('echo $Y')).stdout.trim()).toBe('exported'); + }); + + it('keeps unset variables unset (replaceEnv replay)', async () => { + await sandbox.exec('Z=9'); + await sandbox.exec('unset Z'); + expect((await sandbox.exec('echo "[$Z]"')).stdout.trim()).toBe('[]'); + }); + + it('pins the BASH_ALIAS_ env carry for aliases', async () => { + await sandbox.exec("alias ll='echo aliased'"); + // implementation detail, not a documented contract — this test is the canary + expect(sandbox.getEnv()).toHaveProperty('BASH_ALIAS_ll'); + expect((await sandbox.exec('ll')).stdout.trim()).toBe('aliased'); + }); + + it('does not absorb state from transient execs (completion helpers)', async () => { + await zfs.promises.mkdir('/repo/tmp2'); + await sandbox.exec('cd tmp2', { transient: true }); + expect(sandbox.getCwd()).toBe('/repo'); + }); + + it('supports cooperative cancellation via AbortSignal', async () => { + const controller = new AbortController(); + const pending = sandbox.exec('while true; do :; done', { signal: controller.signal }); + controller.abort(); + const r = await pending.catch((e: Error) => ({ aborted: true, message: e.message })); + // Either shape is fine as long as the loop terminated promptly. + expect(r).toBeTruthy(); + }, 10_000); +}); + +describe('human-shell polish (Phase 1.5)', () => { + it('mirrors executed lines into the history builtin', async () => { + await sandbox.exec('echo one'); + await sandbox.exec('echo two'); + const r = await sandbox.exec('history'); + expect(r.stdout).toMatch(/echo one/); + expect(r.stdout).toMatch(/echo two/); + }); + + it('completes command names and aliases', async () => { + const cmds = await sandbox.complete('ech'); + expect(cmds.candidates).toContain('echo'); + expect(cmds.replaceStart).toBe(0); + + await sandbox.exec("alias gsx='git status'"); + const aliases = await sandbox.complete('gs'); + expect(aliases.candidates).toContain('gsx'); + }); + + it('completes file paths with directory markers', async () => { + await zfs.promises.mkdir('/repo/adir'); + await zfs.promises.writeFile('/repo/afile.txt', 'x'); + const r = await sandbox.complete('cat a'); + expect(r.replaceStart).toBe(4); + expect(r.candidates).toContain('adir/'); + expect(r.candidates).toContain('afile.txt'); + }); + + it('completion execs are transient (no history/cwd pollution)', async () => { + await sandbox.complete('cat a'); + const r = await sandbox.exec('history'); + expect(r.stdout).not.toMatch(/compgen/); + }); +}); diff --git a/src/sandbox/storage-command.test.ts b/src/sandbox/storage-command.test.ts new file mode 100644 index 0000000..8dd25d4 --- /dev/null +++ b/src/sandbox/storage-command.test.ts @@ -0,0 +1,144 @@ +/** + * `mount` / `df` over ZenFS. Node has no navigator.storage, so these tests + * cover the mount enumeration, the table layout and the --scan walk; the + * origin quota row only appears in a browser. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox, type Sandbox } from './index.js'; + +let sandbox: Sandbox; + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + sandbox = createSandbox({ zfs }); +}); + +describe('mount', () => { + it('lists the ZenFS mount points with their backend', async () => { + const r = await sandbox.exec('mount'); + expect(r.exitCode).toBe(0); + expect(r.stdout.trim()).toBe('tmpfs on / type memory (rw)'); + }); + + it('needs -t to mount: a bare source/target pair is not enough', async () => { + const r = await sandbox.exec('mount /dev/sda /mnt'); + expect(r.exitCode).toBe(1); + expect(r.stderr).toMatch(/mount -t /); + }); +}); + +describe('df', () => { + it('prints a df-shaped table with a row per device', async () => { + const r = await sandbox.exec('df'); + expect(r.exitCode).toBe(0); + const [header, first] = r.stdout.trim().split('\n'); + expect(header.split(/\s+/)).toEqual(['Filesystem', '1K-blocks', 'Used', 'Avail', 'Use%', 'Mounted', 'on']); + expect(first).toMatch(/^tmpfs\s/); + expect(first).toMatch(/\/$/); + }); + + it('reports exact apparent bytes with --scan', async () => { + await zfs.promises.writeFile('/repo/big.txt', 'x'.repeat(4096)); + const r = await sandbox.exec('df --scan'); + expect(r.stdout.split('\n')[1].split(/\s+/)[2]).toBe('4'); + }); + + it('renders human sizes with -h', async () => { + await zfs.promises.writeFile('/repo/big.txt', 'x'.repeat(3 * 1024 * 1024)); + const r = await sandbox.exec('df -h --scan'); + expect(r.stdout).toMatch(/\s3M\s/); + }); + + it('ignores compatibility flags in a cluster, as real df does', async () => { + const plain = await sandbox.exec('df -h'); + const noisy = await sandbox.exec('df -vhP'); + expect(noisy.exitCode).toBe(0); + expect(noisy.stdout).toBe(plain.stdout); + }); + + it('adds a Type column with -T', async () => { + const r = await sandbox.exec('df -T'); + expect(r.stdout.split('\n')[0].split(/\s+/).slice(0, 2)).toEqual(['Filesystem', 'Type']); + expect(r.stdout.split('\n')[1]).toMatch(/^tmpfs\s+memory\s/); + }); + + it('accepts a path and reports only its file system', async () => { + const r = await sandbox.exec('df --scan /repo'); + expect(r.exitCode).toBe(0); + expect(r.stdout.trim().split('\n')).toHaveLength(2); + }); + + it('rejects unknown flags', async () => { + const r = await sandbox.exec('df -z'); + expect(r.exitCode).toBe(2); + expect(r.stderr).toMatch(/unrecognized option '-z'/); + }); + + it('keeps -h for human sizes and --help for help', async () => { + const help = await sandbox.exec('df --help'); + expect(help.exitCode).toBe(0); + expect(help.stdout).toMatch(/^usage: df /); + expect((await sandbox.exec('df -h')).stdout).toMatch(/^Filesystem/); + }); +}); + +describe('block-device views', () => { + it('findmnt lists targets and sources', async () => { + const r = await sandbox.exec('findmnt'); + expect(r.stdout.split('\n')[0].split(/\s+/)).toEqual(['TARGET', 'SOURCE', 'FSTYPE', 'OPTIONS']); + expect(r.stdout.split('\n')[1]).toMatch(/^\/\s+tmpfs\s+memory\s+rw$/); + }); + + it('lsblk shows the origin disk with the backends as partitions', async () => { + const r = await sandbox.exec('lsblk'); + const lines = r.stdout.trim().split('\n'); + expect(lines[0].split(/\s+/)).toEqual(['NAME', 'SIZE', 'TYPE', 'MOUNTPOINTS']); + expect(lines[1]).toMatch(/^origin\s/); + expect(lines[2]).toMatch(/^└─mem0\s+-\s+part\s+\/$/); + }); + + it('lsblk -f swaps in the filesystem columns', async () => { + const r = await sandbox.exec('lsblk -f'); + expect(r.stdout.split('\n')[0].split(/\s+/)).toEqual([ + 'NAME', + 'FSTYPE', + 'LABEL', + 'UUID', + 'FSUSE%', + 'MOUNTPOINTS', + ]); + // ZenFS gives every mount a real UUID. + expect(r.stdout).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-/); + }); + + it('fdisk -l describes the quota as the disk', async () => { + const r = await sandbox.exec('fdisk -l'); + expect(r.stdout).toMatch(/^Disk \/dev\/origin: /); + expect(r.stdout).toMatch(/\/dev\/mem0\s+memory/); + expect((await sandbox.exec('fdisk')).exitCode).toBe(1); + }); + + it('diskutil list prints the partition map', async () => { + const r = await sandbox.exec('diskutil list'); + expect(r.stdout).toMatch(/^\/dev\/origin \(browser, shared quota\):/); + expect(r.stdout).toMatch(/1:\s+memory\s+tmpfs/); + expect((await sandbox.exec('diskutil info')).exitCode).toBe(1); + }); + + it('every view answers --help', async () => { + for (const name of ['mount', 'umount', 'findmnt', 'lsblk', 'fdisk', 'diskutil']) { + const long = await sandbox.exec(`${name} --help`); + const short = await sandbox.exec(`${name} -h`); + expect(long.exitCode, name).toBe(0); + expect(long.stdout, name).toMatch(new RegExp(`^usage: ${name}\\b`)); + expect(short.stdout, name).toBe(long.stdout); + } + }); +}); diff --git a/src/sandbox/storage-command.ts b/src/sandbox/storage-command.ts new file mode 100644 index 0000000..be2e3e8 --- /dev/null +++ b/src/sandbox/storage-command.ts @@ -0,0 +1,578 @@ +/** + * `mount`, `df`, `findmnt`, `lsblk`, `fdisk -l`, `diskutil list` — read-only + * views of the browser storage layer, all rendered from one `probeStorage()`. + * + * The block-device framing is deliberate and accurate: the ORIGIN is the disk + * (one quota shared by every backend) and IndexedDB / OPFS are partitions on + * it. So per-device rows report Used only; Size and Avail exist on the origin. + * Anything unknown prints `-` rather than a made up number. + * + * Used bytes come from `navigator.storage.estimate().usageDetails` (Chromium + * only) or, with `df --scan`, from walking the mounted tree. + */ +import { defineCommand } from 'just-bash/browser'; +import type { ExecResult } from 'just-bash/browser'; +import { + backendAt, + getStorageUsage, + mountBackend, + opfsDirBytes, + unmountBackend, + OPFS_FS_DIR, + OPFS_MODELS_DIR, + type MountSpec, +} from './storage.js'; +import { renderTable } from './table.js'; +import type { ZenFsLike } from './types.js'; + +export interface StorageDevice { + /** Block-device-ish name for the lsblk/fdisk views, e.g. `idb0`. */ + name: string; + /** df's "Filesystem" column, e.g. `indexeddb:browser-git-fs`. */ + source: string; + type: 'indexeddb' | 'opfs' | 'memory'; + label: string; + uuid: string | null; + /** ZenFS mount point, or null for a device that exists but is not mounted. */ + mountPoint: string | null; + options: string[]; + /** Apparent bytes, or null when the browser won't tell us. */ + used: number | null; +} + +export interface StorageOrigin { + host: string; + usage: number; + quota: number; + persisted: boolean; +} + +export interface StorageReport { + devices: StorageDevice[]; + origin: StorageOrigin | null; +} + +/** + * ZenFS names a StoreFS after its store, so an IndexedDB mount surfaces as its + * object-store name; only the in-memory and OPFS backends have fixed names. + */ +function deviceType(fsName: string): StorageDevice['type'] { + if (fsName === 'webaccessfs') return 'opfs'; + if (fsName === 'tmpfs') return 'memory'; + return 'indexeddb'; +} + +const NAME_PREFIX: Record = { + indexeddb: 'idb', + opfs: 'opfs', + memory: 'mem', +}; + +export async function probeStorage(opts: { + scan?: boolean; + zfs?: ZenFsLike; +}): Promise { + const { mounts } = await import('@zenfs/core'); + const usage = await getStorageUsage(); + const modelBytes = await opfsDirBytes(OPFS_MODELS_DIR); + + const devices: StorageDevice[] = []; + mounts.forEach((fs, mountPoint) => { + const type = deviceType(fs.name); + const label = type === 'opfs' ? OPFS_FS_DIR : fs.name; + devices.push({ + name: '', + source: type === 'memory' ? 'tmpfs' : `${type}:${label}`, + type, + label, + uuid: fs.uuid ?? null, + mountPoint, + options: ['rw', ...(usage?.persisted ? ['persisted'] : [])], + used: backendBytes(type, usage?.details, modelBytes), + }); + }); + devices.sort((a, b) => (a.mountPoint ?? '').localeCompare(b.mountPoint ?? '')); + + // Data left behind by the other backend still counts against the quota. + if (!devices.some((d) => d.type === 'opfs') && (await opfsDirBytes(OPFS_FS_DIR)) !== null) { + devices.push({ + name: '', + source: `opfs:${OPFS_FS_DIR}`, + type: 'opfs', + label: OPFS_FS_DIR, + uuid: null, + mountPoint: null, + options: ['unmounted'], + used: backendBytes('opfs', usage?.details, modelBytes), + }); + } + + if (modelBytes !== null) { + devices.push({ + name: '', + source: `opfs:${OPFS_MODELS_DIR}`, + type: 'opfs', + label: OPFS_MODELS_DIR, + uuid: null, + mountPoint: null, + options: ['ro', 'host'], + used: modelBytes, + }); + } + + const counters: Record = {}; + for (const device of devices) { + const prefix = NAME_PREFIX[device.type]; + device.name = `${prefix}${counters[prefix] ?? 0}`; + counters[prefix] = (counters[prefix] ?? 0) + 1; + } + + if (opts.scan && opts.zfs) { + const zfs = opts.zfs; + const mountPoints = devices.map((d) => d.mountPoint).filter((p): p is string => p !== null); + for (const device of devices) { + if (device.mountPoint) device.used = await treeBytes(zfs, device.mountPoint, mountPoints); + } + } + + return { + devices, + origin: usage + ? { host: typeof location === 'undefined' ? 'browser' : location.hostname, ...usage } + : null, + }; +} + +function backendBytes( + type: StorageDevice['type'], + details: Record | undefined, + modelBytes: number | null, +): number | null { + if (type === 'memory') return null; + if (type === 'indexeddb') return details?.indexedDB ?? null; + const opfs = details?.fileSystem; + return opfs === undefined ? null : Math.max(0, opfs - (modelBytes ?? 0)); +} + +/** Apparent size of a subtree, skipping paths owned by a nested mount. */ +async function treeBytes(zfs: ZenFsLike, root: string, allMounts: string[]): Promise { + const nested = allMounts.filter((m) => m !== root && m.startsWith(root === '/' ? '/' : `${root}/`)); + let bytes = 0; + const walk = async (dir: string): Promise => { + for (const name of await zfs.promises.readdir(dir)) { + const path = dir === '/' ? `/${name}` : `${dir}/${name}`; + if (nested.includes(path)) continue; + const st = await zfs.promises.lstat(path); + if (st.isDirectory()) await walk(path); + else bytes += st.size; + } + }; + await walk(root); + return bytes; +} + +const mounted = (devices: StorageDevice[]) => devices.filter((d) => d.mountPoint); + +const QUOTA_NOTE = `The browser gives ONE storage quota to the whole origin, shared by every +backend, so per-device Size/Avail is reported as '-' and only the origin +row carries real capacity. Used is a browser estimate (Chromium only). +`; + +const USAGE: Record = { + mount: `usage: mount + mount -t [-o ] + +With no arguments, list the ZenFS mount points in /etc/mtab style. + +One VFS is *the* filesystem; IndexedDB and OPFS are interchangeable backing +devices that can be attached anywhere in it, side by side. + + -t memory a fresh in-memory filesystem (tmpfs) + -t idb [-o store=NAME] an IndexedDB object store (default store: artipodfs) + -t opfs [-o dir=PATH] the OPFS sandbox dir, or a subdirectory of it + +The mount point is created if it does not exist. +`, + umount: `usage: umount [-f] + +Detach a filesystem. / and /proc are never unmountable; a device holding +persistent data (IndexedDB, OPFS) needs -f, since unmounting it hides data +that is still there. +`, + df: `usage: df [-h] [-k] [-T] [--scan] [path...] + + -h human-readable sizes + -k 1K blocks (default) + -T add a filesystem Type column + --scan walk the tree for exact Used bytes instead of the browser estimate + --help this message + +-v -P -l -a --sync --no-sync --local are accepted and ignored, as in real df. + +${QUOTA_NOTE}`, + findmnt: `usage: findmnt + +List the mounted filesystems as TARGET / SOURCE / FSTYPE / OPTIONS. +`, + lsblk: `usage: lsblk [-f] + + -f show FSTYPE, LABEL, UUID and FSUSE% instead of SIZE and TYPE + +The origin quota is the disk; each storage backend is a partition on it. +${QUOTA_NOTE}`, + fdisk: `usage: fdisk -l + +Print the origin quota as a disk with each storage backend as a partition. +Read-only: partitioning is not supported. +${QUOTA_NOTE}`, + diskutil: `usage: diskutil list + +Print the origin quota and its storage backends in diskutil's format. +Read-only: only 'list' is supported. +${QUOTA_NOTE}`, +}; + +/** `--help` everywhere, plus `-h` where df hasn't already claimed it. */ +function helpFor(name: string, args: string[], shortFlag = true): ExecResult | null { + const flags = expandFlags(args); + if (flags.includes('--help') || (shortFlag && flags.includes('-h'))) { + return { stdout: USAGE[name], stderr: '', exitCode: 0 }; + } + return null; +} + +const MOUNT_TYPES: Record = { + memory: 'memory', + mem: 'memory', + tmpfs: 'memory', + idb: 'indexeddb', + indexeddb: 'indexeddb', + opfs: 'opfs', +}; + +/** `-o store=x,dir=y` → `{ store: 'x', dir: 'y' }`. */ +function parseMountOptions(raw: string | undefined): Record { + const out: Record = {}; + for (const part of (raw ?? '').split(',').filter(Boolean)) { + const eq = part.indexOf('='); + out[eq < 0 ? part : part.slice(0, eq)] = eq < 0 ? '' : part.slice(eq + 1); + } + return out; +} + +/** Mount points the shell cannot function without. */ +const PINNED_MOUNTS = new Set(['/', '/proc']); + +const makeMountCommand = () => + defineCommand('mount', async (args, ctx) => { + const help = helpFor('mount', args); + if (help) return help; + + if (!args.length) { + const { devices } = await probeStorage({}); + const lines = mounted(devices).map( + (d) => `${d.source} on ${d.mountPoint} type ${d.type} (${d.options.join(',')})`, + ); + return { stdout: `${lines.join('\n')}\n`, stderr: '', exitCode: 0 }; + } + + let type: string | undefined; + let options: string | undefined; + const rest: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '-t') type = args[++i]; + else if (args[i] === '-o') options = args[++i]; + else rest.push(args[i]); + } + if (!type || rest.length !== 1) { + return fail('mount', "usage: mount -t [-o ] (try 'mount --help')"); + } + const backend = MOUNT_TYPES[type]; + if (!backend) return fail('mount', `unknown filesystem type '${type}'`); + + const { store, dir } = parseMountOptions(options); + const path = ctx.fs.resolvePath(ctx.cwd, rest[0]); + try { + await mountBackend(path, { type: backend, store: store || undefined, dir: dir || undefined }); + } catch (e) { + return fail('mount', (e as Error).message); + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + +const makeUmountCommand = () => + defineCommand('umount', async (args, ctx) => { + const help = helpFor('umount', args); + if (help) return help; + const flags = expandFlags(args); + const force = flags.includes('-f') || flags.includes('--force'); + const target = flags.find((a) => !a.startsWith('-')); + if (!target) return fail('umount', "missing operand (try 'umount --help')"); + + const path = ctx.fs.resolvePath(ctx.cwd, target); + if (PINNED_MOUNTS.has(path)) return fail('umount', `${path}: cannot unmount`); + + const backend = await backendAt(path); + if (!backend) return fail('umount', `${path}: not mounted`); + if (backend !== 'memory' && !force) { + return fail('umount', `${path}: holds persistent data on ${backend}; use -f to detach anyway`); + } + try { + await unmountBackend(path); + } catch (e) { + return fail('umount', (e as Error).message); + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + +const fail = (command: string, message: string): ExecResult => ({ + stdout: '', + stderr: `${command}: ${message}\n`, + exitCode: 1, +}); + +/** Accepted and ignored, exactly as real df ignores them. */ +const DF_NOOP_FLAGS = new Set(['-v', '-P', '-l', '-a', '--all', '--sync', '--no-sync', '--local']); + +const makeDfCommand = (getZfs: () => ZenFsLike) => + defineCommand('df', async (args, ctx) => { + const help = helpFor('df', args, false); // -h is human-readable, as in real df + if (help) return help; + let human = false; + let scan = false; + let showType = false; + const paths: string[] = []; + for (const arg of expandFlags(args)) { + if (DF_NOOP_FLAGS.has(arg)) continue; + else if (arg === '-h') human = true; + else if (arg === '-k') human = false; + else if (arg === '-T') showType = true; + else if (arg === '--scan') scan = true; + else if (arg.startsWith('-')) + return { + stdout: '', + stderr: `df: unrecognized option '${arg}'\nTry 'df --help' for more information.\n`, + exitCode: 2, + }; + else paths.push(arg); + } + + const report = await probeStorage({ scan, zfs: getZfs() }); + let devices = report.devices; + if (paths.length) { + const wanted = new Set( + paths.map((p) => mountOf(ctx.fs.resolvePath(ctx.cwd, p), devices)).filter(Boolean), + ); + devices = devices.filter((d) => d.mountPoint && wanted.has(d.mountPoint)); + if (!devices.length) return { stdout: '', stderr: 'df: no matching file system\n', exitCode: 1 }; + } + + const withType = (row: string[], type: string) => + showType ? [row[0], type, ...row.slice(1)] : row; + + const rows = devices.map((d) => + withType( + [d.source, '-', fmtSize(d.used, human), '-', '-', d.mountPoint ?? `(${d.options[0]})`], + d.type, + ), + ); + if (!paths.length && report.origin) { + const { host, usage, quota } = report.origin; + rows.push( + withType( + [ + `origin:${host}`, + fmtSize(quota, human), + fmtSize(usage, human), + fmtSize(Math.max(0, quota - usage), human), + pct(usage, quota), + '-', + ], + 'quota', + ), + ); + } + + const header = withType( + ['Filesystem', human ? 'Size' : '1K-blocks', 'Used', 'Avail', 'Use%', 'Mounted on'], + 'Type', + ); + return { + stdout: renderTable(header, rows, showType ? [2, 3, 4, 5] : [1, 2, 3, 4]), + stderr: '', + exitCode: 0, + }; + }); + +const makeFindmntCommand = () => + defineCommand('findmnt', async (args) => { + const help = helpFor('findmnt', args); + if (help) return help; + const { devices } = await probeStorage({}); + const rows = mounted(devices).map((d) => [ + d.mountPoint as string, + d.source, + d.type, + d.options.join(','), + ]); + return { + stdout: renderTable(['TARGET', 'SOURCE', 'FSTYPE', 'OPTIONS'], rows), + stderr: '', + exitCode: 0, + }; + }); + +const makeLsblkCommand = () => + defineCommand('lsblk', async (args) => { + const help = helpFor('lsblk', args); + if (help) return help; + let fsMode = false; + for (const arg of expandFlags(args)) { + if (arg === '-f' || arg === '--fs') fsMode = true; + else return { stdout: '', stderr: USAGE.lsblk, exitCode: 2 }; + } + + const { devices, origin } = await probeStorage({}); + const quota = origin?.quota; + const branch = (i: number) => (i === devices.length - 1 ? '└─' : '├─'); + + const header = fsMode + ? ['NAME', 'FSTYPE', 'LABEL', 'UUID', 'FSUSE%', 'MOUNTPOINTS'] + : ['NAME', 'SIZE', 'TYPE', 'MOUNTPOINTS']; + const rows = [ + fsMode + ? ['origin', 'quota', origin?.host ?? '-', '-', pct(origin?.usage ?? null, quota), ''] + : ['origin', fmtSize(quota ?? null, true), 'disk', ''], + ...devices.map((d, i) => + fsMode + ? [ + `${branch(i)}${d.name}`, + d.type, + d.label, + d.uuid ?? '-', + pct(d.used, quota), + d.mountPoint ?? '', + ] + : [`${branch(i)}${d.name}`, '-', 'part', d.mountPoint ?? ''], + ), + ]; + return { stdout: renderTable(header, rows), stderr: '', exitCode: 0 }; + }); + +const makeFdiskCommand = () => + defineCommand('fdisk', async (args) => { + const help = helpFor('fdisk', args); + if (help) return help; + if (!args.includes('-l') && !args.includes('--list')) { + return { + stdout: '', + stderr: "fdisk: read-only view; try 'fdisk -l' or 'fdisk --help'\n", + exitCode: 1, + }; + } + const { devices, origin } = await probeStorage({}); + const size = origin ? `${fmtSize(origin.quota, true)}iB, ${origin.quota} bytes` : 'size unavailable'; + const head = [ + `Disk /dev/origin: ${size}`, + `Disk model: browser storage quota (${origin?.host ?? 'no browser'})`, + 'Units: bytes — a quota is not a block device, so there are no sectors', + 'Disklabel type: browser-storage (one quota shared by every partition)', + '', + '', + ].join('\n'); + const rows = devices.map((d) => [ + `/dev/${d.name}`, + d.type, + fmtSize(d.used, true), + d.mountPoint ?? '-', + ]); + return { + stdout: head + renderTable(['Device', 'Type', 'Used', 'Mountpoint'], rows, [2]), + stderr: '', + exitCode: 0, + }; + }); + +const makeDiskutilCommand = () => + defineCommand('diskutil', async (args) => { + const help = helpFor('diskutil', args); + if (help) return help; + if (args[0] === 'help') return { stdout: USAGE.diskutil, stderr: '', exitCode: 0 }; + if (args[0] !== 'list') { + return { + stdout: '', + stderr: "diskutil: read-only view; only 'diskutil list' is supported\n", + exitCode: 1, + }; + } + const { devices, origin } = await probeStorage({}); + const rows = [ + [ + '0:', + 'browser_quota', + origin?.host ?? 'no browser', + fmtSize(origin?.quota ?? null, true), + 'origin', + ], + ...devices.map((d, i) => [`${i + 1}:`, d.type, d.label, fmtSize(d.used, true), d.name]), + ]; + const table = renderTable(['#:', 'TYPE', 'NAME', 'SIZE', 'IDENTIFIER'], rows, [0, 1, 3]) + .replace(/^/gm, ' ') + .replace(/ +$/gm, ''); + return { + stdout: `/dev/origin (browser, shared quota):\n${table}`, + stderr: '', + exitCode: 0, + }; + }); + +export const makeStorageCommands = (getZfs: () => ZenFsLike) => [ + makeMountCommand(), + makeUmountCommand(), + makeDfCommand(getZfs), + makeFindmntCommand(), + makeLsblkCommand(), + makeFdiskCommand(), + makeDiskutilCommand(), +]; + +/** Expands clustered short flags so `df -vh` behaves like `df -v -h`. */ +function expandFlags(args: string[]): string[] { + const out: string[] = []; + for (const arg of args) { + if (/^-[^-]{2,}$/.test(arg)) out.push(...arg.slice(1).split('').map((c) => `-${c}`)); + else out.push(arg); + } + return out; +} + +/** Longest mount point that is a prefix of `path`. */ +function mountOf(path: string, devices: StorageDevice[]): string { + let best = ''; + for (const d of devices) { + const m = d.mountPoint; + if (!m) continue; + if ((path === m || path.startsWith(m === '/' ? '/' : `${m}/`)) && m.length >= best.length) best = m; + } + return best; +} + +function pct(used: number | null, quota: number | undefined): string { + if (used === null || !quota) return '-'; + return `${Math.round((used / quota) * 100)}%`; +} + +const UNITS = ['K', 'M', 'G', 'T', 'P']; + +function fmtSize(bytes: number | null, human: boolean): string { + if (bytes === null) return '-'; + if (!human) return String(Math.ceil(bytes / 1024)); + if (bytes === 0) return '0'; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < UNITS.length - 1) { + value /= 1024; + unit++; + } + return `${value < 10 && value % 1 !== 0 ? value.toFixed(1) : Math.round(value)}${UNITS[unit]}`; +} diff --git a/src/sandbox/storage.test.ts b/src/sandbox/storage.test.ts new file mode 100644 index 0000000..8bfcbed --- /dev/null +++ b/src/sandbox/storage.test.ts @@ -0,0 +1,41 @@ +/** + * Migration copy/verify logic over live ZenFS mounts (memory→memory in Node; + * the same code path serves indexeddb↔opfs in the browser). + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { migrateStorage, type MigrationProgress } from './storage.js'; + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); +}); + +describe('migrateStorage', () => { + it('copies all files and directories and reports progress', async () => { + await zfs.promises.mkdir('/repo/src/deep', { recursive: true }); + await zfs.promises.writeFile('/repo/readme.md', 'hello'); + await zfs.promises.writeFile('/repo/src/a.ts', 'const a = 1;'); + await zfs.promises.writeFile('/repo/src/deep/b.bin', new Uint8Array([1, 2, 3])); + + const events: MigrationProgress[] = []; + const { files, bytes } = await migrateStorage('memory', (p) => events.push(p)); + + expect(files).toBe(3); + expect(bytes).toBe(5 + 12 + 3); + expect(events.length).toBe(3); + expect(events[events.length - 1].copied).toBe(3); + // migration mount must be gone again + expect(await zfs.promises.exists('/__migrate')).toBe(false); + }); + + it('handles an empty filesystem', async () => { + const { files, bytes } = await migrateStorage('memory'); + expect(files).toBe(0); + expect(bytes).toBe(0); + }); +}); diff --git a/src/sandbox/storage.ts b/src/sandbox/storage.ts new file mode 100644 index 0000000..90c8dfc --- /dev/null +++ b/src/sandbox/storage.ts @@ -0,0 +1,390 @@ +/** + * Storage backend selection, detection, migration and the multi-tab guard. + * + * Browser-only (uses navigator/localStorage inside functions, never at module + * top level). ZenFS backends are dynamically imported so nothing heavy or + * DOM-bound lands in server bundles. + * + * Default stays IndexedDB until OPFS e2e is proven (plan §7 risk table); + * OPFS is opt-in via the settings UI, persisted in localStorage. + */ + +export type StorageBackend = 'opfs' | 'indexeddb' | 'memory'; + +type MountConfig = + | import('@zenfs/core').MountConfiguration<(typeof import('@zenfs/core'))['InMemory']> + | import('@zenfs/core').MountConfiguration<(typeof import('@zenfs/dom'))['IndexedDB']> + | import('@zenfs/core').MountConfiguration<(typeof import('@zenfs/dom'))['WebAccess']>; + +const PREF_KEY = 'artipod-sync-storage-backend'; +const LOCK_NAME = 'artipod-sync-fs'; +/** + * The default IndexedDB object store. One VFS is *the* filesystem — IndexedDB + * and OPFS are interchangeable backing devices for it — so the store is named + * after the filesystem, not after git, which is only one of its consumers. + */ +export const IDB_STORE = 'artipodfs'; +/** Pre-`artipodfs` store name; read once at first boot, then never again. */ +export const LEGACY_IDB_STORE = 'browser-git-fs'; +const MIGRATE_MOUNT = '/__migrate'; +const LEGACY_MOUNT = '/__legacy'; +/** Sandbox subtree inside OPFS — siblings (artipod-models/) stay invisible to agents. */ +export const OPFS_FS_DIR = 'artipod-fs'; +export const OPFS_MODELS_DIR = 'artipod-models'; + +export function loadBackendPref(): StorageBackend | null { + try { + const v = localStorage.getItem(PREF_KEY); + return v === 'opfs' || v === 'indexeddb' || v === 'memory' ? v : null; + } catch { + return null; + } +} + +export function saveBackendPref(backend: StorageBackend): void { + if (typeof localStorage === 'undefined') return; // Node tests + localStorage.setItem(PREF_KEY, backend); +} + +/** OPFS availability: API presence + a real handle probe (Safari private mode etc). */ +export async function supportsOpfs(): Promise { + try { + if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return false; + await navigator.storage.getDirectory(); + return true; + } catch { + return false; + } +} + +async function mountConfigFor(backend: StorageBackend): Promise { + const { InMemory } = await import('@zenfs/core'); + if (backend === 'memory') return { backend: InMemory }; + const { IndexedDB, WebAccess } = await import('@zenfs/dom'); + if (backend === 'opfs') { + return { backend: WebAccess, handle: await opfsSandboxDir() }; + } + return { backend: IndexedDB, storeName: IDB_STORE }; +} + +/** What `mount -t [-o ...]` resolves to. */ +export interface MountSpec { + type: StorageBackend; + /** IndexedDB object store. Default: `artipodfs`. */ + store?: string; + /** OPFS subdirectory below the sandbox dir. Default: the sandbox dir itself. */ + dir?: string; +} + +async function mountConfigForSpec(spec: MountSpec): Promise { + const { InMemory } = await import('@zenfs/core'); + if (spec.type === 'memory') return { backend: InMemory }; + const { IndexedDB, WebAccess } = await import('@zenfs/dom'); + if (spec.type === 'opfs') { + let handle = await opfsSandboxDir(); + for (const segment of (spec.dir ?? '').split('/').filter(Boolean)) { + handle = await handle.getDirectoryHandle(segment, { create: true }); + } + return { backend: WebAccess, handle }; + } + return { backend: IndexedDB, storeName: spec.store ?? IDB_STORE }; +} + +/** + * Attaches a backing device at `path`. ZenFS only surfaces a mount point that + * already exists as a directory underneath, and the mkdir has to happen before + * the mount or it lands inside the new filesystem instead. + */ +export async function mountBackend(path: string, spec: MountSpec): Promise { + const { fs, mount, mounts, resolveMountConfig } = await import('@zenfs/core'); + if (!path.startsWith('/')) throw new Error(`mount point must be absolute: ${path}`); + if (mounts.has(path)) throw new Error(`mount point is already in use: ${path}`); + const resolved = await resolveMountConfig(await mountConfigForSpec(spec)); + if (path !== '/' && !(await fs.promises.exists(path))) { + await fs.promises.mkdir(path, { recursive: true }); + } + mount(path, resolved); +} + +export async function unmountBackend(path: string): Promise { + const { mounts, umount } = await import('@zenfs/core'); + if (!mounts.has(path)) throw new Error(`not mounted: ${path}`); + umount(path); +} + +/** The backend kind currently mounted at `path`, or null when nothing is. */ +export async function backendAt(path: string): Promise { + const { mounts } = await import('@zenfs/core'); + const fs = mounts.get(path); + if (!fs) return null; + if (fs.name === 'webaccessfs') return 'opfs'; + if (fs.name === 'tmpfs') return 'memory'; + return 'indexeddb'; +} + +/** + * The sandbox mounts the artipod-fs/ SUBDIRECTORY, not the OPFS root, so + * model weights and other host data can live beside it out of agent reach. + * Data written by earlier builds (which mounted the root) is moved in once. + */ +async function opfsSandboxDir(): Promise { + const root = await navigator.storage.getDirectory(); + const fsDir = await root.getDirectoryHandle(OPFS_FS_DIR, { create: true }); + await moveLegacyRootEntries(root, fsDir); + return fsDir; +} + +const KEEP_AT_ROOT = new Set([OPFS_FS_DIR, OPFS_MODELS_DIR]); + +async function listEntries(dir: FileSystemDirectoryHandle): Promise<[string, FileSystemHandle][]> { + const out: [string, FileSystemHandle][] = []; + const it = (dir as unknown as { entries(): AsyncIterableIterator<[string, FileSystemHandle]> }).entries(); + for (;;) { + const { done, value } = await it.next(); + if (done) break; + out.push(value); + } + return out; +} + +async function copyHandle( + source: FileSystemHandle, + destParent: FileSystemDirectoryHandle, + name: string, +): Promise { + if (source.kind === 'file') { + const file = await (source as FileSystemFileHandle).getFile(); + const target = await destParent.getFileHandle(name, { create: true }); + const writable = await target.createWritable(); + await writable.write(file); + await writable.close(); + return; + } + const destDir = await destParent.getDirectoryHandle(name, { create: true }); + for (const [childName, child] of await listEntries(source as FileSystemDirectoryHandle)) { + await copyHandle(child, destDir, childName); + } +} + +async function moveLegacyRootEntries( + root: FileSystemDirectoryHandle, + fsDir: FileSystemDirectoryHandle, +): Promise { + for (const [name, handle] of await listEntries(root)) { + if (KEEP_AT_ROOT.has(name)) continue; + await copyHandle(handle, fsDir, name); + await root.removeEntry(name, { recursive: true }); + } +} + +export interface InitResult { + backend: StorageBackend; + /** False when another tab already owns the filesystem. Advisory: writes are not blocked. */ + isPrimaryTab: boolean; +} + +let releaseLock: (() => void) | null = null; + +/** + * Single-writer guard: two tabs on one store corrupt state (ZenFS instances + * don't sync). First tab holds a Web Lock until it closes; later tabs see + * isPrimaryTab=false and warn the user — nothing stops them writing. + */ +async function acquireSingleWriterLock(): Promise { + if (typeof navigator === 'undefined' || !navigator.locks) return true; // older browsers: no guard + if (releaseLock) return true; + return new Promise((resolve) => { + navigator.locks.request(LOCK_NAME, { ifAvailable: true }, (lock) => { + if (!lock) { + resolve(false); + return; + } + resolve(true); + // Hold the lock for the lifetime of the tab. + return new Promise((release) => { + releaseLock = release; + }); + }); + }); +} + +export async function initFileSystem(pref?: StorageBackend): Promise { + const requested = pref ?? loadBackendPref() ?? 'indexeddb'; + const backend: StorageBackend = + requested === 'opfs' && !(await supportsOpfs()) ? 'indexeddb' : requested; + + const isPrimaryTab = await acquireSingleWriterLock(); + + const { configure, fs } = await import('@zenfs/core'); + try { + await configure({ mounts: { '/': await mountConfigFor(backend) } }); + } catch (e) { + if (!(e instanceof Error) || !e.message.includes('Mount point is already in use')) { + throw e; + } + } + if (backend === 'indexeddb') await adoptLegacyStore(); + if (!(await fs.promises.exists('/repo'))) { + await fs.promises.mkdir('/repo'); + } + return { backend, isPrimaryTab }; +} + +/** + * One-time `browser-git-fs` → `artipodfs` adoption. Only runs when the new + * store is untouched, so it can never overwrite live data, and the legacy store + * is never written to — after this the old name is dead. + */ +async function adoptLegacyStore(): Promise { + const { fs, mount, mounts, resolveMountConfig, umount } = await import('@zenfs/core'); + const p = fs.promises; + if (mounts.has(LEGACY_MOUNT) || (await p.readdir('/')).length) return; + + const { IndexedDB } = await import('@zenfs/dom'); + await p.mkdir(LEGACY_MOUNT); + mount(LEGACY_MOUNT, await resolveMountConfig({ backend: IndexedDB, storeName: LEGACY_IDB_STORE })); + try { + if ((await p.readdir(LEGACY_MOUNT)).length) await copyTree(fs, LEGACY_MOUNT, '/'); + } finally { + umount(LEGACY_MOUNT); + await p.rmdir(LEGACY_MOUNT).catch(() => undefined); + } +} + +export interface MigrationProgress { + copied: number; + total: number; + currentPath: string; +} + +/** + * Copy everything from the live backend into `to`, verify file count + bytes, + * flip the preference. Caller must reload the page afterwards. + */ +export async function migrateStorage( + to: StorageBackend, + onProgress?: (p: MigrationProgress) => void, +): Promise<{ files: number; bytes: number }> { + const { fs, mount, resolveMountConfig, umount } = await import('@zenfs/core'); + + // Attach the target as a nested mount, then walk-copy (skipping the mount itself). + mount(MIGRATE_MOUNT, await resolveMountConfig(await mountConfigFor(to))); + try { + const result = await copyTree(fs, '/', MIGRATE_MOUNT, onProgress); + saveBackendPref(to); + return result; + } finally { + umount(MIGRATE_MOUNT); + } +} + +type ZenFs = (typeof import('@zenfs/core'))['fs']; + +/** + * Walk-copy `from` → `to`, skipping nested mount points, then verify every + * file arrived at the same size. Shared by the backend migration and the + * legacy-store adoption. + */ +async function copyTree( + fs: ZenFs, + from: string, + to: string, + onProgress?: (p: MigrationProgress) => void, +): Promise<{ files: number; bytes: number }> { + const { mounts } = await import('@zenfs/core'); + const p = fs.promises; + const nested = [...mounts.keys()].filter((m) => m !== from && isBelow(m, from)); + + const files: string[] = []; + const dirs: string[] = []; + const walk = async (dir: string): Promise => { + for (const name of await p.readdir(dir)) { + const path = dir === '/' ? `/${name}` : `${dir}/${name}`; + if (nested.includes(path)) continue; + const st = await p.lstat(path); + if (st.isDirectory()) { + dirs.push(path); + await walk(path); + } else if (st.isFile()) { + files.push(path); + } + // symlinks: isomorphic-git materializes none by default; skip safely + } + }; + await walk(from); + + const target = (path: string) => { + const rel = from === '/' ? path : path.slice(from.length); + return to === '/' ? rel : `${to}${rel}`; + }; + + let copied = 0; + let bytes = 0; + for (const dir of dirs) { + await p.mkdir(target(dir), { recursive: true }); + } + for (const file of files) { + const data = await p.readFile(file); + await p.writeFile(target(file), data); + bytes += data.byteLength; + copied++; + onProgress?.({ copied, total: files.length, currentPath: file }); + } + + for (const file of files) { + const st = await p.stat(target(file)); + const src = await p.stat(file); + if (st.size !== src.size) { + throw new Error(`migration verify failed for ${file}: ${st.size} != ${src.size}`); + } + } + return { files: files.length, bytes }; +} + +const isBelow = (path: string, root: string) => path.startsWith(root === '/' ? '/' : `${root}/`); + +export interface StorageUsage { + usage: number; + quota: number; + persisted: boolean; + /** Chromium-only per-backend split (`indexedDB`, `fileSystem`, `caches`, ...). */ + details: Record; +} + +export async function getStorageUsage(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.estimate) return null; + const estimate = (await navigator.storage.estimate()) as StorageEstimate & { + usageDetails?: Record; + }; + const { usage = 0, quota = 0 } = estimate; + const persisted = (await navigator.storage.persisted?.()) ?? false; + return { usage, quota, persisted, details: estimate.usageDetails ?? {} }; +} + +/** Apparent bytes under a top-level OPFS directory, or null when unavailable. */ +export async function opfsDirBytes(name: string): Promise { + try { + if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null; + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle(name); // no create: absent means "no such device" + return await sumDirBytes(dir); + } catch { + return null; + } +} + +async function sumDirBytes(dir: FileSystemDirectoryHandle): Promise { + let bytes = 0; + for (const [, handle] of await listEntries(dir)) { + bytes += + handle.kind === 'file' + ? (await (handle as FileSystemFileHandle).getFile()).size + : await sumDirBytes(handle as FileSystemDirectoryHandle); + } + return bytes; +} + +export async function requestPersistence(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false; + return navigator.storage.persist(); +} diff --git a/src/sandbox/table.ts b/src/sandbox/table.ts new file mode 100644 index 0000000..a41c6ac --- /dev/null +++ b/src/sandbox/table.ts @@ -0,0 +1,14 @@ +/** Shared column layout for the shell's table-shaped commands. */ +export function renderTable( + header: string[], + rows: string[][], + rightAligned: number[] = [], +): string { + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); + const line = (cells: string[]) => + cells + .map((c, i) => (rightAligned.includes(i) ? c.padStart(widths[i]) : c.padEnd(widths[i]))) + .join(' ') + .trimEnd(); + return `${[line(header), ...rows.map(line)].join('\n')}\n`; +} diff --git a/src/sandbox/types.ts b/src/sandbox/types.ts new file mode 100644 index 0000000..9cea6a7 --- /dev/null +++ b/src/sandbox/types.ts @@ -0,0 +1,44 @@ +/** + * Shared types for the sandbox core. + * + * This module (and everything under lib/sandbox/) must stay framework-free: + * no React, no Next, no `window` at module top level. Type-only imports of + * @zenfs/core are erased at compile time, so this works in browser and Node. + */ + +/** The node-like ZenFS `fs` object (or a bound context with the same shape). */ +export type ZenFsLike = (typeof import('@zenfs/core'))['fs']; + +export interface SandboxExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface SandboxExecOptions { + /** Cooperative cancellation (Ctrl+C, agent abort). */ + signal?: AbortSignal; + /** + * When true, the result's env/cwd are NOT absorbed into the session and + * the line is not recorded in history. Used for hidden helper execs + * (e.g. tab-completion via compgen). + */ + transient?: boolean; +} + +export interface CompletionResult { + /** Sorted unique candidates; directories carry a trailing `/`. */ + candidates: string[]; + /** Index in the input line where the token being completed starts. */ + replaceStart: number; +} + +export interface Sandbox { + exec(line: string, opts?: SandboxExecOptions): Promise; + getCwd(): string; + /** The carried session environment (vars, exports, BASH_ALIAS_* entries). */ + getEnv(): Readonly>; + /** Tab-completion for an input line (commands, aliases, paths). */ + complete(line: string): Promise; + fs: import('./zenfs-adapter.js').ZenFsAdapter; +} diff --git a/src/sandbox/zenfs-adapter.test.ts b/src/sandbox/zenfs-adapter.test.ts new file mode 100644 index 0000000..024c99a --- /dev/null +++ b/src/sandbox/zenfs-adapter.test.ts @@ -0,0 +1,139 @@ +/** + * IFileSystem contract tests for ZenFsAdapter, mirroring + * just-bash/src/fs/interface.contract.test.ts plus error-shape checks. + * Runs over an in-memory ZenFS in Node — same code path as the browser. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { ZenFsAdapter } from './zenfs-adapter.js'; + +async function freshAdapter(): Promise { + try { + umount('/'); + } catch { + // first run: nothing mounted yet + } + await configure({ mounts: { '/': InMemory } }); + return new ZenFsAdapter(zfs); +} + +let fs: ZenFsAdapter; + +beforeEach(async () => { + fs = await freshAdapter(); +}); + +describe('ZenFsAdapter IFileSystem contract', () => { + it('reads, writes, appends, stats, lists, and removes files', async () => { + await fs.mkdir('/docs', { recursive: true }); + await fs.writeFile('/docs/readme.md', 'hello'); + await fs.appendFile('/docs/readme.md', ' world'); + + expect(await fs.readFile('/docs/readme.md')).toBe('hello world'); + expect(await fs.exists('/docs/readme.md')).toBe(true); + expect((await fs.stat('/docs/readme.md')).isFile).toBe(true); + expect(await fs.readdir('/docs')).toContain('readme.md'); + + await fs.rm('/docs/readme.md'); + expect(await fs.exists('/docs/readme.md')).toBe(false); + }); + + it('copies and moves files without changing file contents', async () => { + await fs.mkdir('/tmp', { recursive: true }); + await fs.writeFile('/tmp/source.txt', 'contents'); + await fs.cp('/tmp/source.txt', '/tmp/copy.txt'); + await fs.mv('/tmp/copy.txt', '/tmp/moved.txt'); + + expect(await fs.readFile('/tmp/source.txt')).toBe('contents'); + expect(await fs.readFile('/tmp/moved.txt')).toBe('contents'); + expect(await fs.exists('/tmp/copy.txt')).toBe(false); + }); + + it('copies directories only when recursive is set', async () => { + await fs.mkdir('/dir/sub', { recursive: true }); + await fs.writeFile('/dir/sub/f.txt', 'x'); + + await expect(fs.cp('/dir', '/dir2')).rejects.toThrow(); + await fs.cp('/dir', '/dir3', { recursive: true }); + expect(await fs.readFile('/dir3/sub/f.txt')).toBe('x'); + }); + + it('resolves relative paths consistently', () => { + expect(fs.resolvePath('/work', 'file.txt')).toBe('/work/file.txt'); + expect(fs.resolvePath('/work', '../file.txt')).toBe('/file.txt'); + expect(fs.resolvePath('/work', '/absolute.txt')).toBe('/absolute.txt'); + expect(fs.resolvePath('/', 'a/./b/../c')).toBe('/a/c'); + expect(fs.resolvePath('/a', '../../..')).toBe('/'); + }); + + it('creates symlinks and keeps lstat/stat semantics apart', async () => { + await fs.writeFile('/target.txt', 'target'); + await fs.symlink('/target.txt', '/link.txt'); + + expect(await fs.readlink('/link.txt')).toBe('/target.txt'); + expect((await fs.lstat('/link.txt')).isSymbolicLink).toBe(true); + expect((await fs.stat('/link.txt')).isFile).toBe(true); + expect(await fs.readFile('/link.txt')).toBe('target'); + expect(await fs.realpath('/link.txt')).toBe('/target.txt'); + }); + + it('creates hard links sharing content', async () => { + await fs.writeFile('/orig.txt', 'data'); + await fs.link('/orig.txt', '/hard.txt'); + expect(await fs.readFile('/hard.txt')).toBe('data'); + }); + + it('supports binary reads and buffer round-trips', async () => { + const bytes = new Uint8Array([0, 1, 2, 250, 255]); + await fs.writeFile('/bin.dat', bytes); + expect(Array.from(await fs.readFileBuffer('/bin.dat'))).toEqual([0, 1, 2, 250, 255]); + }); + + it('lists directory entries with file types', async () => { + await fs.mkdir('/d/sub', { recursive: true }); + await fs.writeFile('/d/a.txt', 'A'); + const entries = await fs.readdirWithFileTypes('/d'); + const byName = Object.fromEntries(entries.map((e) => [e.name, e])); + expect(byName['sub'].isDirectory).toBe(true); + expect(byName['a.txt'].isFile).toBe(true); + }); + + it('chmod and utimes update metadata', async () => { + await fs.writeFile('/m.txt', 'm'); + await fs.chmod('/m.txt', 0o755); + expect((await fs.stat('/m.txt')).mode & 0o777).toBe(0o755); + + const when = new Date('2020-01-02T03:04:05Z'); + await fs.utimes('/m.txt', when, when); + expect((await fs.stat('/m.txt')).mtime.getTime()).toBe(when.getTime()); + }); + + it('exposes stable identity for cp/mv cycle detection', async () => { + await fs.writeFile('/id.txt', 'x'); + const s = await fs.stat('/id.txt'); + expect(s.identity).toBe(`${s.dev}:${s.ino}`); + }); + + it('rm honors force and recursive flags', async () => { + await expect(fs.rm('/nope')).rejects.toThrow(); + await expect(fs.rm('/nope', { force: true })).resolves.toBeUndefined(); + + await fs.mkdir('/full'); + await fs.writeFile('/full/f.txt', 'f'); + await fs.rm('/full', { recursive: true }); + expect(await fs.exists('/full')).toBe(false); + }); + + it('throws node-shaped errors just-bash can match on', async () => { + // ENOENT on missing file — message mentions the code, like node + await expect(fs.readFile('/missing.txt')).rejects.toThrow(/ENOENT/); + await expect(fs.stat('/missing.txt')).rejects.toThrow(/ENOENT/); + // EEXIST on mkdir over existing path + await fs.mkdir('/dup'); + await expect(fs.mkdir('/dup')).rejects.toThrow(/EEXIST/); + }); + + it('getAllPaths degrades gracefully (documented ls-glob edge case)', () => { + expect(fs.getAllPaths()).toEqual([]); + }); +}); diff --git a/src/sandbox/zenfs-adapter.ts b/src/sandbox/zenfs-adapter.ts new file mode 100644 index 0000000..325aed9 --- /dev/null +++ b/src/sandbox/zenfs-adapter.ts @@ -0,0 +1,195 @@ +/** + * ZenFsAdapter — bridges just-bash's IFileSystem contract onto the ZenFS + * node-like `fs.promises` API. + * + * ZenFS stays the single source of truth: the shell sees files through this + * adapter while isomorphic-git / Monaco / the file tree keep using the same + * ZenFS instance directly, so all views are always coherent. + * + * Errors are passed through unchanged: ZenFS throws node-shaped ErrnoErrors + * ("ENOENT: No such file or directory, open '/x'"), which is exactly what + * just-bash matches on. + */ +import type { + BufferEncoding as JbBufferEncoding, + CpOptions, + FsStat, + IFileSystem, + MkdirOptions, + RmOptions, +} from 'just-bash/browser'; +import type { ZenFsLike } from './types.js'; + +interface DirentEntry { + name: string; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +type ReadOptions = { encoding?: JbBufferEncoding | null } | JbBufferEncoding; +type WriteOptions = { encoding?: JbBufferEncoding } | JbBufferEncoding; + +function normalizeEncoding(options?: ReadOptions | WriteOptions): JbBufferEncoding | undefined { + if (!options) return undefined; + if (typeof options === 'string') return options; + return options.encoding ?? undefined; +} + +/** Pure POSIX path normalization — no fs access (mirrors just-bash path-utils). */ +export function normalizePath(path: string): string { + if (!path || path === '/') return '/'; + let normalized = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path; + if (!normalized.startsWith('/')) normalized = `/${normalized}`; + const parts = normalized.split('/').filter((p) => p && p !== '.'); + const resolved: string[] = []; + for (const part of parts) { + if (part === '..') resolved.pop(); + else resolved.push(part); + } + return `/${resolved.join('/')}` || '/'; +} + +export class ZenFsAdapter implements IFileSystem { + constructor(private zfs: ZenFsLike) {} + + async readFile(path: string, options?: ReadOptions): Promise { + const enc = normalizeEncoding(options) ?? 'utf8'; + return (await this.zfs.promises.readFile(path, enc)) as string; + } + + async readFileBuffer(path: string): Promise { + return new Uint8Array(await this.zfs.promises.readFile(path)); + } + + async writeFile(path: string, content: string | Uint8Array, options?: WriteOptions): Promise { + const enc = normalizeEncoding(options); + if (typeof content === 'string') { + await this.zfs.promises.writeFile(path, content, enc ?? 'utf8'); + } else { + await this.zfs.promises.writeFile(path, content); + } + } + + async appendFile(path: string, content: string | Uint8Array, options?: WriteOptions): Promise { + const enc = normalizeEncoding(options); + if (typeof content === 'string') { + await this.zfs.promises.appendFile(path, content, enc ?? 'utf8'); + } else { + await this.zfs.promises.appendFile(path, content); + } + } + + async exists(path: string): Promise { + try { + await this.zfs.promises.lstat(path); + return true; + } catch { + return false; + } + } + + private toFsStat(s: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode: number; + size: number; + mtime: Date; + dev: number | bigint; + ino: number | bigint; + }): FsStat { + return { + isFile: s.isFile(), + isDirectory: s.isDirectory(), + isSymbolicLink: s.isSymbolicLink(), + mode: s.mode, + size: s.size, + mtime: s.mtime, + dev: s.dev, + ino: s.ino, + identity: `${s.dev}:${s.ino}`, + }; + } + + async stat(path: string): Promise { + return this.toFsStat(await this.zfs.promises.stat(path)); + } + + async lstat(path: string): Promise { + return this.toFsStat(await this.zfs.promises.lstat(path)); + } + + async mkdir(path: string, options?: MkdirOptions): Promise { + await this.zfs.promises.mkdir(path, { recursive: options?.recursive ?? false }); + } + + async readdir(path: string): Promise { + return await this.zfs.promises.readdir(path); + } + + async readdirWithFileTypes(path: string): Promise { + const dirents = await this.zfs.promises.readdir(path, { withFileTypes: true }); + return dirents.map((d) => ({ + name: d.name, + isFile: d.isFile(), + isDirectory: d.isDirectory(), + isSymbolicLink: d.isSymbolicLink(), + })); + } + + async rm(path: string, options?: RmOptions): Promise { + await this.zfs.promises.rm(path, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }); + } + + async cp(src: string, dest: string, options?: CpOptions): Promise { + const st = await this.zfs.promises.stat(src); + if (st.isDirectory() && !options?.recursive) { + throw new Error(`EISDIR: illegal operation on a directory, cp '${src}'`); + } + await this.zfs.promises.cp(src, dest, { recursive: options?.recursive ?? false }); + } + + async mv(src: string, dest: string): Promise { + await this.zfs.promises.rename(src, dest); + } + + resolvePath(base: string, path: string): string { + if (path.startsWith('/')) return normalizePath(path); + const combined = base === '/' ? `/${path}` : `${base}/${path}`; + return normalizePath(combined); + } + + // Only used by `ls` for unexpanded glob patterns; glob expansion itself is + // readdir-based. Returning [] degrades that one edge case gracefully. + getAllPaths(): string[] { + return []; + } + + async chmod(path: string, mode: number): Promise { + await this.zfs.promises.chmod(path, mode); + } + + async symlink(target: string, linkPath: string): Promise { + await this.zfs.promises.symlink(target, linkPath); + } + + async link(existingPath: string, newPath: string): Promise { + await this.zfs.promises.link(existingPath, newPath); + } + + async readlink(path: string): Promise { + return await this.zfs.promises.readlink(path, 'utf8'); + } + + async realpath(path: string): Promise { + return await this.zfs.promises.realpath(path); + } + + async utimes(path: string, atime: Date, mtime: Date): Promise { + await this.zfs.promises.utimes(path, atime, mtime); + } +} diff --git a/tsconfig.json b/tsconfig.json index d47a0b4..a97d1d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "NodeNext", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "declaration": true, "declarationMap": true, "sourceMap": true, diff --git a/vitest.config.ts b/vitest.config.ts index af68b76..bb4687c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,11 +4,11 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/__tests__/**/*.spec.ts'], + include: ['src/**/__tests__/**/*.spec.ts', 'src/**/*.test.ts'], coverage: { provider: 'v8', include: ['src/**/*.ts'], - exclude: ['src/**/__tests__/**', 'src/**/*.spec.ts'], + exclude: ['src/**/__tests__/**', 'src/**/*.spec.ts', 'src/**/*.test.ts'], reportsDirectory: 'coverage', reporter: ['text', 'lcov', 'html'], }, From 80f861871ab2784ab5395cf94c3c66765805fb82 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sun, 30 Aug 2026 21:22:00 -0400 Subject: [PATCH 02/12] fix(core)!: drop node path/crypto from isomorphic core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artimount/artipod now use dependency-free posix path helpers (pod paths are posix by contract; Windows callers pass forward slashes) and WebCrypto ids — node builtins broke browser bundles (Next/Vite don't polyfill). Root-mount ('/') traversal boundary fixed: '//' prefix never matched. --- src/artimount.ts | 22 ++++++++------- src/artipod.ts | 27 ++++++++++++++----- src/pathUtils.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 src/pathUtils.ts diff --git a/src/artimount.ts b/src/artimount.ts index d2bfd95..35bb73e 100644 --- a/src/artimount.ts +++ b/src/artimount.ts @@ -1,4 +1,4 @@ -import * as path from 'path'; +import { resolvePosix, dirnamePosix, joinPosix, relativePosix } from './pathUtils.js'; import type { PodFs } from './podfs.js'; import { nodePodFs } from './nodePodFs.js'; @@ -20,7 +20,7 @@ export class ArtiMount { */ constructor(name: string, rootPath: string, readonly: boolean = false, podFs?: PodFs) { this.name = name; - this.rootPath = path.resolve(rootPath); + this.rootPath = resolvePosix(rootPath); this.readonly = readonly; this.fs = podFs ?? nodePodFs(); } @@ -76,11 +76,13 @@ export class ArtiMount { * @param relativePath - Path relative to mount root */ private _resolve(relativePath: string): string { - const resolved = path.resolve(this.rootPath, relativePath); + const resolved = resolvePosix(this.rootPath, relativePath); // Prevent path traversal outside mount (exact-boundary check: '/a' must - // not admit '/ab/...', only '/a' itself or '/a/...') - if (resolved !== this.rootPath && !resolved.startsWith(this.rootPath + path.sep)) { + // not admit '/ab/...', only '/a' itself or '/a/...'). Root mounts ('/') + // admit every absolute path. + const boundary = this.rootPath === '/' ? '/' : this.rootPath + '/'; + if (resolved !== this.rootPath && !resolved.startsWith(boundary)) { throw new Error(`Path ${relativePath} is outside mount root`); } @@ -170,7 +172,7 @@ export class ArtiMount { throw new Error(`Cannot write to read-only mount '${this.name}'`); } const fullPath = this._resolve(relativePath); - const dir = path.dirname(fullPath); + const dir = dirnamePosix(fullPath); // Ensure parent directories exist await this.fs.mkdir(dir, { recursive: true }); @@ -204,7 +206,7 @@ export class ArtiMount { // Return paths relative to root with sizes return files.map(f => ({ - path: path.relative(this.rootPath, f.fullPath), + path: relativePosix(this.rootPath, f.fullPath), size: f.size })); } @@ -222,7 +224,7 @@ export class ArtiMount { // Return paths relative to root with sizes and directory flag return entries.map(e => ({ - path: path.relative(this.rootPath, e.fullPath), + path: relativePosix(this.rootPath, e.fullPath), size: e.size, isDirectory: e.isDirectory })); @@ -241,7 +243,7 @@ export class ArtiMount { const dirEntries = await this.fs.readdir(dir, { withFileTypes: true }); for (const entry of dirEntries) { - const fullPath = path.join(dir, entry.name); + const fullPath = joinPosix(dir, entry.name); if (entry.isDirectory()) { entries.push({ fullPath, size: 0, isDirectory: true }); @@ -273,7 +275,7 @@ export class ArtiMount { const entries = await this.fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { - const fullPath = path.join(dir, entry.name); + const fullPath = joinPosix(dir, entry.name); if (entry.isDirectory()) { await this._listRecursive(fullPath, files); diff --git a/src/artipod.ts b/src/artipod.ts index 6f8d674..15abc1f 100644 --- a/src/artipod.ts +++ b/src/artipod.ts @@ -11,8 +11,15 @@ import { import { ArtiPodOptions } from './types.js'; import type { PodFs } from './podfs.js'; import { nodePodFs } from './nodePodFs.js'; -import * as crypto from 'crypto'; -import * as path from 'path'; +import { joinPosix } from './pathUtils.js'; +import { PodEvents } from './events.js'; + +/** Isomorphic 16-byte hex id (WebCrypto exists in browsers and Node ≥20). */ +function randomHexId(): string { + const buf = new Uint8Array(16); + globalThis.crypto.getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); +} interface BuildPromptOptions { maxSize?: number; @@ -36,6 +43,8 @@ export class ArtiPod { private useMainMount: boolean; private initialized: boolean = false; private fs: PodFs; + /** Coherence bus: exec:start/exec:end + coarse fs:changed (plan §3). */ + readonly events = new PodEvents(); /** * Format file size in human-readable format @@ -50,7 +59,7 @@ export class ArtiPod { constructor(options?: ArtiPodOptions) { // Generate or use provided ID - this.id = options?.id || crypto.randomBytes(16).toString('hex'); + this.id = options?.id || randomHexId(); this.workspaceDir = options?.workspaceDir; this.useMainMount = options?.useMainMount ?? true; this.fs = options?.fs ?? nodePodFs(); @@ -102,7 +111,7 @@ export class ArtiPod { // Create and initialize main mount if needed if (this.useMainMount && this.workspaceDir) { - const mainMountPath = path.join(this.workspaceDir, `artipod-${this.id}`); + const mainMountPath = joinPosix(this.workspaceDir, `artipod-${this.id}`); // Create directory (recursive: true means no error if exists) await this.fs.mkdir(mainMountPath, { recursive: true }); @@ -139,7 +148,7 @@ export class ArtiPod { // Delete the directory if (this.workspaceDir) { - const mainMountPath = path.join(this.workspaceDir, `artipod-${this.id}`); + const mainMountPath = joinPosix(this.workspaceDir, `artipod-${this.id}`); await this.fs.rm(mainMountPath, { recursive: true, force: true }); } } @@ -480,7 +489,13 @@ export class ArtiPod { } const effectiveTimeout = timeout ?? this.commandTimeout; - return await executeCommandInContainer(this.container, command, effectiveTimeout); + this.events.emit('exec:start', { line: command }); + const startedAt = Date.now(); + const result = await executeCommandInContainer(this.container, command, effectiveTimeout); + this.events.emit('exec:end', { line: command, exitCode: result.exitCode, durationMs: Date.now() - startedAt }); + // Coarse by contract: a container command can touch any mount. + this.events.emit('fs:changed', { origin: 'exec' }); + return result; } /** diff --git a/src/pathUtils.ts b/src/pathUtils.ts new file mode 100644 index 0000000..5a742da --- /dev/null +++ b/src/pathUtils.ts @@ -0,0 +1,69 @@ +/** + * Dependency-free posix path helpers for the isomorphic core. + * + * Pod paths are posix by contract (container bind mounts, ZenFS and OCI + * layers all speak '/'); Node callers on Windows pass forward-slash paths. + * Pure string ops — removes the `node:path` import that broke browser + * bundles (Next/Vite don't polyfill node builtins). + */ + +/** Collapse '.', '..' and duplicate slashes; result keeps a single leading '/'. */ +export function normalizePosix(p: string): string { + const out: string[] = []; + for (const part of p.split('/')) { + if (!part || part === '.') continue; + if (part === '..') { + out.pop(); + continue; + } + out.push(part); + } + return '/' + out.join('/'); +} + +/** + * posix `path.resolve`: right-most absolute segment wins; relative inputs + * resolve against `process.cwd()` in Node and throw elsewhere (browsers must + * pass absolute pod paths). + */ +export function resolvePosix(...segments: string[]): string { + let acc = ''; + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (!seg) continue; + acc = acc ? `${seg}/${acc}` : seg; + if (seg.startsWith('/')) return normalizePosix(acc); + } + if (typeof process !== 'undefined' && typeof process.cwd === 'function') { + return normalizePosix(`${process.cwd().replace(/\\/g, '/')}/${acc}`); + } + throw new Error(`Cannot resolve relative path '${segments.join(', ')}' without process.cwd(); pass an absolute path`); +} + +/** posix `path.join` for '..'-free segments (mount-internal joins). */ +export function joinPosix(...segments: string[]): string { + const parts = segments.filter(Boolean); + const joined = parts.join('/'); + if (!joined) return '.'; + const abs = joined.startsWith('/'); + const normalized = normalizePosix(joined).slice(1); + return abs ? `/${normalized}` : normalized || '.'; +} + +/** posix `path.dirname`. */ +export function dirnamePosix(p: string): string { + const n = p.length > 1 ? p.replace(/\/+$/, '') : p; + const idx = n.lastIndexOf('/'); + if (idx < 0) return '.'; + if (idx === 0) return '/'; + return n.slice(0, idx); +} + +/** posix `path.relative` for absolute paths. */ +export function relativePosix(from: string, to: string): string { + const f = normalizePosix(from).split('/').filter(Boolean); + const t = normalizePosix(to).split('/').filter(Boolean); + let i = 0; + while (i < f.length && i < t.length && f[i] === t[i]) i++; + return [...f.slice(i).map(() => '..'), ...t.slice(i)].join('/'); +} From a00a96f7c767fc7ef63984ac67d25b2111b851bd Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sun, 30 Aug 2026 21:22:00 -0400 Subject: [PATCH 03/12] feat(core,sandbox): pod events bus + sudo confinement stub PodEvents: exec:start/end, coarse fs:changed per live command (command-boundary invalidation is the contract), edit:request, agent:tool-call, approval:request. sudo is default-deny EPERM until the Phase 6.5 approval flow (Decision #10), pinned by tests, attempt surfaced via approval:request. --- src/events.ts | 98 ++++++++++++++++++++++++++++++++ src/sandbox/edit-command.ts | 11 ++-- src/sandbox/index.ts | 14 ++++- src/sandbox/sudo-command.test.ts | 55 ++++++++++++++++++ src/sandbox/sudo-command.ts | 22 +++++++ 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 src/events.ts create mode 100644 src/sandbox/sudo-command.test.ts create mode 100644 src/sandbox/sudo-command.ts diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..f74cac8 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,98 @@ +/** + * pod.events — the coherence bus for everything operating on one pod + * (plan §3 "Browser UI surfaces"). Shell, tools, editor, tree and agent all + * see the same store; these events keep them in sync without polling. + * + * fs invalidation is command-boundary coarse BY CONTRACT (a bash line can + * touch anything): after every live exec a coarse `fs:changed` fires. Do not + * rely on ZenFS `fs.watch`. + */ + +export interface ExecStartEvent { + line: string; +} + +export interface ExecEndEvent { + line: string; + exitCode: number; + durationMs: number; +} + +export interface FsChangedEvent { + /** + * Affected paths when precisely known (tool edits, editor saves); + * undefined = coarse "anything may have changed" (after a shell command). + */ + paths?: string[]; + origin: 'exec' | 'tool' | 'editor' | 'git' | 'agent' | 'external'; +} + +export interface EditRequestEvent { + path: string; +} + +export interface AgentToolCallEvent { + phase: 'call' | 'result'; + name: string; + /** Raw JSON argument string as the model sent it. */ + arguments: string; + id?: string; + /** Result phase only. */ + ok?: boolean; + summary?: string; +} + +export interface ApprovalRequestEvent { + verb: string; + target?: string; + justification?: string; +} + +export interface PodEventMap { + 'exec:start': ExecStartEvent; + 'exec:end': ExecEndEvent; + 'fs:changed': FsChangedEvent; + 'edit:request': EditRequestEvent; + 'agent:tool-call': AgentToolCallEvent; + 'approval:request': ApprovalRequestEvent; +} + +export type PodEventName = keyof PodEventMap; + +type AnyListener = (payload: never) => void; + +export class PodEvents { + private listeners = new Map>(); + + /** Subscribe; returns the unsubscribe function. */ + on(event: K, listener: (payload: PodEventMap[K]) => void): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(listener as AnyListener); + return () => this.off(event, listener); + } + + off(event: K, listener: (payload: PodEventMap[K]) => void): void { + this.listeners.get(event)?.delete(listener as AnyListener); + } + + emit(event: K, payload: PodEventMap[K]): void { + const set = this.listeners.get(event); + if (!set) return; + for (const listener of [...set]) { + try { + (listener as (p: PodEventMap[K]) => void)(payload); + } catch (error) { + // A misbehaving listener must never take down the emitter. + console.error(`pod.events listener for '${event}' threw:`, error); + } + } + } + + listenerCount(event: PodEventName): number { + return this.listeners.get(event)?.size ?? 0; + } +} diff --git a/src/sandbox/edit-command.ts b/src/sandbox/edit-command.ts index f036654..6b3d93f 100644 --- a/src/sandbox/edit-command.ts +++ b/src/sandbox/edit-command.ts @@ -1,18 +1,21 @@ /** * `edit` as a just-bash custom command: resolves the argument against the - * shell cwd and hands the path to the host (Monaco in the browser). + * shell cwd and hands the path to the host (Monaco in the browser). With a + * PodEvents bus attached it emits `edit:request` (generalizes onEdit). */ import { defineCommand } from 'just-bash/browser'; +import type { PodEvents } from '../events.js'; -export const makeEditCommand = (onEdit?: (path: string) => void) => +export const makeEditCommand = (onEdit?: (path: string) => void, events?: PodEvents) => defineCommand('edit', async (args, ctx) => { if (!args[0]) { return { stdout: '', stderr: 'usage: edit \n', exitCode: 1 }; } - if (!onEdit) { + if (!onEdit && !events) { return { stdout: '', stderr: 'edit: no editor attached in this environment\n', exitCode: 1 }; } const path = ctx.fs.resolvePath(ctx.cwd, args[0]); - onEdit(path); + onEdit?.(path); + events?.emit('edit:request', { path }); return { stdout: `Opening editor for ${path}...\n`, stderr: '', exitCode: 0 }; }); diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 2fc572b..85fb7e2 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -23,18 +23,23 @@ import { makeGitCommand } from './git-command.js'; import { makeModuleCommands } from './module-command.js'; import { makeNotesCommand } from './notes-command.js'; import { makeStorageCommands } from './storage-command.js'; +import { makeSudoCommand } from './sudo-command.js'; +import type { PodEvents } from '../events.js'; import type { CompletionResult, Sandbox, SandboxExecOptions, ZenFsLike } from './types.js'; import { ZenFsAdapter } from './zenfs-adapter.js'; export type { CompletionResult, Sandbox, SandboxExecOptions, SandboxExecResult } from './types.js'; export { SHELL_NOTES } from './notes-command.js'; export { ZenFsAdapter } from './zenfs-adapter.js'; +export { SUDO_DENIED_MESSAGE } from './sudo-command.js'; export interface CreateSandboxOptions { /** The ZenFS node-like fs object backing the sandbox. */ zfs: ZenFsLike; /** Host hook for the `edit` command (opens Monaco in the browser). */ onEdit?: (path: string) => void; + /** Pod event bus: exec:start/exec:end, coarse fs:changed, edit:request, approval:request. */ + events?: PodEvents; /** Initial working directory. Default: /repo */ cwd?: string; /** Additional custom commands (server may add more; agents reuse as-is). */ @@ -74,8 +79,9 @@ export function createSandbox(opts: CreateSandboxOptions): Sandbox { customCommands: [ // git shares the sandbox's zfs — shell view and git view stay coherent makeGitCommand(createGitOps(() => opts.zfs)), - makeEditCommand(opts.onEdit), + makeEditCommand(opts.onEdit, opts.events), makeNotesCommand(), + makeSudoCommand(opts.events), ...makeStorageCommands(() => opts.zfs), ...(opts.proc ? makeModuleCommands() : []), ...(opts.extraCommands ?? []), @@ -105,7 +111,9 @@ export function createSandbox(opts: CreateSandboxOptions): Sandbox { // Transient execs (tab completion) must not disturb the snapshot. const live = !execOpts?.transient; const notices: string[] = []; + const startedAt = Date.now(); if (live) { + opts.events?.emit('exec:start', { line }); notices.push(...((await opts.hooks?.beforeExec?.()) ?? [])); if (opts.proc) notices.push(...(await refreshProc(opts.zfs))); history.push(line); @@ -119,6 +127,9 @@ export function createSandbox(opts: CreateSandboxOptions): Sandbox { if (r.env?.PWD) cwd = r.env.PWD; if (opts.proc) notices.push(...(await reconcileProc(opts.zfs))); notices.push(...((await opts.hooks?.afterExec?.()) ?? [])); + opts.events?.emit('exec:end', { line, exitCode: r.exitCode, durationMs: Date.now() - startedAt }); + // Coarse by contract: a shell line can touch anything. + opts.events?.emit('fs:changed', { origin: 'exec' }); } const stderr = notices.length ? `${r.stderr}${notices.join('\n')}\n` : r.stderr; return { stdout: r.stdout, stderr, exitCode: r.exitCode }; @@ -154,6 +165,7 @@ export function createSandbox(opts: CreateSandboxOptions): Sandbox { }, fs: adapter, + zfs: opts.zfs, }; return sandbox; } diff --git a/src/sandbox/sudo-command.test.ts b/src/sandbox/sudo-command.test.ts new file mode 100644 index 0000000..2d6c37e --- /dev/null +++ b/src/sandbox/sudo-command.test.ts @@ -0,0 +1,55 @@ +/** + * Agent-confinement stub tests (plan Phase 2, Decision #10): sudo is + * recognized, always denied with EPERM until Phase 6.5, never prompts, and + * surfaces the attempt as approval:request. docs/security-model.md is + * normative. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { PodEvents } from '../events.js'; +import { createSandbox, SUDO_DENIED_MESSAGE, type Sandbox } from './index.js'; + +let sandbox: Sandbox; +let events: PodEvents; + +beforeEach(async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + events = new PodEvents(); + sandbox = createSandbox({ zfs, events }); +}); + +describe('sudo confinement stub', () => { + it('denies sudo with EPERM and the Phase 6.5 pointer', async () => { + const r = await sandbox.exec('sudo rm -rf /'); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('EPERM'); + expect(r.stderr).toContain('Phase 6.5'); + expect(r.stdout).toBe(''); + }); + + it('denies bare sudo too — there is no interactive prompt to rubber-stamp', async () => { + const r = await sandbox.exec('sudo'); + expect(r.exitCode).toBe(1); + expect(r.stderr).toBe(SUDO_DENIED_MESSAGE); + }); + + it('emits approval:request so hosts can observe the attempt', async () => { + const attempts: Array<{ verb: string; target?: string }> = []; + events.on('approval:request', (e) => attempts.push({ verb: e.verb, target: e.target })); + await sandbox.exec('sudo apt-get install nmap'); + expect(attempts).toEqual([{ verb: 'sudo', target: 'apt-get install nmap' }]); + }); + + it('does not escape via pipelines or command substitution', async () => { + const r = await sandbox.exec('echo start && sudo whoami && echo end'); + expect(r.stdout).toContain('start'); + expect(r.stdout).not.toContain('end'); // && chain stops at the denial + expect(r.stderr).toContain('EPERM'); + }); +}); diff --git a/src/sandbox/sudo-command.ts b/src/sandbox/sudo-command.ts new file mode 100644 index 0000000..e662182 --- /dev/null +++ b/src/sandbox/sudo-command.ts @@ -0,0 +1,22 @@ +/** + * Agent-confinement stub (plan Phase 2; docs/security-model.md is normative): + * the pod boundary is the tool layer, and `sudo` is the only escape. Until + * the Phase 6.5 approval flow lands, every sudo is default-deny EPERM — + * agents can never self-approve. The attempt is surfaced via + * `approval:request` so hosts can already observe it. + */ +import { defineCommand } from 'just-bash/browser'; +import type { PodEvents } from '../events.js'; + +export const SUDO_DENIED_MESSAGE = + 'sudo: EPERM: privileged execution requires human approval — the approval flow lands in Phase 6.5 (docs/security-model.md)\n'; + +export const makeSudoCommand = (events?: PodEvents) => + defineCommand('sudo', async (args) => { + events?.emit('approval:request', { + verb: 'sudo', + target: args.join(' ') || undefined, + justification: 'phase-2 stub: default-deny, no approval flow yet', + }); + return { stdout: '', stderr: SUDO_DENIED_MESSAGE, exitCode: 1 }; + }); From 9d1e1ec9ef79595d14fa100606f3e8d35f28eed6 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sun, 30 Aug 2026 21:22:00 -0400 Subject: [PATCH 04/12] feat(agent)!: agent file tools are the VS Code-schema pod tools Collision #1 resolved: read_file/write_file/list_files minimal shapes deleted; createSandboxTools now serves bash (16 KiB truncation kept) + the pod file tools (read_file/create_file/list_dir/create_directory/replace_string_in_file/multi_replace_string_in_file/apply_patch) resolved over an app-declared mount table (default '/'), sharing the shell's ZenFS store via Sandbox.zfs. --- src/agent/agent.test.ts | 32 +++++-- src/agent/tools.ts | 189 ++++++++++++++-------------------------- src/sandbox/types.ts | 2 + 3 files changed, 93 insertions(+), 130 deletions(-) diff --git a/src/agent/agent.test.ts b/src/agent/agent.test.ts index 9aba597..350839b 100644 --- a/src/agent/agent.test.ts +++ b/src/agent/agent.test.ts @@ -137,19 +137,39 @@ describe('tool surface serializers', () => { it('exposes the same tools as OpenAI definitions and MCP descriptors', () => { const tools = createSandboxTools(sandbox); const mcp = toMcpToolDescriptors(tools); - expect(mcp.map((t) => t.name).sort()).toEqual(['bash', 'list_files', 'read_file', 'write_file']); + // Collision #1 resolved: VS Code-schema file tools + bash — no more + // write_file/list_files shapes. + expect(mcp.map((t) => t.name).sort()).toEqual([ + 'apply_patch', + 'bash', + 'create_directory', + 'create_file', + 'list_dir', + 'multi_replace_string_in_file', + 'read_file', + 'replace_string_in_file', + ]); const bash = mcp.find((t) => t.name === 'bash'); expect(bash?.inputSchema.required).toEqual(['command']); expect(bash?.inputSchema.type).toBe('object'); }); - it('read_file/write_file/list_files operate relative to the shell cwd', async () => { + it('file tools use VS Code schemas over the sandbox store (shared with bash)', async () => { const tools = createSandboxTools(sandbox); - await tools.get('write_file')!.execute({ path: 'note.md', content: 'hi' }); - const read = await tools.get('read_file')!.execute({ path: 'note.md' }); - expect(read).toMatchObject({ success: true, content: 'hi' }); - const listing = await tools.get('list_files')!.execute({}); + const created = await tools.get('create_file')!.execute({ filePath: '/repo/note.md', content: 'hi' }); + expect(created.success).toBe(true); + const read = await tools.get('read_file')!.execute({ filePath: '/repo/note.md' }); + expect(read.success).toBe(true); + expect(read.content).toContain('hi'); + const listing = await tools.get('list_dir')!.execute({ path: '/repo' }); expect(listing.content).toMatch(/note\.md/); + // the shell sees the same store + const r = await sandbox.exec('cat /repo/note.md'); + expect(r.stdout).toBe('hi'); + // and shell writes are visible to the tools + await sandbox.exec('echo shell > /repo/from-shell.txt'); + const read2 = await tools.get('read_file')!.execute({ filePath: '/repo/from-shell.txt' }); + expect(read2.content).toContain('shell'); }); }); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index dc47371..aab4b70 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,151 +1,92 @@ /** - * Sandbox-bound tools for the agent loop: `bash` plus read/write/list - * conveniences (cheaper in tokens than cat/echo round-trips). + * Agent tool bindings — resolves plan §2 collision #1: the agent's file tools + * ARE the package's VS Code-schema tools (read_file, create_file, list_dir, + * create_directory, replace_string_in_file, multi_replace_string_in_file, + * apply_patch) resolved over a mount table. artipod-sync's minimal + * read_file/write_file/list_files shapes are gone. * - * One source of truth for the tool surface, two serializers: - * OpenAI function-call shape (ToolDefinition, used by the loop) and an - * MCP-style descriptor (toMcpToolDescriptors, for MCPToolCall-style UIs). + * `bash` keeps artipod-sync's semantics: JSON body with per-stream 16 KiB + * head+tail truncation, executed in the persistent sandbox session. */ +import { ArtiMount } from '../artimount.js'; +import type { PodFs } from '../podfs.js'; import type { Sandbox } from '../sandbox/types.js'; +import { bashDefinition } from '../tools/bashTool.js'; +import { PodPathResolver, createPodFileTools, type MountTableEntry } from '../tools/podFileTools.js'; +import { toOpenAiTool } from '../tools/serializers.js'; +import { truncateOutput } from '../tools/truncation.js'; +import type { ToolHandler as CoreToolHandler, ToolResult as CoreToolResult } from '../tools/types.js'; import type { McpToolDescriptor, ToolDefinition, ToolHandler, ToolResult } from './types.js'; -/** Cap tool output to protect the model's context window (head + tail). */ -export const MAX_TOOL_OUTPUT_BYTES = 16 * 1024; - -export function truncateOutput(text: string, maxBytes = MAX_TOOL_OUTPUT_BYTES): string { - if (text.length <= maxBytes) return text; - const half = Math.floor(maxBytes / 2); - const omitted = text.length - half * 2; - return `${text.slice(0, half)}\n…[${omitted} characters truncated]…\n${text.slice(-half)}`; -} +export { MAX_TOOL_OUTPUT_BYTES, truncateOutput } from '../tools/truncation.js'; const okResult = (content: string): ToolResult => ({ success: true, content }); const errResult = (error: string): ToolResult => ({ success: false, content: '', error }); -export function createSandboxTools(sandbox: Sandbox): Map { +/** An app-declared mount the file tools resolve against (plan Decision #3). */ +export interface SandboxMountDeclaration { + name: string; + path: string; + readonly?: boolean; +} + +export interface CreateSandboxToolsOptions { + /** Mount table for the file tools. Default: one writable mount at '/'. */ + mounts?: SandboxMountDeclaration[]; +} + +/** Adapt a core (structured-result) handler to the agent's OpenAI wire shape. */ +function adaptCoreHandler(handler: CoreToolHandler): ToolHandler { + return { + definition: toOpenAiTool(handler.definition) as ToolDefinition, + execute: async (args) => { + const result = (await handler.execute(args as never)) as CoreToolResult; + if (!result.success) return errResult(result.error ?? `${handler.name} failed`); + if (typeof result.content === 'string') return okResult(truncateOutput(result.content)); + const { success: _success, ...rest } = result; + return okResult(truncateOutput(JSON.stringify(rest))); + }, + }; +} + +export function createSandboxTools( + sandbox: Sandbox, + options: CreateSandboxToolsOptions = {}, +): Map { const tools = new Map(); tools.set('bash', { - definition: { - type: 'function', - function: { - name: 'bash', - description: - 'Execute a bash command in the persistent sandbox (pipes, globs, redirects, ~90 coreutils, plus git and edit). cwd, variables and aliases persist between calls. Output is truncated to 16 KiB.', - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: 'The bash command line to execute' }, - }, - required: ['command'], - }, - }, - }, + definition: toOpenAiTool(bashDefinition) as ToolDefinition, execute: async (args, signal) => { const command = String(args.command ?? ''); if (!command) return errResult('missing "command"'); const r = await sandbox.exec(command, { signal }); - const body = JSON.stringify({ - stdout: truncateOutput(r.stdout), - stderr: truncateOutput(r.stderr), - exitCode: r.exitCode, - }); - return okResult(body); - }, - }); - - tools.set('read_file', { - definition: { - type: 'function', - function: { - name: 'read_file', - description: 'Read a text file from the sandbox filesystem (cheaper than bash cat).', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path (absolute, or relative to the shell cwd)' }, - }, - required: ['path'], - }, - }, - }, - execute: async (args) => { - const path = resolveArgPath(sandbox, args.path); - if (!path) return errResult('missing "path"'); - try { - return okResult(truncateOutput(await sandbox.fs.readFile(path))); - } catch (e) { - return errResult((e as Error).message); - } - }, - }); - - tools.set('write_file', { - definition: { - type: 'function', - function: { - name: 'write_file', - description: 'Write (create or overwrite) a text file in the sandbox filesystem.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path (absolute, or relative to the shell cwd)' }, - content: { type: 'string', description: 'Full file content' }, - }, - required: ['path', 'content'], - }, - }, - }, - execute: async (args) => { - const path = resolveArgPath(sandbox, args.path); - if (!path) return errResult('missing "path"'); - try { - await sandbox.fs.writeFile(path, String(args.content ?? '')); - return okResult(`wrote ${path}`); - } catch (e) { - return errResult((e as Error).message); - } + return okResult( + JSON.stringify({ + stdout: truncateOutput(r.stdout), + stderr: truncateOutput(r.stderr), + exitCode: r.exitCode, + }), + ); }, }); - tools.set('list_files', { - definition: { - type: 'function', - function: { - name: 'list_files', - description: 'List directory entries (directories get a trailing slash).', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Directory path; defaults to the shell cwd' }, - }, - }, - }, - }, - execute: async (args) => { - const path = resolveArgPath(sandbox, args.path ?? '.') ?? sandbox.getCwd(); - try { - const entries = await sandbox.fs.readdirWithFileTypes(path); - const listing = entries - .map((e) => (e.isDirectory ? `${e.name}/` : e.name)) - .sort() - .join('\n'); - return okResult(truncateOutput(listing)); - } catch (e) { - return errResult((e as Error).message); - } - }, - }); + // VS Code-schema file tools over the sandbox's own store. ZenFS's + // `fs.promises` is PodFs-shaped, so the mounts share the shell's view. + const podFs = sandbox.zfs.promises as unknown as PodFs; + const declarations = options.mounts ?? [{ name: 'root', path: '/' }]; + const entries: MountTableEntry[] = declarations.map((m) => ({ + path: m.path, + mount: new ArtiMount(m.name, m.path, m.readonly ?? false, podFs), + })); + const resolver = new PodPathResolver(entries); + for (const handler of createPodFileTools(resolver)) { + tools.set(handler.name, adaptCoreHandler(handler)); + } return tools; } -function resolveArgPath(sandbox: Sandbox, value: unknown): string | null { - const raw = String(value ?? ''); - if (!raw) return null; - return sandbox.fs.resolvePath(sandbox.getCwd(), raw); -} - /** OpenAI function-call JSON shape (what the loop / ozwellChat speak). */ export function toOpenAiToolDefinitions(tools: Map): ToolDefinition[] { return Array.from(tools.values()).map((t) => t.definition); diff --git a/src/sandbox/types.ts b/src/sandbox/types.ts index 9cea6a7..c6a71f7 100644 --- a/src/sandbox/types.ts +++ b/src/sandbox/types.ts @@ -41,4 +41,6 @@ export interface Sandbox { /** Tab-completion for an input line (commands, aliases, paths). */ complete(line: string): Promise; fs: import('./zenfs-adapter.js').ZenFsAdapter; + /** The raw node-like fs backing the sandbox (same store as `fs`). */ + zfs: ZenFsLike; } From 4d77e17749504d08d696e315c35260e70816d07b Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sun, 30 Aug 2026 21:22:00 -0400 Subject: [PATCH 05/12] feat(host,console): headless UI controllers + Ctrl+~ console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TerminalSession (line discipline, history, tab completion, Ctrl+C, agent echo via agent:tool-call), FileBuffer (dirty tracking, external-change reload/flag), TreeSource (TreeDataProvider-shaped, fs:changed invalidation) — extracted from artipod-sync's components per plan §3. installConsole: zero-dep overlay console, SSR-safe no-op, approval:request surfacing. New exports: ./host ./console. --- package.json | 8 ++ src/console/console.test.ts | 31 +++++ src/console/index.ts | 200 +++++++++++++++++++++++++++++++ src/host/file-buffer.ts | 145 +++++++++++++++++++++++ src/host/host.test.ts | 217 ++++++++++++++++++++++++++++++++++ src/host/index.ts | 22 ++++ src/host/terminal-session.ts | 221 +++++++++++++++++++++++++++++++++++ src/host/tree-source.ts | 89 ++++++++++++++ src/index.ts | 12 ++ 9 files changed, 945 insertions(+) create mode 100644 src/console/console.test.ts create mode 100644 src/console/index.ts create mode 100644 src/host/file-buffer.ts create mode 100644 src/host/host.test.ts create mode 100644 src/host/index.ts create mode 100644 src/host/terminal-session.ts create mode 100644 src/host/tree-source.ts diff --git a/package.json b/package.json index 54a1cef..1f5120b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,14 @@ "types": "./dist/agent/local/worker.d.ts", "default": "./dist/agent/local/worker.js" }, + "./host": { + "types": "./dist/host/index.d.ts", + "default": "./dist/host/index.js" + }, + "./console": { + "types": "./dist/console/index.d.ts", + "default": "./dist/console/index.js" + }, "./docker": { "types": "./dist/docker/index.d.ts", "default": "./dist/docker/index.js" diff --git a/src/console/console.test.ts b/src/console/console.test.ts new file mode 100644 index 0000000..a362535 --- /dev/null +++ b/src/console/console.test.ts @@ -0,0 +1,31 @@ +/** + * Console module contract: SSR-safe no-op without a DOM (docs/console.md). + * The DOM rendering itself is exercised in browser consumers; here we pin + * that importing and installing in Node never throws and never renders. + */ +import { describe, expect, it } from 'vitest'; +import { configure, InMemory, fs as zfs, umount } from '@zenfs/core'; +import { createSandbox } from '../sandbox/index.js'; +import { installConsole } from './index.js'; + +describe('installConsole (no DOM)', () => { + it('returns a no-op handle in Node/SSR', async () => { + try { + umount('/'); + } catch { + // first run + } + await configure({ mounts: { '/': InMemory } }); + await zfs.promises.mkdir('/repo'); + const sandbox = createSandbox({ zfs }); + + const handle = installConsole({ sandbox }); + expect(handle.isOpen).toBe(false); + // all methods are safe no-ops + handle.toggle(); + handle.show(); + handle.hide(); + handle.dispose(); + expect(handle.isOpen).toBe(false); + }); +}); diff --git a/src/console/index.ts b/src/console/index.ts new file mode 100644 index 0000000..249bc49 --- /dev/null +++ b/src/console/index.ts @@ -0,0 +1,200 @@ +/** + * @artipod/core/console — the Ctrl+~ drop-in overlay console (docs/console.md). + * + * One call gives any web app a Quake-style drop-down artipod shell. Builtin + * zero-dependency renderer (no xterm): a log
 + an  line driven
+ * by the same sandbox/agent surfaces every other consumer uses. SSR-safe:
+ * without a DOM this is a no-op. Consumes only /host-style contracts +
+ * pod.events.
+ */
+import type { Sandbox } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface InstallConsoleOptions {
+  sandbox: Sandbox;
+  events?: PodEvents;
+  /** Hotkey: 'Ctrl+`' (default) — also matches Ctrl+~ (shifted backquote). */
+  hotkey?: string;
+  /** Secondary tabs: run nothing, say why. */
+  readOnly?: boolean;
+  /** Overlay height as a CSS size. Default: '45vh'. */
+  height?: string;
+}
+
+export interface ConsoleHandle {
+  toggle(): void;
+  show(): void;
+  hide(): void;
+  dispose(): void;
+  readonly isOpen: boolean;
+}
+
+const NOOP_HANDLE: ConsoleHandle = {
+  toggle() {},
+  show() {},
+  hide() {},
+  dispose() {},
+  isOpen: false,
+};
+
+/** Strip ANSI SGR sequences — the builtin renderer is plain text. */
+// eslint-disable-next-line no-control-regex
+const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '');
+
+export function installConsole(options: InstallConsoleOptions): ConsoleHandle {
+  if (typeof document === 'undefined' || typeof window === 'undefined') {
+    return NOOP_HANDLE; // SSR / Node: no-op by contract
+  }
+
+  const { sandbox, events, readOnly, height = '45vh' } = options;
+
+  const overlay = document.createElement('div');
+  overlay.setAttribute('data-artipod-console', '');
+  overlay.style.cssText = [
+    'position:fixed;top:0;left:0;right:0;z-index:2147483000',
+    `height:${height};display:none;flex-direction:column`,
+    'background:rgba(20,20,20,.96);color:#e6e6e6',
+    'font:13px/1.4 Menlo,Monaco,"Courier New",monospace',
+    'border-bottom:1px solid #444;box-shadow:0 6px 24px rgba(0,0,0,.5)',
+  ].join(';');
+
+  const log = document.createElement('pre');
+  log.style.cssText = 'flex:1;margin:0;padding:8px 10px;overflow:auto;white-space:pre-wrap;word-break:break-word';
+
+  const inputRow = document.createElement('div');
+  inputRow.style.cssText = 'display:flex;border-top:1px solid #333';
+  const promptEl = document.createElement('span');
+  promptEl.style.cssText = 'padding:6px 0 6px 10px;color:#8bd5a0;white-space:nowrap';
+  const input = document.createElement('input');
+  input.type = 'text';
+  input.autocapitalize = 'off';
+  input.autocomplete = 'off';
+  input.spellcheck = false;
+  input.style.cssText =
+    'flex:1;background:transparent;border:0;outline:0;color:inherit;font:inherit;padding:6px 10px';
+  inputRow.append(promptEl, input);
+  overlay.append(log, inputRow);
+  document.body.appendChild(overlay);
+
+  let open = false;
+  let busy = false;
+  let abortController: AbortController | null = null;
+  const history: string[] = [];
+  let historyIndex = 0;
+  const disposers: Array<() => void> = [];
+
+  const append = (text: string) => {
+    log.append(stripAnsi(text));
+    log.scrollTop = log.scrollHeight;
+  };
+  const refreshPrompt = () => {
+    promptEl.textContent = `${sandbox.getCwd()} $`;
+  };
+
+  append('artipod console — type a command, `notes` for help, Ctrl+` to hide\n');
+  refreshPrompt();
+
+  if (events) {
+    disposers.push(
+      events.on('agent:tool-call', (e) => {
+        if (e.phase === 'call') append(`⚙ ${e.name} ${e.arguments}\n`);
+      }),
+      events.on('approval:request', (e) => {
+        append(`⛔ approval requested: ${e.verb}${e.target ? ` ${e.target}` : ''} — denied (Phase 6.5 lands the flow)\n`);
+      }),
+    );
+  }
+
+  const run = async (cmd: string) => {
+    append(`${sandbox.getCwd()} $ ${cmd}\n`);
+    if (!cmd.trim()) return;
+    if (readOnly) {
+      append('read-only session: commands are disabled in this tab\n');
+      return;
+    }
+    history.push(cmd);
+    historyIndex = history.length;
+    busy = true;
+    abortController = new AbortController();
+    try {
+      const r = await sandbox.exec(cmd, { signal: abortController.signal });
+      if (r.stdout) append(r.stdout);
+      if (r.stderr) append(r.stderr);
+    } catch (e) {
+      append(`${String(e)}\n`);
+    } finally {
+      busy = false;
+      abortController = null;
+      refreshPrompt();
+    }
+  };
+
+  const onInputKey = async (e: KeyboardEvent) => {
+    if (e.key === 'Enter' && !busy) {
+      const cmd = input.value;
+      input.value = '';
+      await run(cmd);
+    } else if (e.key === 'c' && e.ctrlKey && busy) {
+      abortController?.abort();
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      if (historyIndex > 0) {
+        historyIndex--;
+        input.value = history[historyIndex];
+      }
+    } else if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      if (historyIndex < history.length) {
+        historyIndex++;
+        input.value = historyIndex === history.length ? '' : history[historyIndex];
+      }
+    } else if (e.key === 'Tab') {
+      e.preventDefault();
+      if (!input.value) return;
+      const { candidates, replaceStart } = await sandbox.complete(input.value);
+      if (candidates.length === 1) {
+        const token = input.value.slice(replaceStart);
+        input.value += candidates[0].slice(token.length) + (candidates[0].endsWith('/') ? '' : ' ');
+      } else if (candidates.length > 1) {
+        append(`${candidates.slice(0, 40).join('  ')}${candidates.length > 40 ? '  …' : ''}\n`);
+      }
+    }
+  };
+  input.addEventListener('keydown', onInputKey);
+
+  const show = () => {
+    open = true;
+    overlay.style.display = 'flex';
+    refreshPrompt();
+    input.focus();
+  };
+  const hide = () => {
+    open = false;
+    overlay.style.display = 'none';
+  };
+  const toggle = () => (open ? hide() : show());
+
+  // Ctrl+` and Ctrl+~ (same physical key, shifted) toggle the overlay.
+  const onHotkey = (e: KeyboardEvent) => {
+    if (e.ctrlKey && (e.key === '`' || e.key === '~')) {
+      e.preventDefault();
+      toggle();
+    }
+  };
+  window.addEventListener('keydown', onHotkey);
+  disposers.push(() => window.removeEventListener('keydown', onHotkey));
+  disposers.push(() => input.removeEventListener('keydown', onInputKey));
+
+  return {
+    toggle,
+    show,
+    hide,
+    dispose() {
+      for (const d of disposers.splice(0)) d();
+      overlay.remove();
+    },
+    get isOpen() {
+      return open;
+    },
+  };
+}
diff --git a/src/host/file-buffer.ts b/src/host/file-buffer.ts
new file mode 100644
index 0000000..5318558
--- /dev/null
+++ b/src/host/file-buffer.ts
@@ -0,0 +1,145 @@
+/**
+ * FileBuffer — the headless editor document extracted from artipod-sync's
+ * Editor.tsx (plan §3): open → content, save(), isDirty, external-change
+ * detection over `fs:changed` (reload-if-clean / flag-if-dirty). Editor-
+ * agnostic: Monaco, kerebron RichEditor or CodeMirror all sit on top.
+ */
+import type { ZenFsLike } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface FileBufferOptions {
+  zfs: ZenFsLike;
+  path: string;
+  events?: PodEvents;
+  /** Secondary tabs: refuse to save. */
+  readOnly?: boolean;
+}
+
+export type FileBufferListener = (buffer: FileBuffer) => void;
+
+export function languageForPath(path: string): string {
+  if (path.endsWith('.ts') || path.endsWith('.tsx')) return 'typescript';
+  if (path.endsWith('.js') || path.endsWith('.jsx')) return 'javascript';
+  if (path.endsWith('.json')) return 'json';
+  if (path.endsWith('.css')) return 'css';
+  if (path.endsWith('.html')) return 'html';
+  if (path.endsWith('.md')) return 'markdown';
+  return 'plaintext';
+}
+
+export class FileBuffer {
+  private _content = '';
+  private _savedContent = '';
+  private _isDirty = false;
+  private _externallyChanged = false;
+  private listeners = new Set();
+  private disposers: Array<() => void> = [];
+
+  private constructor(private readonly opts: FileBufferOptions) {}
+
+  static async open(opts: FileBufferOptions): Promise {
+    const buffer = new FileBuffer(opts);
+    await buffer.reload();
+    if (opts.events) {
+      buffer.disposers.push(
+        opts.events.on('fs:changed', (e) => {
+          // Editor's own save already synced the buffer.
+          if (e.origin === 'editor' && e.paths?.includes(opts.path)) return;
+          const affectsThisFile = !e.paths || e.paths.includes(opts.path);
+          if (!affectsThisFile) return;
+          void buffer.handleExternalChange();
+        }),
+      );
+    }
+    return buffer;
+  }
+
+  get path(): string {
+    return this.opts.path;
+  }
+
+  get content(): string {
+    return this._content;
+  }
+
+  get isDirty(): boolean {
+    return this._isDirty;
+  }
+
+  /** Set when the file changed underneath a dirty buffer (warn, don't clobber). */
+  get externallyChanged(): boolean {
+    return this._externallyChanged;
+  }
+
+  get language(): string {
+    return languageForPath(this.opts.path);
+  }
+
+  onChange(listener: FileBufferListener): () => void {
+    this.listeners.add(listener);
+    return () => this.listeners.delete(listener);
+  }
+
+  private notify(): void {
+    for (const l of [...this.listeners]) l(this);
+  }
+
+  /** Editor keystroke path: update content, mark dirty. */
+  setContent(text: string): void {
+    this._content = text;
+    this._isDirty = text !== this._savedContent;
+    this.notify();
+  }
+
+  async reload(): Promise {
+    const { zfs, path } = this.opts;
+    try {
+      this._content = (await zfs.promises.readFile(path, 'utf8')) as string;
+    } catch {
+      this._content = ''; // new file
+    }
+    this._savedContent = this._content;
+    this._isDirty = false;
+    this._externallyChanged = false;
+    this.notify();
+  }
+
+  async save(): Promise {
+    if (this.opts.readOnly) {
+      throw new Error('read-only session: saving is disabled in this tab');
+    }
+    const { zfs, path, events } = this.opts;
+    await zfs.promises.writeFile(path, this._content);
+    this._savedContent = this._content;
+    this._isDirty = false;
+    this._externallyChanged = false;
+    events?.emit('fs:changed', { paths: [path], origin: 'editor' });
+    this.notify();
+  }
+
+  /** fs:changed for our path: reload if clean, flag if dirty. */
+  private async handleExternalChange(): Promise {
+    const { zfs, path } = this.opts;
+    let onDisk: string;
+    try {
+      onDisk = (await zfs.promises.readFile(path, 'utf8')) as string;
+    } catch {
+      onDisk = '';
+    }
+    if (onDisk === this._savedContent) return; // nothing actually changed
+    if (this._isDirty) {
+      this._externallyChanged = true;
+      this.notify();
+    } else {
+      this._content = onDisk;
+      this._savedContent = onDisk;
+      this._externallyChanged = false;
+      this.notify();
+    }
+  }
+
+  dispose(): void {
+    for (const d of this.disposers.splice(0)) d();
+    this.listeners.clear();
+  }
+}
diff --git a/src/host/host.test.ts b/src/host/host.test.ts
new file mode 100644
index 0000000..34465e9
--- /dev/null
+++ b/src/host/host.test.ts
@@ -0,0 +1,217 @@
+/**
+ * /host controller tests over a real sandbox (ZenFS InMemory) with a fake
+ * xterm-shaped IO — the Phase 2 acceptance wiring, headless:
+ * tree invalidates after every command, editor detects external changes,
+ * agent echo arrives via agent:tool-call (no registerWriter).
+ */
+import { beforeEach, describe, expect, it } from 'vitest';
+import { configure, InMemory, fs as zfs, umount } from '@zenfs/core';
+import { PodEvents } from '../events.js';
+import { createSandbox, type Sandbox } from '../sandbox/index.js';
+import { FileBuffer } from './file-buffer.js';
+import { TerminalSession, commonPrefix, toCrLf } from './terminal-session.js';
+import { TREE_ROOT_ID, TreeSource } from './tree-source.js';
+
+let sandbox: Sandbox;
+let events: PodEvents;
+
+class FakeIO {
+  out = '';
+  write(text: string): void {
+    this.out += text;
+  }
+}
+
+beforeEach(async () => {
+  try {
+    umount('/');
+  } catch {
+    // first run
+  }
+  await configure({ mounts: { '/': InMemory } });
+  await zfs.promises.mkdir('/repo');
+  events = new PodEvents();
+  sandbox = createSandbox({ zfs, events });
+});
+
+describe('TerminalSession', () => {
+  it('runs a typed command line and writes CRLF output after the prompt', async () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io, banner: ['hello banner'] });
+    expect(io.out).toContain('hello banner\r\n');
+    expect(io.out).toContain('/repo $ ');
+
+    for (const ch of 'echo hi') await session.handleData(ch);
+    await session.handleData('\r');
+    expect(io.out).toContain('hi\r\n');
+    session.dispose();
+  });
+
+  it('recalls history with arrow keys and completes with Tab', async () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io });
+    for (const ch of 'echo one') await session.handleData(ch);
+    await session.handleData('\r');
+
+    io.out = '';
+    await session.handleData('\x1b[A'); // up
+    expect(io.out).toContain('echo one');
+
+    // Tab completion: unique command prefix
+    await session.handleData('\x1b[B'); // down → empty line
+    for (const ch of 'ech') await session.handleData(ch);
+    await session.handleData('\t');
+    expect(io.out).toContain('echo ');
+    session.dispose();
+  });
+
+  it('echoes agent tool calls via agent:tool-call (no registerWriter)', () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io, events });
+    events.emit('agent:tool-call', { phase: 'call', name: 'bash', arguments: '{"command":"ls"}' });
+    expect(io.out).toContain('⚙ bash');
+    expect(io.out).toContain('"command":"ls"');
+    session.dispose();
+    io.out = '';
+    events.emit('agent:tool-call', { phase: 'call', name: 'bash', arguments: '{}' });
+    expect(io.out).toBe(''); // disposed sessions detach their listeners
+  });
+
+  it('refuses commands in read-only sessions', async () => {
+    const io = new FakeIO();
+    const session = new TerminalSession({ sandbox, io, readOnly: true });
+    for (const ch of 'echo nope') await session.handleData(ch);
+    await session.handleData('\r');
+    expect(io.out).toContain('read-only session');
+    expect(io.out).not.toContain('\r\nnope\r\n'); // the command never ran
+    session.dispose();
+  });
+
+  it('helpers: toCrLf and commonPrefix', () => {
+    expect(toCrLf('a\nb\r\nc')).toBe('a\r\nb\r\nc');
+    expect(commonPrefix(['foobar', 'foobaz', 'foo'])).toBe('foo');
+  });
+});
+
+describe('FileBuffer', () => {
+  it('opens, edits, saves, and reports dirty state', async () => {
+    await zfs.promises.writeFile('/repo/a.md', 'one');
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/a.md', events });
+    expect(buffer.content).toBe('one');
+    expect(buffer.isDirty).toBe(false);
+    expect(buffer.language).toBe('markdown');
+
+    buffer.setContent('two');
+    expect(buffer.isDirty).toBe(true);
+    await buffer.save();
+    expect(buffer.isDirty).toBe(false);
+    expect(await zfs.promises.readFile('/repo/a.md', 'utf8')).toBe('two');
+    buffer.dispose();
+  });
+
+  it('detects external changes: reloads when clean', async () => {
+    await zfs.promises.writeFile('/repo/b.txt', 'v1');
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/b.txt', events });
+
+    // a shell command rewrites the file → coarse fs:changed fires
+    await sandbox.exec('echo v2 > /repo/b.txt');
+    await new Promise((r) => setTimeout(r, 10)); // async reload settles
+    expect(buffer.content).toBe('v2\n');
+    expect(buffer.isDirty).toBe(false);
+    buffer.dispose();
+  });
+
+  it('flags external changes without clobbering a dirty buffer', async () => {
+    await zfs.promises.writeFile('/repo/c.txt', 'v1');
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/c.txt', events });
+    buffer.setContent('my unsaved edit');
+
+    await sandbox.exec('echo surprise > /repo/c.txt');
+    await new Promise((r) => setTimeout(r, 10));
+    expect(buffer.content).toBe('my unsaved edit'); // preserved
+    expect(buffer.externallyChanged).toBe(true);
+    buffer.dispose();
+  });
+
+  it('save emits a precise fs:changed the buffer itself ignores', async () => {
+    const paths: (string[] | undefined)[] = [];
+    events.on('fs:changed', (e) => paths.push(e.paths));
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/d.txt', events });
+    buffer.setContent('data');
+    await buffer.save();
+    expect(paths).toContainEqual(['/repo/d.txt']);
+    expect(buffer.isDirty).toBe(false);
+    buffer.dispose();
+  });
+
+  it('refuses to save in read-only sessions', async () => {
+    const buffer = await FileBuffer.open({ zfs, path: '/repo/e.txt', readOnly: true });
+    buffer.setContent('x');
+    await expect(buffer.save()).rejects.toThrow(/read-only/);
+    buffer.dispose();
+  });
+});
+
+describe('TreeSource', () => {
+  it('serves roots from options and lists children sorted', async () => {
+    await zfs.promises.writeFile('/repo/z.txt', 'z');
+    await zfs.promises.mkdir('/repo/sub');
+    const tree = new TreeSource({ zfs, roots: ['/repo'], events });
+
+    const root = await tree.getItem(TREE_ROOT_ID);
+    expect(root.children).toEqual(['/repo']);
+
+    const repo = await tree.getItem('/repo');
+    expect(repo.isFolder).toBe(true);
+    expect(repo.children).toEqual(['/repo/sub', '/repo/z.txt']);
+
+    const file = await tree.getItem('/repo/z.txt');
+    expect(file.isFolder).toBe(false);
+    expect(file.data).toBe('z.txt');
+    tree.dispose();
+  });
+
+  it('auto-invalidates every known id after each shell command', async () => {
+    const tree = new TreeSource({ zfs, roots: ['/repo'], events });
+    await tree.getItem(TREE_ROOT_ID);
+    await tree.getItem('/repo');
+
+    const invalidations: string[][] = [];
+    tree.onDidChange((ids) => invalidations.push(ids));
+
+    await sandbox.exec('touch /repo/new-file.txt');
+    expect(invalidations.length).toBeGreaterThan(0);
+    expect(invalidations[0]).toContain('/repo');
+    expect(invalidations[0]).toContain(TREE_ROOT_ID);
+
+    // the re-fetch sees the new file
+    const repo = await tree.getItem('/repo');
+    expect(repo.children).toContain('/repo/new-file.txt');
+    tree.dispose();
+  });
+});
+
+describe('sandbox events', () => {
+  it('emits exec:start/exec:end and coarse fs:changed per live command', async () => {
+    const seen: string[] = [];
+    events.on('exec:start', (e) => seen.push(`start:${e.line}`));
+    events.on('exec:end', (e) => seen.push(`end:${e.line}:${e.exitCode}`));
+    events.on('fs:changed', (e) => seen.push(`fs:${e.origin}`));
+
+    await sandbox.exec('true');
+    expect(seen).toEqual(['start:true', 'end:true:0', 'fs:exec']);
+
+    // transient execs (tab completion) stay silent
+    seen.length = 0;
+    await sandbox.complete('ech');
+    expect(seen).toEqual([]);
+  });
+
+  it('emits edit:request from the edit command', async () => {
+    const requested: string[] = [];
+    events.on('edit:request', (e) => requested.push(e.path));
+    const r = await sandbox.exec('edit notes.md');
+    expect(r.exitCode).toBe(0);
+    expect(requested).toEqual(['/repo/notes.md']);
+  });
+});
diff --git a/src/host/index.ts b/src/host/index.ts
new file mode 100644
index 0000000..908d0a7
--- /dev/null
+++ b/src/host/index.ts
@@ -0,0 +1,22 @@
+/**
+ * @artipod/core/host — headless UI controllers (plan §3): framework-free
+ * logic for terminals, editors and file trees. Apps keep thin shells
+ * (xterm, Monaco, react-complex-tree); tests use fakes. Import-safe in Node.
+ */
+export { TerminalSession, toCrLf, commonPrefix } from './terminal-session.js';
+export type { TerminalIO, TerminalSessionOptions } from './terminal-session.js';
+export { FileBuffer, languageForPath } from './file-buffer.js';
+export type { FileBufferOptions, FileBufferListener } from './file-buffer.js';
+export { TreeSource, TREE_ROOT_ID } from './tree-source.js';
+export type { TreeItemData, TreeSourceOptions } from './tree-source.js';
+export { PodEvents } from '../events.js';
+export type {
+  PodEventMap,
+  PodEventName,
+  ExecStartEvent,
+  ExecEndEvent,
+  FsChangedEvent,
+  EditRequestEvent,
+  AgentToolCallEvent,
+  ApprovalRequestEvent,
+} from '../events.js';
diff --git a/src/host/terminal-session.ts b/src/host/terminal-session.ts
new file mode 100644
index 0000000..d77decc
--- /dev/null
+++ b/src/host/terminal-session.ts
@@ -0,0 +1,221 @@
+/**
+ * TerminalSession — the headless terminal line discipline extracted from
+ * artipod-sync's Terminal.tsx (plan §3): input buffer, history, common-prefix
+ * tab completion, Ctrl+C abort, prompt-from-cwd, \n→\r\n normalization.
+ *
+ * I/O contract: `handleData(data)` in, `io.write(text)` out — anything
+ * xterm-shaped satisfies it; tests use a fake. No DOM at module top level.
+ */
+import type { Sandbox } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface TerminalIO {
+  write(text: string): void;
+}
+
+export interface TerminalSessionOptions {
+  sandbox: Sandbox;
+  io: TerminalIO;
+  /** Agent tool-call echo arrives via events (replaces registerWriter). */
+  events?: PodEvents;
+  /** Lines written once on attach (app-owned banner). */
+  banner?: string[];
+  /** Secondary tabs: refuse to run commands, explain why. */
+  readOnly?: boolean;
+}
+
+const RED = '\x1b[31m';
+const DIM = '\x1b[2m';
+const RESET = '\x1b[0m';
+
+/** xterm wants \r\n; sandbox output uses \n. */
+export const toCrLf = (s: string): string => s.replace(/\r?\n/g, '\r\n');
+
+/** Longest common prefix of a non-empty candidate list. */
+export function commonPrefix(items: string[]): string {
+  let prefix = items[0];
+  for (const item of items.slice(1)) {
+    while (!item.startsWith(prefix)) prefix = prefix.slice(0, -1);
+  }
+  return prefix;
+}
+
+export class TerminalSession {
+  private buffer = '';
+  private history: string[] = [];
+  private historyIndex = 0;
+  private busy = false;
+  private abortController: AbortController | null = null;
+  private completing = false;
+  private disposers: Array<() => void> = [];
+  private disposed = false;
+
+  constructor(private readonly opts: TerminalSessionOptions) {
+    const { io, banner, events } = opts;
+    if (banner?.length) {
+      for (const line of banner) io.write(`${line}\r\n`);
+    }
+    if (events) {
+      this.disposers.push(
+        events.on('agent:tool-call', (e) => {
+          if (e.phase !== 'call') return;
+          const args = e.arguments.length > 120 ? `${e.arguments.slice(0, 120)}…` : e.arguments;
+          io.write(`\r\n${DIM}⚙ ${e.name} ${args}${RESET}\r\n`);
+        }),
+      );
+    }
+    this.prompt();
+  }
+
+  /** The shell prompt string (cwd-derived, like the app's getPrompt). */
+  private promptText(): string {
+    return `${this.opts.sandbox.getCwd()} $ `;
+  }
+
+  prompt(): void {
+    this.opts.io.write(`\r\n${this.promptText()}`);
+  }
+
+  private redrawLine(): void {
+    this.opts.io.write(`${this.promptText()}${this.buffer}`);
+  }
+
+  private eraseBuffer(): void {
+    while (this.buffer.length > 0) {
+      this.opts.io.write('\b \b');
+      this.buffer = this.buffer.slice(0, -1);
+    }
+  }
+
+  /** Feed raw terminal input (keystrokes, paste). */
+  async handleData(data: string): Promise {
+    if (this.disposed) return;
+    const { io, sandbox } = this.opts;
+
+    // Ctrl+C first: must work while a command is running.
+    if (data === '\x03') {
+      if (this.busy) {
+        this.abortController?.abort();
+      } else {
+        io.write('^C');
+        this.buffer = '';
+        this.prompt();
+      }
+      return;
+    }
+    if (this.busy) return; // ignore typing while a command runs
+    const code = data.charCodeAt(0);
+
+    if (data === '\t') {
+      if (this.completing || !this.buffer) return;
+      this.completing = true;
+      try {
+        const { candidates, replaceStart } = await sandbox.complete(this.buffer);
+        if (candidates.length === 0) return;
+        const token = this.buffer.slice(replaceStart);
+        if (candidates.length === 1) {
+          const chosen = candidates[0];
+          const suffix = chosen.endsWith('/') ? '' : ' ';
+          const insert = chosen.slice(token.length) + suffix;
+          this.buffer += insert;
+          io.write(insert);
+        } else {
+          const lcp = commonPrefix(candidates);
+          if (lcp.length > token.length) {
+            const insert = lcp.slice(token.length);
+            this.buffer += insert;
+            io.write(insert);
+          } else {
+            const shown = candidates.slice(0, 60);
+            const more = candidates.length - shown.length;
+            io.write(`\r\n${DIM}${shown.join('  ')}${more > 0 ? `  …+${more}` : ''}${RESET}\r\n`);
+            this.redrawLine();
+          }
+        }
+      } finally {
+        this.completing = false;
+      }
+      return;
+    }
+
+    if (data === '\x1b[A') {
+      // Up arrow
+      if (this.historyIndex > 0) {
+        this.eraseBuffer();
+        this.historyIndex--;
+        const prev = this.history[this.historyIndex];
+        io.write(prev);
+        this.buffer = prev;
+      }
+      return;
+    }
+
+    if (data === '\x1b[B') {
+      // Down arrow
+      if (this.historyIndex < this.history.length) {
+        this.eraseBuffer();
+        this.historyIndex++;
+        if (this.historyIndex === this.history.length) {
+          this.buffer = '';
+        } else {
+          const next = this.history[this.historyIndex];
+          io.write(next);
+          this.buffer = next;
+        }
+      }
+      return;
+    }
+
+    if (code === 13) {
+      // Enter
+      io.write('\r\n');
+      const cmd = this.buffer;
+      this.buffer = '';
+
+      if (cmd.trim()) {
+        if (this.opts.readOnly) {
+          io.write(`${RED}read-only session: commands are disabled in this tab${RESET}\r\n`);
+          this.prompt();
+          return;
+        }
+        this.history.push(cmd);
+        this.historyIndex = this.history.length;
+
+        this.busy = true;
+        this.abortController = new AbortController();
+        try {
+          const result = await sandbox.exec(cmd, { signal: this.abortController.signal });
+          if (result.stdout) io.write(toCrLf(result.stdout));
+          if (result.stderr) io.write(`${RED}${toCrLf(result.stderr)}${RESET}`);
+        } catch (e) {
+          io.write(`${RED}${toCrLf(String(e))}${RESET}\r\n`);
+        } finally {
+          this.busy = false;
+          this.abortController = null;
+        }
+      }
+      this.prompt();
+    } else if (code === 127) {
+      // Backspace
+      if (this.buffer.length > 0) {
+        this.buffer = this.buffer.slice(0, -1);
+        this.opts.io.write('\b \b');
+      }
+    } else if (code < 32) {
+      // Ignore other control characters
+    } else {
+      this.buffer += data;
+      this.opts.io.write(data);
+    }
+  }
+
+  get isBusy(): boolean {
+    return this.busy;
+  }
+
+  dispose(): void {
+    this.disposed = true;
+    this.abortController?.abort();
+    for (const d of this.disposers.splice(0)) d();
+  }
+}
diff --git a/src/host/tree-source.ts b/src/host/tree-source.ts
new file mode 100644
index 0000000..b4bf9c6
--- /dev/null
+++ b/src/host/tree-source.ts
@@ -0,0 +1,89 @@
+/**
+ * TreeSource — the headless file-tree data source extracted from
+ * artipod-sync's FileTree.tsx (plan §3): getItem/children lookups plus
+ * `fs:changed`-driven invalidation, structurally matching react-complex-tree's
+ * TreeDataProvider (incl. its change-listener slot) without importing its
+ * types. Roots are app-declared (the pod's mount table in Phase 3+), not a
+ * hardcoded /repo.
+ */
+import type { ZenFsLike } from '../sandbox/types.js';
+import type { PodEvents } from '../events.js';
+
+export interface TreeItemData {
+  index: string;
+  isFolder: boolean;
+  children: string[];
+  /** Display label (basename). */
+  data: string;
+}
+
+export const TREE_ROOT_ID = 'root';
+
+export interface TreeSourceOptions {
+  zfs: ZenFsLike;
+  /** Absolute paths shown at the top level. Default: ['/repo']. */
+  roots?: string[];
+  events?: PodEvents;
+}
+
+export class TreeSource {
+  private readonly roots: string[];
+  private listeners = new Set<(changedIds: string[]) => void>();
+  /** Every id handed out — the bounded invalidation set for coarse changes. */
+  private knownIds = new Set();
+  private disposers: Array<() => void> = [];
+
+  constructor(private readonly opts: TreeSourceOptions) {
+    this.roots = opts.roots ?? ['/repo'];
+    if (opts.events) {
+      this.disposers.push(
+        opts.events.on('fs:changed', () => this.invalidate()),
+      );
+    }
+  }
+
+  /** Matches TreeDataProvider.getTreeItem structurally. */
+  async getItem(itemId: string): Promise {
+    if (itemId === TREE_ROOT_ID) {
+      this.knownIds.add(TREE_ROOT_ID);
+      return { index: TREE_ROOT_ID, isFolder: true, children: [...this.roots], data: 'Root' };
+    }
+    const path = itemId;
+    this.knownIds.add(path);
+    try {
+      const stat = await this.opts.zfs.promises.stat(path);
+      const isFolder = stat.isDirectory();
+      let children: string[] = [];
+      if (isFolder) {
+        const names = (await this.opts.zfs.promises.readdir(path)) as string[];
+        children = names.map((f) => (path === '/' ? `/${f}` : `${path}/${f}`)).sort();
+      }
+      return { index: itemId, isFolder, children, data: path.split('/').pop() || path };
+    } catch {
+      return { index: itemId, isFolder: false, children: [], data: path.split('/').pop() || path };
+    }
+  }
+
+  async getChildren(itemId: string): Promise {
+    return (await this.getItem(itemId)).children;
+  }
+
+  /** Matches TreeDataProvider's change-listener slot. */
+  onDidChange(listener: (changedIds: string[]) => void): () => void {
+    this.listeners.add(listener);
+    return () => this.listeners.delete(listener);
+  }
+
+  /** Coarse invalidation: every id the UI has asked about gets re-fetched. */
+  invalidate(): void {
+    if (this.listeners.size === 0) return;
+    const ids = [TREE_ROOT_ID, ...this.knownIds];
+    const unique = [...new Set(ids)];
+    for (const l of [...this.listeners]) l(unique);
+  }
+
+  dispose(): void {
+    for (const d of this.disposers.splice(0)) d();
+    this.listeners.clear();
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index 5caedf3..5e5b839 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,18 @@ export { ArtiPod } from './artipod.js';
 export { ArtiMount } from './artimount.js';
 export type { PodFs, PodDirent, PodStats } from './podfs.js';
 export { nodePodFs } from './nodePodFs.js';
+export { PodEvents } from './events.js';
+export type {
+  PodEventMap,
+  PodEventName,
+  ExecStartEvent,
+  ExecEndEvent,
+  FsChangedEvent,
+  EditRequestEvent,
+  AgentToolCallEvent,
+  ApprovalRequestEvent,
+} from './events.js';
+export { normalizePosix, resolvePosix, joinPosix, dirnamePosix, relativePosix } from './pathUtils.js';
 export { 
   findAllContainers, 
   removeContainer,

From 0b6f05cea4ed66f143f7e724eb5b3ecd9ddcb948 Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:24:25 -0400
Subject: [PATCH 06/12] feat(sandbox,agent): widen app-facing surfaces; worker
 URL targets dist

sandbox index re-exports storage + git/git-auth APIs (what artipod-sync's app layer consumes); agent exports DEFAULT_LOCAL_MODEL + CachedModel; LocalModelClient worker URL now ./worker.js so bundlers resolve it inside the published dist; terminal echoes bash result summaries on agent:tool-call result phase.
---
 src/agent/index.ts           |  2 ++
 src/agent/local/client.ts    |  2 +-
 src/host/terminal-session.ts |  9 ++++++---
 src/sandbox/index.ts         | 16 +++++++++++++++-
 4 files changed, 24 insertions(+), 5 deletions(-)

diff --git a/src/agent/index.ts b/src/agent/index.ts
index 85afad3..2a7eb0e 100644
--- a/src/agent/index.ts
+++ b/src/agent/index.ts
@@ -35,9 +35,11 @@ export { LocalModelClient, webGpuAvailable } from './local/client.js';
 export type { LocalModelClientOptions } from './local/client.js';
 export {
   CURATED_MODELS,
+  DEFAULT_LOCAL_MODEL,
   listLocalModels,
   modelInfo,
   type LocalModelInfo,
   type OnnxDtype,
 } from './local/model-registry.js';
 export { formatBytes, listCachedModels, modelIdFromCacheKey } from './local/model-cache.js';
+export type { CachedModel } from './local/model-cache.js';
diff --git a/src/agent/local/client.ts b/src/agent/local/client.ts
index 043a4a4..9f789c9 100644
--- a/src/agent/local/client.ts
+++ b/src/agent/local/client.ts
@@ -36,7 +36,7 @@ export class LocalModelClient implements ChatCompletionClient {
 
   private getWorker(): Worker {
     if (!this.worker) {
-      this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
+      this.worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
       this.worker.onmessage = (event: MessageEvent) => {
         const msg = event.data;
         if (msg.type === 'progress') {
diff --git a/src/host/terminal-session.ts b/src/host/terminal-session.ts
index d77decc..6b1ec9f 100644
--- a/src/host/terminal-session.ts
+++ b/src/host/terminal-session.ts
@@ -58,9 +58,12 @@ export class TerminalSession {
     if (events) {
       this.disposers.push(
         events.on('agent:tool-call', (e) => {
-          if (e.phase !== 'call') return;
-          const args = e.arguments.length > 120 ? `${e.arguments.slice(0, 120)}…` : e.arguments;
-          io.write(`\r\n${DIM}⚙ ${e.name} ${args}${RESET}\r\n`);
+          if (e.phase === 'call') {
+            const args = e.arguments.length > 120 ? `${e.arguments.slice(0, 120)}…` : e.arguments;
+            io.write(`\r\n${DIM}⚙ ${e.name} ${args}${RESET}\r\n`);
+          } else if (e.summary) {
+            io.write(`${DIM}${toCrLf(e.summary)}${RESET}`);
+          }
         }),
       );
     }
diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts
index 85fb7e2..0749993 100644
--- a/src/sandbox/index.ts
+++ b/src/sandbox/index.ts
@@ -28,10 +28,24 @@ import type { PodEvents } from '../events.js';
 import type { CompletionResult, Sandbox, SandboxExecOptions, ZenFsLike } from './types.js';
 import { ZenFsAdapter } from './zenfs-adapter.js';
 
-export type { CompletionResult, Sandbox, SandboxExecOptions, SandboxExecResult } from './types.js';
+export type { CompletionResult, Sandbox, SandboxExecOptions, SandboxExecResult, ZenFsLike } from './types.js';
 export { SHELL_NOTES } from './notes-command.js';
 export { ZenFsAdapter } from './zenfs-adapter.js';
 export { SUDO_DENIED_MESSAGE } from './sudo-command.js';
+// App-facing sandbox infrastructure: storage backends, git ops + auth.
+export * from './storage.js';
+export { createGitOps, getAuthor, setAuthor, setCorsProxy, getCorsProxy } from './git.js';
+export type { GitOps, GitStatusResult, StatusEntry } from './git.js';
+export {
+  setAuthPrompt,
+  persistenceEnabled,
+  setPersistence,
+  setToken,
+  getToken,
+  clearToken,
+  onAuthForUrl,
+  onAuthFailureForUrl,
+} from './git-auth.js';
 
 export interface CreateSandboxOptions {
   /** The ZenFS node-like fs object backing the sandbox. */

From 62030137966410722b4b7a8de10f374d727568a1 Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:29:18 -0400
Subject: [PATCH 07/12] =?UTF-8?q?fix(core)!:=20docker=20backend=20loads=20?=
 =?UTF-8?q?lazily=20=E2=80=94=20browser=20bundles=20stay=20clean?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ArtiPod's container methods import ./docker on first use; root entry re-exports docker TYPES only (values moved to @artipod/core/docker) — dockerode's ssh2 native addon was breaking webpack builds of browser consumers.
---
 src/artipod.ts        | 23 ++++++++++-------------
 src/index.ts          | 10 ++--------
 src/tools/podTools.ts |  2 +-
 3 files changed, 13 insertions(+), 22 deletions(-)

diff --git a/src/artipod.ts b/src/artipod.ts
index 15abc1f..bc94264 100644
--- a/src/artipod.ts
+++ b/src/artipod.ts
@@ -1,19 +1,15 @@
 import { ArtiMount } from './artimount.js';
-import {
-  ContainerHandle,
-  CommandResult,
-  ContainerOptions,
-  buildContainerImage,
-  createContainer,
-  executeCommandInContainer,
-  stopAndRemoveContainer,
-} from './docker/containerUtils.js';
+import type { ContainerHandle, CommandResult, ContainerOptions } from './docker/containerUtils.js';
 import { ArtiPodOptions } from './types.js';
 import type { PodFs } from './podfs.js';
 import { nodePodFs } from './nodePodFs.js';
 import { joinPosix } from './pathUtils.js';
 import { PodEvents } from './events.js';
 
+// The docker backend (dockerode → ssh2 native addon) must never enter
+// browser bundles: it is loaded on first container use, node-only.
+const dockerBackend = () => import('./docker/containerUtils.js');
+
 /** Isomorphic 16-byte hex id (WebCrypto exists in browsers and Node ≥20). */
 function randomHexId(): string {
   const buf = new Uint8Array(16);
@@ -454,13 +450,14 @@ export class ArtiPod {
     }
 
     // Build or reuse image
-    this.imageName = await buildContainerImage(dockerfilePath);
+    const docker = await dockerBackend();
+    this.imageName = await docker.buildContainerImage(dockerfilePath);
     
     // Store timeout for later use
     this.commandTimeout = options?.commandTimeout || 30000;
 
     // Create and start container
-    this.container = await createContainer(this.imageName, mounts, options);
+    this.container = await docker.createContainer(this.imageName, mounts, options);
     return this.container;
   }
 
@@ -472,7 +469,7 @@ export class ArtiPod {
       throw new Error('No running container for this pod');
     }
 
-    await stopAndRemoveContainer(this.container);
+    await (await dockerBackend()).stopAndRemoveContainer(this.container);
     this.container = undefined;
     this.imageName = undefined;
   }
@@ -491,7 +488,7 @@ export class ArtiPod {
     const effectiveTimeout = timeout ?? this.commandTimeout;
     this.events.emit('exec:start', { line: command });
     const startedAt = Date.now();
-    const result = await executeCommandInContainer(this.container, command, effectiveTimeout);
+    const result = await (await dockerBackend()).executeCommandInContainer(this.container, command, effectiveTimeout);
     this.events.emit('exec:end', { line: command, exitCode: result.exitCode, durationMs: Date.now() - startedAt });
     // Coarse by contract: a container command can touch any mount.
     this.events.emit('fs:changed', { origin: 'exec' });
diff --git a/src/index.ts b/src/index.ts
index 5e5b839..b74e290 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,14 +14,8 @@ export type {
   ApprovalRequestEvent,
 } from './events.js';
 export { normalizePosix, resolvePosix, joinPosix, dirnamePosix, relativePosix } from './pathUtils.js';
-export { 
-  findAllContainers, 
-  removeContainer,
-  detectRuntime,
-  getCachedRuntimeInfo,
-  clearRuntimeCache,
-  isRuntimeAvailable,
-} from './docker/index.js';
+// Docker runtime VALUES live in '@artipod/core/docker' only — re-exporting
+// them here would drag dockerode (native ssh2) into browser bundles.
 export type {
   ArtiPodConfig,
   ArtiPodOptions,
diff --git a/src/tools/podTools.ts b/src/tools/podTools.ts
index c69f0b3..a4ee684 100644
--- a/src/tools/podTools.ts
+++ b/src/tools/podTools.ts
@@ -6,7 +6,7 @@
  */
 
 import { ArtiPod } from '../artipod.js';
-import { CommandResult } from '../docker/containerUtils.js';
+import type { CommandResult } from '../docker/containerUtils.js';
 import {
   ToolHandler,
   ToolName,

From aa75b3df6264691a8477985511b8922874cb20ff Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:29:42 -0400
Subject: [PATCH 08/12] fix(pkg): browser field stubs the docker graph out of
 client bundles

---
 package.json | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/package.json b/package.json
index 1f5120b..7e05ef3 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,12 @@
   "publishConfig": {
     "access": "public"
   },
+  "browser": {
+    "./dist/docker/index.js": false,
+    "./dist/docker/containerUtils.js": false,
+    "./dist/docker/containerRuntime.js": false,
+    "dockerode": false
+  },
   "files": [
     "dist"
   ],

From 538fc428f01eb6977acde1b69040eddcb3b5e17e Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:30:50 -0400
Subject: [PATCH 09/12] fix(core): webpackIgnore on the lazy docker import

---
 src/artipod.ts | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/artipod.ts b/src/artipod.ts
index bc94264..855f3ff 100644
--- a/src/artipod.ts
+++ b/src/artipod.ts
@@ -7,8 +7,12 @@ import { joinPosix } from './pathUtils.js';
 import { PodEvents } from './events.js';
 
 // The docker backend (dockerode → ssh2 native addon) must never enter
-// browser bundles: it is loaded on first container use, node-only.
-const dockerBackend = () => import('./docker/containerUtils.js');
+// browser bundles: loaded on first container use, node-only. webpackIgnore
+// keeps webpack from following the dynamic edge (vite/vitest still resolve it).
+const dockerBackend = () =>
+  import(/* webpackIgnore: true */ './docker/containerUtils.js') as Promise<
+    typeof import('./docker/containerUtils.js')
+  >;
 
 /** Isomorphic 16-byte hex id (WebCrypto exists in browsers and Node ≥20). */
 function randomHexId(): string {

From a363e51ad66bdf2351929aa1efa6e6f4ae20acde Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:33:48 -0400
Subject: [PATCH 10/12] fix(sandbox): initFileSystem returns the configured zfs
 instance

Under file:/link installs the package resolves its own @zenfs/core copy; consumers adopting the returned instance is the only way to guarantee one store. lib/filesystem in artipod-sync now binds result.zfs.
---
 src/sandbox/storage.ts | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/src/sandbox/storage.ts b/src/sandbox/storage.ts
index 90c8dfc..b07031e 100644
--- a/src/sandbox/storage.ts
+++ b/src/sandbox/storage.ts
@@ -9,6 +9,8 @@
  * OPFS is opt-in via the settings UI, persisted in localStorage.
  */
 
+import type { ZenFsLike } from './types.js';
+
 export type StorageBackend = 'opfs' | 'indexeddb' | 'memory';
 
 type MountConfig =
@@ -181,6 +183,13 @@ export interface InitResult {
   backend: StorageBackend;
   /** False when another tab already owns the filesystem. Advisory: writes are not blocked. */
   isPrimaryTab: boolean;
+  /**
+   * The node-like fs this init configured. Consumers MUST adopt this instance
+   * instead of importing '@zenfs/core' themselves — under file:/link installs
+   * the package resolves its own @zenfs/core copy, and two singletons means
+   * two disjoint filesystems.
+   */
+  zfs: ZenFsLike;
 }
 
 let releaseLock: (() => void) | null = null;
@@ -227,7 +236,7 @@ export async function initFileSystem(pref?: StorageBackend): Promise
   if (!(await fs.promises.exists('/repo'))) {
     await fs.promises.mkdir('/repo');
   }
-  return { backend, isPrimaryTab };
+  return { backend, isPrimaryTab, zfs: fs };
 }
 
 /**

From 9f9b00236b58b4d4f29876b4b21dbdd8eea2d7fd Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:41:52 -0400
Subject: [PATCH 11/12] chore(pkg): prepare script builds dist for git-protocol
 installs

---
 package.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 7e05ef3..ea985cf 100644
--- a/package.json
+++ b/package.json
@@ -77,7 +77,8 @@
     "lint:fix": "eslint src --ext .ts --fix",
     "clean": "rm -rf dist",
     "prepublishOnly": "npm run clean && npm run build",
-    "example:basic": "npx tsx examples/basic/basic-pod-usage.ts"
+    "example:basic": "npx tsx examples/basic/basic-pod-usage.ts",
+    "prepare": "npm run build"
   },
   "keywords": [
     "ai",

From ed9a2d8e1a78d574ee04b1cdccbfce0181f1f56e Mon Sep 17 00:00:00 2001
From: Doug Horner 
Date: Sun, 30 Aug 2026 21:50:10 -0400
Subject: [PATCH 12/12] docs(plan): phase 2 gate

---
 artipod-layer-plan.md | 39 ++++++++++++++++++++++-----------------
 1 file changed, 22 insertions(+), 17 deletions(-)

diff --git a/artipod-layer-plan.md b/artipod-layer-plan.md
index b048f33..4f97601 100644
--- a/artipod-layer-plan.md
+++ b/artipod-layer-plan.md
@@ -43,7 +43,7 @@ Read order: §1 (goal) and §6 (decisions already made) first; skim §2–§3 fo
 |---|---|---|---|
 | 0 — repo prep | `phase-0-esm-vitest` | done (owner npm actions pending) | [#33](https://github.com/mieweb/artipod/pull/33), [#34](https://github.com/mieweb/artipod/pull/34) |
 | 1 — fs injection | `phase-1-podfs-injection` | done | [#35](https://github.com/mieweb/artipod/pull/35) |
-| 2 — import sandbox/agent/proc | `phase-2-import-sandbox` | not started | |
+| 2 — import sandbox/agent/proc | `phase-2-import-sandbox` | done (shim deletion + ui PR landing pending) | mieweb/artipod#37, [horner/artipod-sync#2](https://github.com/horner/artipod-sync/pull/2), [mieweb/ui#404](https://github.com/mieweb/ui/pull/404) |
 | 3 — manifest + realizers | `phase-3-manifest-realizers` | not started | |
 | 4 — OCI store | `phase-4-oci-store` | not started | |
 | 5 — snapshots + commit | `phase-5-snapshots` | not started | |
@@ -288,29 +288,34 @@ The one structural refactor everything else depends on. `ArtiMount` already uses
 
 ### Phase 2 — Move sandbox, agent, proc into `@artipod/core` (executes just-bash-plan Phase 6)
 
-> **Branch** `phase-2-import-sandbox` · **Status** _not started_ · includes a consuming PR in `horner/artipod-sync`
+> **Branch** `phase-2-import-sandbox` · **Status** _done_ · includes a consuming PR in `horner/artipod-sync`
 
-- [ ] Move `artipod-sync/lib/sandbox/` → `@artipod/core/sandbox` (incl. `zenfs-adapter`, `git-command`, `edit-command`, `notes/storage/module` commands, `storage.ts`, `table.ts`) with their vitest suites.
-- [ ] Move `artipod-sync/lib/agent/` → `@artipod/core/agent` (loop, ozwell client, `local/` ONNX worker stack). Replace its `read_file`/`write_file`/`list_files` with bindings to `/tools` (schema collision #1 resolved here). Keep `bash` + truncation. Tests must satisfy Decision #8: scripted fakes only — no live model calls, no model downloads, no default AI endpoint shipped (audit the `local/` suite's mocks on the way in).
-- [ ] Move `artipod-sync/lib/proc/` → `@artipod/core/proc`.
-- [ ] Add `pod.events` (core) + extract `/host` controllers (`TerminalSession`, `FileBuffer`, `TreeSource`) from the logic currently embedded in `Terminal.tsx` / `Editor.tsx` / `FileTree.tsx`; rewire those components as thin shells. Acceptance detail: tree auto-refreshes after every command, editor detects external changes to its open file, agent tool-call echo arrives via `agent:tool-call` (no `registerWriter`).
-- [ ] artipod-sync consumes via workspace/path dep; `lib/sandbox` etc. become re-export shims for one release, then delete. `lib/server/`, routes, components stay in the app.
-- [ ] Wire `createSandbox` into the pod: `pod.createSandbox()` mounts the realized fs and registers the pod's custom commands.
-- [ ] **Agent confinement stub (default-deny)**: tools/bash confined to the pod; `sudo` is recognized but returns EPERM with "approval flow lands in Phase 6.5" — pinned by tests (docs/security-model.md is normative).
-- [ ] `/console` module: `installConsole({ pod, hotkey })` — builtin zero-dep renderer, `` Ctrl+` ``/`Ctrl+~` hotkey, SSR-safe no-op, honors `isPrimaryTab`; consumes only `/host` controllers + `pod.events` (docs/console.md).
-- [ ] `ui/` proof: Storybook story where AIChat drives the sandbox via MCPToolCall rendering (the original Phase 6 acceptance).
+- [x] Move `artipod-sync/lib/sandbox/` → `@artipod/core/sandbox` (incl. `zenfs-adapter`, `git-command`, `edit-command`, `notes/storage/module` commands, `storage.ts`, `table.ts`) with their vitest suites. Also pulled in `git.ts`/`git-auth.ts` (git-command's engine) — decoupled from the app fs singleton (factory-only; `NEXT_PUBLIC_GIT_CORS_PROXY` guarded for non-Next bundles).
+- [x] Move `artipod-sync/lib/agent/` → `@artipod/core/agent` (loop, ozwell client, `local/` ONNX worker stack). Replace its `read_file`/`write_file`/`list_files` with bindings to `/tools` (schema collision #1 resolved here). Keep `bash` + truncation. Tests must satisfy Decision #8: scripted fakes only — audit done: `local/` suite touches only parsers/registry/cache math, worker loads transformers from CDN at runtime (types-only devDep), no live calls, no downloads, no default endpoint shipped.
+- [x] Move `artipod-sync/lib/proc/` → `@artipod/core/proc`.
+- [x] Add `pod.events` (core) + extract `/host` controllers (`TerminalSession`, `FileBuffer`, `TreeSource`) from the logic currently embedded in `Terminal.tsx` / `Editor.tsx` / `FileTree.tsx`; rewire those components as thin shells. Acceptance detail: tree auto-refreshes after every command, editor detects external changes to its open file, agent tool-call echo arrives via `agent:tool-call` (no `registerWriter`) — all three verified live (worklog).
+- [x] artipod-sync consumes via workspace/path dep (`file:../artipod` until the npm publish); `lib/sandbox` etc. are re-export shims for one release, then delete (ask-first). `lib/server/`, routes, components stay in the app.
+- [ ] ~~Wire `createSandbox` into the pod: `pod.createSandbox()`~~ — **deferred to Phase 3** (rule-6 deviation): it needs the realized fs, which only exists once manifests + realizers land; a Phase 2 version would hardcode exactly the bespoke init Phase 3 deletes. Tracked as part of the Phase 3 realizer work.
+- [x] **Agent confinement stub (default-deny)**: tools/bash confined to the pod; `sudo` is recognized but returns EPERM with "approval flow lands in Phase 6.5" — pinned by tests (`sudo-command.test.ts`: bare/args/pipeline forms + `approval:request` emission; docs/security-model.md is normative).
+- [x] `/console` module: `installConsole({ sandbox, events, hotkey })` — builtin zero-dep renderer, `` Ctrl+` ``/`Ctrl+~` hotkey, SSR-safe no-op (pinned by test), read-only mode for secondary tabs; consumes only `/host`-style contracts + `pod.events` (docs/console.md). `pod` param arrives with Phase 3's pod↔sandbox unification.
+- [x] `ui/` proof: Storybook story where AIChat drives the sandbox via MCPToolCall rendering (the original Phase 6 acceptance) — mieweb/ui#404 (draft until the package publishes).
 
 **Done when:**
 
-- [ ] All moved vitest suites green in this repo (sandbox, agent, proc — same counts as they had in artipod-sync)
-- [ ] artipod-sync behavior unchanged: clone → pipeline commands → edit → commit → reload persists (Playwright e2e if present, else the manual script — record which in the worklog)
-- [ ] Duplicated tool code deleted from artipod-sync; only re-export shims remain (their deletion is on the ask-first list)
-- [ ] Event wiring proven in the app: tree auto-refreshes after every command, editor detects external changes to its open file, agent echo arrives via `agent:tool-call`
-- [ ] `ui/` Storybook story runs (AIChat driving the sandbox, MCPToolCall rendering)
+- [x] All moved vitest suites green in this repo (sandbox, agent, proc — same counts as they had in artipod-sync) — verified: the 6 moved test files (sandbox, storage, storage-command, zenfs-adapter, git-command, proc, agent, local) all pass; package total 17 files / **306 tests** (up from 197), artipod-sync keeps its 2 server files / 11 tests green
+- [x] artipod-sync behavior unchanged: clone → pipeline commands → edit → commit → reload persists — no Playwright e2e exists, so recorded as a scripted browser session (worklog): shell pipeline ✓, git clone via /api/git ✓ (clone-into-cwd semantics unchanged), Monaco edit + save ✓, IndexedDB persistence across reload ✓, sudo → EPERM ✓
+- [x] Duplicated tool code deleted from artipod-sync; only re-export shims remain (their deletion is on the ask-first list)
+- [x] Event wiring proven in the app: tree auto-refreshes after every command (zero Refresh clicks), editor detects external changes to its open file (clean-buffer reload verified), agent echo arrives via `agent:tool-call` (scripted fake-LLM run: ⚙ create_file + ⚙ bash echoed in xterm)
+- [x] `ui/` Storybook story runs (AIChat driving the sandbox, MCPToolCall rendering) — verified live: pipeline command exit 0 with output block, sudo renders an error tool call; mieweb/ui#404
 
 **Worklog:**
 
-- _(empty)_
+- 2026-08-30 — move executed: lib/{sandbox,proc,agent} + git/git-auth copied verbatim (~2.9k lines, 6 test files / 90 tests), 72 imports got `.js` extensions, `../git` → `./git`. New subpath exports: `./sandbox` `./proc` `./agent` (+ `./agent/local/worker`, `./host`, `./console`). isomorphic-git + diff join the exact-pinned optional peers; @huggingface/transformers is a types-only devDep (worker loads it from CDN).
+- 2026-08-30 — isomorphism fallout fixed while consuming: (1) node `path`/`crypto` were still imported by artimount/artipod — replaced with dependency-free posix helpers (`pathUtils.ts`) + WebCrypto ids; root-mount (`'/'`) traversal boundary had an `'//'` bug caught by the new agent binding tests. (2) dockerode's ssh2 native addon broke webpack builds — docker backend now lazy-imports with `webpackIgnore`, root entry re-exports docker **types only** (values live in `@artipod/core/docker`), plus a `browser` field stubbing the docker graph. (3) `initFileSystem` now returns the configured `zfs` — under `file:` installs the package resolves its own @zenfs/core copy, and artipod-sync's old `import('@zenfs/core')` bound a second, empty store (found live: `/repo` ENOENT).
+- 2026-08-30 — agent rebinding (collision #1): `createSandboxTools` = core `bash` definition (16 KiB truncation preserved) + `createPodFileTools` over an app-declared mount table (default `'/'`), backed by `sandbox.zfs.promises` as PodFs — shell and tools share one store, pinned by a test. Old write_file/list_files shapes deleted; agent suite updated (8-tool surface).
+- 2026-08-30 — app acceptance (horner/artipod-sync#2), scripted browser session on `npm run dev` + Playwright: tree auto-refresh ✓ (StrictMode gotcha: TreeSource's bus subscription must live in a React effect — effect-cleanup replay had stranded the memoized instance's constructor-time subscription), editor external-change reload ✓, agent echo via `agent:tool-call` ✓ using the new `/api/fake-llm` scripted endpoint (Decision #8), sudo EPERM ✓, IndexedDB persistence ✓, git clone through the proxy ✓. `registerWriter` deleted.
+- 2026-08-30 — ui story (mieweb/ui#404, draft): `@artipod/core` installed via `github:` protocol (package gained a `prepare` build script; pnpm `onlyBuiltDependencies` allowlists it); story drives the real sandbox through the agent bash tool with MCPToolCall rendering. Gotcha for the record: vite/storybook caches optional-peer-dep shims — after adding isomorphic-git/diff, `node_modules/.cache/storybook` must be cleared.
+- 2026-08-30 — package totals: 17 files / **306 tests** green (docker suites included), lint clean, Next production build of artipod-sync green. Shim deletion scheduled one release later (ask-first).
 
 ### Phase 3 — Pod manifest + realizers