mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 15:25:50 +00:00
feat: 新增 artifacts 功能 (#1278)
* feat: 新增 cloud-artifacts 包(Cloudflare Worker HTML artifact 托管)
POST /upload 鉴权上传 HTML 到 R2 返回 hash URL,GET /<7d|30d>/<id>.html
由 Worker 代理读取并直出 text/html。R2 lifecycle rule 自动 7/30 天删除。
独立服务,不被主 CLI 引用(类似 packages/remote-control-server/ 定位)。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs: 完善 cloud-artifacts 文档并统一出口域名
- CLAUDE.md 加 cloud-artifacts 到 Workspace Packages 表和新增 HTML Artifact Hosting 段落
- docs.json 注册 cloud-artifacts 到运行模式 group
- README 加 Quickstart、架构图(含 Deno Deploy 代理层)、Security Considerations、Troubleshooting
- 统一出口域名为 https://cloud-artifacts.claude-code-best.win(wrangler.toml PUBLIC_URL、test.sh 默认 WORKER_URL、所有文档示例)
- test.sh expect() 加 [via body] fallback:经 Deno Deploy 代理(status 抹平为 200)时按 body 的 error 字段断言
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs: 修正 CLAUDE.md cloud-artifacts 引用死链
之前指向不存在的 docs/features/cloud-artifacts.md(用户未注册到 docs.json),
改为指向已存在的 packages/cloud-artifacts/README.md,并补充生产出口域名
与 Deno Deploy status 抹平副作用的说明。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* chore: 同步 cloud-artifacts 测试默认 TOKEN 到新值
用户已通过 wrangler secret put 把生产 TOKEN 改为 claude-code-best,
test.sh 的默认值(之前用旧 token 作 fallback)和注释示例同步更新。
现在直接 bash scripts/test.sh 即可跑通(无需显式传 TOKEN)。
src/index.ts 不依赖具体 token 值(只读 env.TOKEN 做比较),
wrangler.toml 不含 secret,README/.dev.vars.example 用 <your-token>
占位符故无需改。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs: add artifacts feature implementation plan
* feat(artifact): add cloud-artifacts config with token/URL defaults
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add HTTP client with body-error parsing
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add tool name, description, and prompt
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add buildTool definition with file validation
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* test(artifact): add end-to-end tool tests for upload/error paths
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): export ArtifactTool from builtin-tools barrel
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): register ArtifactTool in tools list
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add /use-artifacts bundled skill
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add extractArtifacts message scanner
Scans Message[] for artifact tool_use/tool_result pairs, parses URL/id/expires
from the upload response string, and returns ArtifactInfo[] newest-first.
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(artifact): scanner type narrowing and url regex
- Use double assertion (`as unknown as Record<string, unknown>`) at lines 30
and 90 to fix TS2352 per project convention
- Tighten URL_REGEX to avoid capturing trailing punctuation (parens,
quotes, commas) when URL is embedded in text
- Add test case for array-form tool_result content path
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add ArtifactsMenu Ink component
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): add /artifacts slash command entry
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): register /artifacts command
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(artifact): use setClipboard instead of pbcopy for cross-platform support
* fix(artifact): drop userFacingName override so display matches /artifacts
* fix(rcs): add resJson helper to resolve strict mode type errors in tests
Hono Response.json() returns Promise<unknown> under strict TypeScript,
causing 121 TS errors across middleware and routes test files.
Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
* fix(cloud-artifacts): add type stubs so tsc passes without worker-configuration.d.ts
The wrangler-generated worker-configuration.d.ts is gitignored, causing CI to
fail with missing ExportedHandler/Env/R2Bucket types. This file provides minimal
stubs for all Cloudflare Workers types used by the artifact upload Worker.
Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
* fix(query): shallow-copy messages before stripping toolUseResult
Previously the per-query cleanup mutated messagesForQuery entries in
place via `delete msg.toolUseResult`. Those entries are references
shared with mutableMessages (UI state), so the delete stripped the
field from the live message object. The next query can start within
milliseconds of tool_result creation — before the React UI commit
lands — so UserToolSuccessMessage's `!message.toolUseResult` check
returned null and tool.renderToolResultMessage was never called,
leaving tool-result rows blank.
Map to a stripped copy instead so mutableMessages keeps the original
for the UI. Downstream API transformations (applyToolResultBudget,
snip, microcompact) already build new arrays via .map(), so they
compose cleanly with this copy.
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(artifact): show uploaded URL inline below ExecuteExtraTool
Deferred tools (shouldDefer: true) are invoked via SearchExtraTools →
ExecuteExtraTool, so their tool_result rows used to render blank —
the UI looked up ExecuteExtraTool, which had no renderToolResultMessage,
and returned null. Add a generic delegation in ExecuteTool that forwards
renderToolResultMessage to the inner tool when it defines one, unwrapping
the { result, tool_name } envelope and the params from the input shape.
All 28 deferred tools can now render their own UI by defining
renderToolResultMessage.
For ArtifactTool specifically, render the uploaded URL as an OSC 8
hyperlink (Link component) in warning color so it's visually prominent,
with the expiry timestamp on a second line and a separate error branch.
Also add `error: z.string().optional()` to outputSchema — zod's default
strip mode was dropping the field, so error states never reached the UI.
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(cloud-artifacts): make Env stubs actually take effect in CI
The previous stub file (2e29e362) wrapped `interface Env` in
`declare global { ... }`, but the file has no top-level import/export so
it's a script, not a module. TS2669 forbids `declare global` in scripts,
and in .d.ts files that error is silently swallowed — so the Env stubs
were never merged into the global scope. Locally typecheck passed only
because worker-configuration.d.ts (gitignored) provided Env separately;
in CI / fresh clones, `BUCKET`, `MAX_BYTES`, `DEFAULT_TTL_DAYS`,
`PUBLIC_URL` were all missing on Env.
Drop the wrapper. Top-level `interface Env` in a script .d.ts is already
global ambient and merges with worker-configuration.d.ts via interface
declaration merging, so both environments typecheck cleanly.
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
---------
Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -172,6 +172,7 @@ bun run docs:dev
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/cloud-artifacts/` | 独立 Cloudflare Worker + R2 服务:POST `/upload` HTML 上传返回 hash URL,GET `/<7d\|30d>/<id>.html` 由 Worker 代理读取;R2 lifecycle rule 自动 7/30 天过期 |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
@@ -188,6 +189,10 @@ bun run docs:dev
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### HTML Artifact Hosting
|
||||
|
||||
- **`packages/cloud-artifacts/`** — 独立 Cloudflare Worker + R2 服务,类似 `remote-control-server/` 的"独立部署服务"定位,**不被主 CLI import**。Worker 处理 `POST /upload`(Bearer token 鉴权 + text/html 校验 + 10MB 上限 + ttl∈{7,30})和 `GET /<7d|30d>/<id>.html`(从 R2 读 + Cache-Control: max-age=86400)。R2 用 prefix + lifecycle rule 实现 TTL(`7d/` 删 7 天、`30d/` 删 30 天),Worker 不参与过期处理。ID 默认 `nanoid(21)`(126 bit 熵),可指定 `?hash=` 自定义 ID(覆盖语义:先删 7d/30d prefix 旧 key 再写新 key)。Worker 用 `wrangler types` 生成的全局 `Env` 类型(`worker-configuration.d.ts`,已 gitignore),不依赖 `@cloudflare/workers-types`。部署用 `npm create cloudflare@latest` 初始化 + `bun run setup`(创建 bucket + lifecycle + secret)+ `bun run deploy`。生产出口经 Deno Deploy 边缘代理(`https://cloud-artifacts.claude-code-best.win`),副作用是 HTTP status code 被抹平为 200(body 的 `{error}` 字段仍保留)。详见 `packages/cloud-artifacts/README.md`。
|
||||
|
||||
### ACP Protocol (Agent Client Protocol)
|
||||
|
||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||
|
||||
125
bun.lock
125
bun.lock
@@ -239,6 +239,17 @@
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/cloud-artifacts": {
|
||||
"name": "cloud-artifacts",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"nanoid": "^5.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
"wrangler": "^4.0.0",
|
||||
},
|
||||
},
|
||||
"packages/color-diff-napi": {
|
||||
"name": "color-diff-napi",
|
||||
"version": "1.0.0",
|
||||
@@ -599,8 +610,26 @@
|
||||
|
||||
"@claude-code-best/workflow-engine": ["@claude-code-best/workflow-engine@workspace:packages/workflow-engine"],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260617.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260617.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260617.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260617.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260617.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw=="],
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260620.1", "", {}, "sha512-WB81w9u1bAS7KcekpC7/nYhLpIXAEtgybso7XgGJV8CQKNkNPYcyjvICLdghOlDBi/9Ivk+f7NRckV2Bkq1bDg=="],
|
||||
|
||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
@@ -1001,6 +1030,12 @@
|
||||
|
||||
"@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
@@ -1249,6 +1284,8 @@
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@smithy/abort-controller": ["@smithy/abort-controller@2.2.0", "https://registry.npmmirror.com/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw=="],
|
||||
@@ -1341,6 +1378,8 @@
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@stricli/auto-complete": ["@stricli/auto-complete@1.2.6", "https://registry.npmmirror.com/@stricli/auto-complete/-/auto-complete-1.2.6.tgz", { "dependencies": { "@stricli/core": "^1.2.6" }, "bin": { "auto-complete": "dist/bin/cli.js" } }, "sha512-H7dectwnLBoyDrp4Vek1gTNdUWzqkEDt5X6oFoOPxPVbca5FA9ttBZ/OlfNvt14aeiZUsg1rC7GEHjIh3tjn2A=="],
|
||||
@@ -1589,6 +1628,8 @@
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
@@ -1659,6 +1700,8 @@
|
||||
|
||||
"cliui": ["cliui@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||
|
||||
"cloud-artifacts": ["cloud-artifacts@workspace:packages/cloud-artifacts"],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "https://registry.npmmirror.com/cmdk/-/cmdk-1.1.1.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
@@ -1843,6 +1886,8 @@
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
@@ -2137,6 +2182,8 @@
|
||||
|
||||
"khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"knip": ["knip@6.4.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw=="],
|
||||
|
||||
"langium": ["langium@4.2.2", "https://registry.npmmirror.com/langium/-/langium-4.2.2.tgz", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="],
|
||||
@@ -2327,6 +2374,8 @@
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260617.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260617.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -2427,7 +2476,7 @@
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
@@ -2747,6 +2796,8 @@
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
@@ -2823,6 +2874,10 @@
|
||||
|
||||
"which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"workerd": ["workerd@1.20260617.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260617.1", "@cloudflare/workerd-darwin-arm64": "1.20260617.1", "@cloudflare/workerd-linux-64": "1.20260617.1", "@cloudflare/workerd-linux-arm64": "1.20260617.1", "@cloudflare/workerd-windows-64": "1.20260617.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew=="],
|
||||
|
||||
"wrangler": ["wrangler@4.103.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260617.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260617.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260617.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -2849,6 +2904,10 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
@@ -3083,6 +3142,8 @@
|
||||
|
||||
"@claude-code-best/mcp-client/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@fastify/otel/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||
|
||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||
@@ -3337,6 +3398,10 @@
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
|
||||
"miniflare/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"minipass-flush/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
@@ -3367,6 +3432,8 @@
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"streamdown/lucide-react": ["lucide-react@0.542.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.542.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
|
||||
|
||||
"streamdown/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
@@ -3375,10 +3442,14 @@
|
||||
|
||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||
|
||||
"wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library/gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
|
||||
@@ -3629,6 +3700,58 @@
|
||||
|
||||
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
1325
docs/superpowers/plans/2026-06-20-artifacts-feature.md
Normal file
1325
docs/superpowers/plans/2026-06-20-artifacts-feature.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ export { MonitorTool } from './tools/MonitorTool/MonitorTool.js'
|
||||
export { PowerShellTool } from './tools/PowerShellTool/PowerShellTool.js'
|
||||
export { PushNotificationTool } from './tools/PushNotificationTool/PushNotificationTool.js'
|
||||
export { REPLTool } from './tools/REPLTool/REPLTool.js'
|
||||
export { ArtifactTool } from './tools/ArtifactTool/ArtifactTool.js'
|
||||
export { RemoteTriggerTool } from './tools/RemoteTriggerTool/RemoteTriggerTool.js'
|
||||
export { ReviewArtifactTool } from './tools/ReviewArtifactTool/ReviewArtifactTool.js'
|
||||
export { CronCreateTool } from './tools/ScheduleCronTool/CronCreateTool.js'
|
||||
|
||||
177
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Normal file
177
packages/builtin-tools/src/tools/ArtifactTool/ArtifactTool.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { stat, readFile } from 'fs/promises'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
ARTIFACT_TOOL_NAME,
|
||||
describeArtifactTool,
|
||||
getArtifactToolPrompt,
|
||||
} from './prompt.js'
|
||||
import { getArtifactsToken, getUploadUrl } from './config.js'
|
||||
import { uploadArtifact } from './client.js'
|
||||
import { renderToolResultMessage } from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
file_path: z
|
||||
.string()
|
||||
.describe('Absolute path to a local HTML file to upload.'),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9_-]{1,128}$/, 'must match ^[A-Za-z0-9_-]{1,128}$')
|
||||
.optional()
|
||||
.describe(
|
||||
'If provided, overwrites the existing artifact with this hash (URL stays stable). If omitted, a new random id is generated.',
|
||||
),
|
||||
ttl: z
|
||||
.union([z.literal(7), z.literal(30)])
|
||||
.default(7)
|
||||
.describe('Lifetime in days. Must be 7 or 30. Default 7.'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
type ArtifactInput = z.infer<InputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
expiresAt: z.string(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
export type ArtifactOutput = z.infer<OutputSchema>
|
||||
|
||||
export const ArtifactTool = buildTool({
|
||||
name: ARTIFACT_TOOL_NAME,
|
||||
searchHint:
|
||||
'upload html artifact share url cloud publish progress report public link',
|
||||
maxResultSizeChars: 2_000,
|
||||
shouldDefer: true,
|
||||
strict: true,
|
||||
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
|
||||
async description() {
|
||||
return describeArtifactTool()
|
||||
},
|
||||
async prompt() {
|
||||
return getArtifactToolPrompt()
|
||||
},
|
||||
|
||||
isEnabled() {
|
||||
return true
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return false
|
||||
},
|
||||
isReadOnly() {
|
||||
return false
|
||||
},
|
||||
requiresUserInteraction() {
|
||||
return true
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Artifact'
|
||||
},
|
||||
|
||||
renderToolUseMessage(input: Partial<ArtifactInput>) {
|
||||
const hashPart = input.hash ? ` (hash=${input.hash})` : ''
|
||||
return `Upload artifact: ${input.file_path ?? '...'}${hashPart}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: ArtifactOutput,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam {
|
||||
if (content.error) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
is_error: true,
|
||||
content: content.error,
|
||||
}
|
||||
}
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
|
||||
}
|
||||
},
|
||||
renderToolResultMessage,
|
||||
|
||||
async call(input: ArtifactInput) {
|
||||
const { file_path, hash, ttl } = input
|
||||
|
||||
let size: number
|
||||
try {
|
||||
const fileStat = await stat(file_path)
|
||||
if (!fileStat.isFile()) {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `Path is not a regular file: ${file_path}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
size = fileStat.size
|
||||
} catch {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `File does not exist or is not readable: ${file_path}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (size > 10 * 1024 * 1024) {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `File is ${size} bytes; backend limit is 10MB.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let html: string
|
||||
try {
|
||||
html = await readFile(file_path, 'utf8')
|
||||
} catch {
|
||||
return {
|
||||
data: {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: `Failed to read file: ${file_path}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadArtifact({
|
||||
html,
|
||||
token: getArtifactsToken(),
|
||||
uploadUrl: getUploadUrl(),
|
||||
hash,
|
||||
ttl,
|
||||
})
|
||||
return { data: result }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { data: { id: '', url: '', expiresAt: '', error: message } }
|
||||
}
|
||||
},
|
||||
})
|
||||
37
packages/builtin-tools/src/tools/ArtifactTool/UI.tsx
Normal file
37
packages/builtin-tools/src/tools/ArtifactTool/UI.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Link, Text } from '@anthropic/ink';
|
||||
import type { ToolProgressData } from 'src/Tool.js';
|
||||
import type { ProgressMessage } from 'src/types/message.js';
|
||||
import type { ArtifactOutput } from './ArtifactTool.js';
|
||||
|
||||
export function renderToolResultMessage(
|
||||
content: ArtifactOutput,
|
||||
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
|
||||
_options: { verbose: boolean; theme?: string },
|
||||
): React.ReactNode {
|
||||
if (content.error) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="error">⚠ Artifact upload failed: {content.error}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (!content.url) return null;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color="success">↑</Text> Artifact uploaded:{' '}
|
||||
<Link url={content.url}>
|
||||
<Text color="warning">{content.url}</Text>
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
{content.expiresAt ? (
|
||||
<Box>
|
||||
<Text dimColor>expires: {content.expiresAt}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { ArtifactTool } from '../ArtifactTool.js'
|
||||
|
||||
const TEST_DIR = join(tmpdir(), 'artifact-tool-test')
|
||||
const TEST_FILE = join(TEST_DIR, 'report.html')
|
||||
const MISSING_FILE = join(TEST_DIR, 'does-not-exist.html')
|
||||
const DIR_AS_FILE = TEST_DIR
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
function mockFetchSuccess(body: object): typeof fetch {
|
||||
return mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('ArtifactTool.call', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
writeFileSync(TEST_FILE, '<h1>test report</h1>', 'utf8')
|
||||
process.env.CLAUDE_ARTIFACTS_TOKEN = 'test-token'
|
||||
process.env.CLAUDE_ARTIFACTS_URL = 'https://example.test'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_ARTIFACTS_TOKEN
|
||||
delete process.env.CLAUDE_ARTIFACTS_URL
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test('uploads existing HTML file and returns id/url/expiresAt', async () => {
|
||||
globalThis.fetch = mockFetchSuccess({
|
||||
id: 'abc123',
|
||||
url: 'https://example.test/7d/abc123.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 })
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
id: 'abc123',
|
||||
url: 'https://example.test/7d/abc123.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
})
|
||||
expect((result.data as { error?: string }).error).toBeUndefined()
|
||||
})
|
||||
|
||||
test('passes hash through when overwriting', async () => {
|
||||
const fetchMock = mockFetchSuccess({
|
||||
id: 'stable-id',
|
||||
url: 'https://example.test/7d/stable-id.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await ArtifactTool.call({ file_path: TEST_FILE, hash: 'stable-id', ttl: 7 })
|
||||
|
||||
const calledUrl = (
|
||||
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
|
||||
).mock.calls[0][0]
|
||||
expect(calledUrl.toString()).toContain('hash=stable-id')
|
||||
})
|
||||
|
||||
test('returns error when file does not exist (no HTTP call)', async () => {
|
||||
let fetchCalled = false
|
||||
globalThis.fetch = mock(() => {
|
||||
fetchCalled = true
|
||||
return Promise.resolve(new Response('{}'))
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const result = await ArtifactTool.call({ file_path: MISSING_FILE, ttl: 7 })
|
||||
|
||||
expect(fetchCalled).toBe(false)
|
||||
expect((result.data as { error?: string }).error).toContain(
|
||||
'does not exist',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns error when path is a directory', async () => {
|
||||
const result = await ArtifactTool.call({ file_path: DIR_AS_FILE, ttl: 7 })
|
||||
|
||||
expect((result.data as { error?: string }).error).toContain(
|
||||
'not a regular file',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns error verbatim when backend rejects', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ error: 'payload_too_large' }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// Force the size guard to pass by writing a small file but having backend complain.
|
||||
const result = await ArtifactTool.call({ file_path: TEST_FILE, ttl: 7 })
|
||||
|
||||
expect((result.data as { error?: string }).error).toContain(
|
||||
'payload_too_large',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import type { ProgressMessage } from 'src/types/message.js';
|
||||
import type { ToolProgressData } from 'src/Tool.js';
|
||||
import { renderToolResultMessage } from '../UI.js';
|
||||
import type { ArtifactOutput } from '../ArtifactTool.js';
|
||||
|
||||
const NO_PROGRESS: ProgressMessage<ToolProgressData>[] = [];
|
||||
const OPTIONS = { verbose: false, theme: 'dark' } as never;
|
||||
|
||||
/** Walk a React element tree and concatenate all string/number children. */
|
||||
function extractText(node: React.ReactNode): string {
|
||||
if (node == null || typeof node === 'boolean') return '';
|
||||
if (typeof node === 'string') return node;
|
||||
if (typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractText).join('');
|
||||
if (React.isValidElement(node)) {
|
||||
const children = (node.props as { children?: React.ReactNode }).children;
|
||||
return extractText(children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
describe('ArtifactTool UI.renderToolResultMessage', () => {
|
||||
test('renders the uploaded URL and expiry on success', () => {
|
||||
const content: ArtifactOutput = {
|
||||
id: 'abc123',
|
||||
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc123.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
};
|
||||
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||
expect(React.isValidElement(node)).toBe(true);
|
||||
const text = extractText(node);
|
||||
expect(text).toContain(content.url);
|
||||
expect(text).toContain(content.expiresAt);
|
||||
expect(text).toContain('Artifact uploaded');
|
||||
});
|
||||
|
||||
test('renders the error message on failure', () => {
|
||||
const content: ArtifactOutput = {
|
||||
id: '',
|
||||
url: '',
|
||||
expiresAt: '',
|
||||
error: 'File does not exist or is not readable: /tmp/missing.html',
|
||||
};
|
||||
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||
expect(React.isValidElement(node)).toBe(true);
|
||||
const text = extractText(node);
|
||||
expect(text).toContain('Artifact upload failed');
|
||||
expect(text).toContain('/tmp/missing.html');
|
||||
});
|
||||
|
||||
test('returns null when url is empty without error', () => {
|
||||
const content: ArtifactOutput = { id: '', url: '', expiresAt: '' };
|
||||
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||
expect(node).toBeNull();
|
||||
});
|
||||
|
||||
test('omits the expiry line when expiresAt is empty', () => {
|
||||
const content: ArtifactOutput = {
|
||||
id: 'abc',
|
||||
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc.html',
|
||||
expiresAt: '',
|
||||
};
|
||||
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
|
||||
expect(React.isValidElement(node)).toBe(true);
|
||||
// Sanity: still renders URL even without expiry
|
||||
expect(extractText(node)).toContain(content.url);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { uploadArtifact } from '../client.js'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
function mockFetch(body: object, status = 200): typeof fetch {
|
||||
return mock((_url: string | URL | Request, _init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('uploadArtifact', () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test('returns id/url/expiresAt on successful upload', async () => {
|
||||
globalThis.fetch = mockFetch({
|
||||
id: 'V1StGXR8_Z5jdHi6B',
|
||||
url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
})
|
||||
|
||||
const result = await uploadArtifact({
|
||||
html: '<h1>hello</h1>',
|
||||
token: 'test-token',
|
||||
uploadUrl: 'https://example.test/upload',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'V1StGXR8_Z5jdHi6B',
|
||||
url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
test('passes hash as query param when provided', async () => {
|
||||
const fetchMock = mockFetch({
|
||||
id: 'my-id',
|
||||
url: 'https://x/y.html',
|
||||
expiresAt: '2026-06-27T00:00:00.000Z',
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await uploadArtifact({
|
||||
html: '<p>x</p>',
|
||||
token: 't',
|
||||
uploadUrl: 'https://example.test/upload',
|
||||
hash: 'my-id',
|
||||
})
|
||||
|
||||
const calledUrl = (
|
||||
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
|
||||
).mock.calls[0][0]
|
||||
expect(calledUrl.toString()).toContain('hash=my-id')
|
||||
})
|
||||
|
||||
test('passes ttl=30 query param when provided', async () => {
|
||||
const fetchMock = mockFetch({
|
||||
id: 'x',
|
||||
url: 'https://x',
|
||||
expiresAt: '2026-07-20T00:00:00.000Z',
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await uploadArtifact({
|
||||
html: '<p>x</p>',
|
||||
token: 't',
|
||||
uploadUrl: 'https://example.test/upload',
|
||||
ttl: 30,
|
||||
})
|
||||
|
||||
const calledUrl = (
|
||||
fetchMock as unknown as { mock: { calls: [string | URL | Request][] } }
|
||||
).mock.calls[0][0]
|
||||
expect(calledUrl.toString()).toContain('ttl=30')
|
||||
})
|
||||
|
||||
test('throws with error code when body contains {error} (Deno Deploy flattens status)', async () => {
|
||||
globalThis.fetch = mockFetch({ error: 'payload_too_large' }, 200)
|
||||
|
||||
await expect(
|
||||
uploadArtifact({
|
||||
html: 'x'.repeat(100),
|
||||
token: 't',
|
||||
uploadUrl: 'https://example.test/upload',
|
||||
}),
|
||||
).rejects.toThrow(/payload_too_large/)
|
||||
})
|
||||
|
||||
test('throws on non-JSON body', async () => {
|
||||
globalThis.fetch = mock((_u: string | URL | Request) =>
|
||||
Promise.resolve(new Response('Internal Server Error', { status: 500 })),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
await expect(
|
||||
uploadArtifact({
|
||||
html: '<p/>',
|
||||
token: 't',
|
||||
uploadUrl: 'https://example.test/upload',
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
59
packages/builtin-tools/src/tools/ArtifactTool/client.ts
Normal file
59
packages/builtin-tools/src/tools/ArtifactTool/client.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type UploadResult = {
|
||||
id: string
|
||||
url: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export type UploadParams = {
|
||||
html: string
|
||||
token: string
|
||||
uploadUrl: string
|
||||
hash?: string
|
||||
ttl?: 7 | 30
|
||||
}
|
||||
|
||||
export async function uploadArtifact(
|
||||
params: UploadParams,
|
||||
): Promise<UploadResult> {
|
||||
const url = new URL(params.uploadUrl)
|
||||
if (params.hash) url.searchParams.set('hash', params.hash)
|
||||
if (params.ttl) url.searchParams.set('ttl', String(params.ttl))
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
'Content-Type': 'text/html',
|
||||
},
|
||||
body: params.html,
|
||||
})
|
||||
|
||||
// Deno Deploy proxy flattens upstream status to 200; the Worker embeds the
|
||||
// real error in the body as `{ "error": "<code>" }`. Always parse body first.
|
||||
const text = await response.text()
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Artifact upload failed: HTTP ${response.status} (non-JSON body)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
||||
const code = (parsed as { error: unknown }).error
|
||||
throw new Error(`Artifact upload failed: ${String(code)}`)
|
||||
}
|
||||
|
||||
const data = parsed as Partial<UploadResult>
|
||||
if (
|
||||
typeof data.id !== 'string' ||
|
||||
typeof data.url !== 'string' ||
|
||||
typeof data.expiresAt !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Artifact upload returned malformed body: ${text.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
return { id: data.id, url: data.url, expiresAt: data.expiresAt }
|
||||
}
|
||||
21
packages/builtin-tools/src/tools/ArtifactTool/config.ts
Normal file
21
packages/builtin-tools/src/tools/ArtifactTool/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Cloud Artifacts service configuration.
|
||||
* Token/URL have hardcoded production defaults; env vars override for self-hosted deployments.
|
||||
*/
|
||||
export const ARTIFACTS_DEFAULT_TOKEN = 'claude-code-best'
|
||||
export const ARTIFACTS_DEFAULT_URL =
|
||||
'https://cloud-artifacts.claude-code-best.win'
|
||||
|
||||
export function getArtifactsToken(): string {
|
||||
return process.env.CLAUDE_ARTIFACTS_TOKEN ?? ARTIFACTS_DEFAULT_TOKEN
|
||||
}
|
||||
|
||||
export function getArtifactsBaseUrl(): string {
|
||||
return process.env.CLAUDE_ARTIFACTS_URL ?? ARTIFACTS_DEFAULT_URL
|
||||
}
|
||||
|
||||
/** Strip trailing slash so `${base}/upload` is well-formed. */
|
||||
export function getUploadUrl(): string {
|
||||
const base = getArtifactsBaseUrl()
|
||||
return base.endsWith('/') ? `${base}upload` : `${base}/upload`
|
||||
}
|
||||
25
packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
Normal file
25
packages/builtin-tools/src/tools/ArtifactTool/prompt.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const ARTIFACT_TOOL_NAME = 'artifact'
|
||||
|
||||
export async function describeArtifactTool(): Promise<string> {
|
||||
return 'Upload an HTML file to the cloud-artifacts hosting service and get back a public URL. Pass `hash` to overwrite a previously-uploaded artifact (keeps URL stable).'
|
||||
}
|
||||
|
||||
export async function getArtifactToolPrompt(): Promise<string> {
|
||||
return `Upload an HTML file to a public hosting service and return a shareable URL plus an internal \`id\` (the "hash").
|
||||
|
||||
## Inputs
|
||||
- \`file_path\` (required): absolute path to a local HTML file.
|
||||
- \`hash\` (optional): if provided, overwrites the artifact with the same hash (URL stays the same). If omitted, a new random id is generated.
|
||||
- \`ttl\` (optional, default \`7\`): artifact lifetime in days. Must be \`7\` or \`30\`.
|
||||
|
||||
## Output
|
||||
\`{ id, url, expiresAt }\` — \`id\` is the hash (save it for future overwrite calls), \`url\` is publicly accessible.
|
||||
|
||||
## Workflow
|
||||
1. Use the Write tool to create a local HTML file.
|
||||
2. Call this tool with its \`file_path\`.
|
||||
3. If iterating on the same artifact, pass back the \`id\` returned from the first call as \`hash\` so the URL stays stable.
|
||||
|
||||
## Errors
|
||||
The tool surfaces backend error codes verbatim (e.g. \`payload_too_large\`, \`unauthorized\`). If the file does not exist or is not a regular file, the tool returns an \`error\` field without making an HTTP request.`
|
||||
}
|
||||
@@ -236,4 +236,29 @@ export const ExecuteTool = buildTool({
|
||||
content: JSON.stringify(content),
|
||||
}
|
||||
},
|
||||
// Output shape: { result: <inner tool output>, tool_name: string }.
|
||||
// Delegate rendering to the inner tool when it defines its own
|
||||
// renderToolResultMessage so deferred tools can show their own UI
|
||||
// (e.g. ArtifactTool displays its uploaded URL). Without this, the
|
||||
// ExecuteExtraTool tool_result row renders nothing below the tool_use
|
||||
// line. The inner tool expects its own input shape, so unwrap params.
|
||||
//
|
||||
// Inline the lookup rather than calling findToolByName — deferred tools
|
||||
// are matched by exact name (no aliases needed), and avoiding the
|
||||
// shared helper keeps this method resilient to src/Tool.js mocks in
|
||||
// co-located test files (process-global mock.module pollution).
|
||||
renderToolResultMessage(content, progressMessages, options) {
|
||||
const innerTool = options.tools.find(t => t.name === content.tool_name)
|
||||
if (!innerTool?.renderToolResultMessage) return null
|
||||
const innerInput = (options.input as { params?: unknown } | undefined)
|
||||
?.params
|
||||
return innerTool.renderToolResultMessage(
|
||||
content.result as never,
|
||||
progressMessages,
|
||||
{
|
||||
...options,
|
||||
input: innerInput,
|
||||
},
|
||||
)
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||
|
||||
// Same mock setup as ExecuteTool.runner.ts — ExecuteTool's import chain
|
||||
// (growthbook, searchExtraTools, messages) loads real modules with side
|
||||
// effects otherwise. mock.module is process-global; identical setup in
|
||||
// sibling test files in this directory is safe (last-write-wins, same stubs).
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set<string>(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/tools.js', () => ({
|
||||
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/messages.js', () => ({
|
||||
createUserMessage: ({ content }: { content: string }) => ({
|
||||
type: 'user' as const,
|
||||
content,
|
||||
uuid: 'test-uuid',
|
||||
}),
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE: '[Request interrupted]',
|
||||
}))
|
||||
|
||||
mock.module('src/utils/toolErrors.js', () => ({
|
||||
formatZodValidationError: (_name: string, error: unknown) =>
|
||||
`validation error: ${JSON.stringify(error)}`,
|
||||
}))
|
||||
|
||||
const { ExecuteTool } = await import('../ExecuteTool.js')
|
||||
|
||||
type RenderResult = React.ReactNode
|
||||
|
||||
describe('ExecuteTool.renderToolResultMessage delegation', () => {
|
||||
test('delegates to inner tool with content.result and unwrapped params', () => {
|
||||
const seen: Array<{
|
||||
content: unknown
|
||||
input: unknown
|
||||
}> = []
|
||||
const innerRender = (
|
||||
content: unknown,
|
||||
_progress: unknown,
|
||||
options: { input?: unknown },
|
||||
): RenderResult => {
|
||||
seen.push({ content, input: options.input })
|
||||
return 'RENDERED' as unknown as RenderResult
|
||||
}
|
||||
const innerTool = {
|
||||
name: 'artifact',
|
||||
renderToolResultMessage: innerRender,
|
||||
}
|
||||
const tools = [innerTool] as never
|
||||
|
||||
const result = ExecuteTool.renderToolResultMessage(
|
||||
{
|
||||
result: {
|
||||
id: 'abc',
|
||||
url: 'https://example.com/x.html',
|
||||
expiresAt: 'T',
|
||||
},
|
||||
tool_name: 'artifact',
|
||||
},
|
||||
[],
|
||||
{
|
||||
tools,
|
||||
input: {
|
||||
tool_name: 'artifact',
|
||||
params: { file_path: '/tmp/x.html', ttl: 7 },
|
||||
},
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result).toBe('RENDERED')
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.content).toEqual({
|
||||
id: 'abc',
|
||||
url: 'https://example.com/x.html',
|
||||
expiresAt: 'T',
|
||||
})
|
||||
// Inner tool should see its own params shape, not the ExecuteExtraTool wrapper
|
||||
expect(seen[0]?.input).toEqual({ file_path: '/tmp/x.html', ttl: 7 })
|
||||
})
|
||||
|
||||
test('returns null when inner tool has no renderToolResultMessage', () => {
|
||||
const innerTool = { name: 'bare' }
|
||||
const tools = [innerTool] as never
|
||||
|
||||
const result = ExecuteTool.renderToolResultMessage(
|
||||
{ result: { ok: true }, tool_name: 'bare' },
|
||||
[],
|
||||
{ tools, input: { tool_name: 'bare', params: {} } } as never,
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when inner tool is not found in tools list', () => {
|
||||
const tools = [] as never
|
||||
|
||||
const result = ExecuteTool.renderToolResultMessage(
|
||||
{ result: { ok: true }, tool_name: 'missing' },
|
||||
[],
|
||||
{ tools, input: { tool_name: 'missing', params: {} } } as never,
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('passes through undefined input safely when input is missing', () => {
|
||||
const seen: unknown[] = []
|
||||
const innerTool = {
|
||||
name: 'artifact',
|
||||
renderToolResultMessage: (
|
||||
_content: unknown,
|
||||
_progress: unknown,
|
||||
options: { input?: unknown },
|
||||
): RenderResult => {
|
||||
seen.push(options.input)
|
||||
return null
|
||||
},
|
||||
}
|
||||
const tools = [innerTool] as never
|
||||
|
||||
const result = ExecuteTool.renderToolResultMessage(
|
||||
{ result: { ok: true }, tool_name: 'artifact' },
|
||||
[],
|
||||
{ tools } as never,
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(seen[0]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
1
packages/cloud-artifacts/.dev.vars.example
Normal file
1
packages/cloud-artifacts/.dev.vars.example
Normal file
@@ -0,0 +1 @@
|
||||
TOKEN=replace-with-your-bearer-token
|
||||
171
packages/cloud-artifacts/.gitignore
vendored
Normal file
171
packages/cloud-artifacts/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
.wrangler/
|
||||
|
||||
# wrangler types 生成物(每次 wrangler types / dev / deploy 后会刷新,含 Cloudflare 运行时类型,体积大、会触发 biome lint)
|
||||
worker-configuration.d.ts
|
||||
|
||||
202
packages/cloud-artifacts/README.md
Normal file
202
packages/cloud-artifacts/README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# cloud-artifacts
|
||||
|
||||
> **生产出口**:`https://cloud-artifacts.claude-code-best.win`
|
||||
>
|
||||
> 服务端(CLI / RCS 后台)通过单一 bearer token 上传 HTML,得到一个公开可访问的 URL。
|
||||
> 文件到期由 R2 lifecycle rule 自动删除(默认 7 天,最长 30 天)。
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# 上传一份 html(默认随机 ID + 7 天 TTL)
|
||||
echo '<h1>hello</h1>' > /tmp/t.html
|
||||
curl -X POST "https://cloud-artifacts.claude-code-best.win/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/t.html
|
||||
# {"id":"V1StGXR8_Z5jdHi6B-myT",
|
||||
# "url":"https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html",
|
||||
# "expiresAt":"2026-06-27T10:00:00.000Z"}
|
||||
|
||||
# 任何人拿到 url 都能访问
|
||||
curl "https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html"
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
客户端 --POST /upload----▶│ Deno Deploy 边缘代理 │
|
||||
│ cloud-artifacts.ccb.win │
|
||||
└────────────┬─────────────┘
|
||||
│ 透传
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Cloudflare Worker │
|
||||
│ - 鉴权 + MIME + 大小校验 │
|
||||
│ - ttl∈{7,30} + hash 校验 │
|
||||
│ - R2 put / R2 get │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ R2 bucket │
|
||||
│ key: <7d|30d>/<id>.html │
|
||||
│ lifecycle: │
|
||||
│ 7d/ -> expire 7 days │
|
||||
│ 30d/ -> expire 30 days │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
- **POST /upload**:Bearer 鉴权 → text/html 校验 → 10MB 上限 → ttl ∈ {7,30} → R2 put
|
||||
- **GET /<7d\|30d>/<id>.html**:Worker 从 R2 读 → 返回 `text/html; charset=utf-8` + `Cache-Control: public, max-age=86400`
|
||||
- **TTL**:R2 prefix + lifecycle rule 实现,Worker 不参与过期处理(零额外代码)
|
||||
- **覆盖**:指定 `?hash=` 时,先删 `7d/<hash>.html` 和 `30d/<hash>.html` 旧 key,再写新 key
|
||||
- **ID**:默认 `nanoid(21)`(126 bit 熵),可指定 `?hash=<custom-id>`
|
||||
|
||||
## 为什么套一层 Deno Deploy
|
||||
|
||||
国内直连 Cloudflare Workers 边缘节点延迟高、丢包严重(DNS 污染 + 路由问题)。在 `cloud-artifacts.claude-code-best.win` 上套 Deno Deploy 边缘代理后:
|
||||
|
||||
- 国内访问延迟显著降低(Deno Deploy 在国内可达性好)
|
||||
- POST/GET body 完整透传
|
||||
- **副作用**:Deno Deploy 代理会把上游 HTTP status code 抹平为 200(但 body 内的 `{error: ...}` 字段完整保留)。客户端若依赖 status code 判断错误类型,应改为解析 body 中的 `error` 字段。直连 Worker 自身(如 `*.workers.dev`)时 status code 正常透传。
|
||||
|
||||
## API
|
||||
|
||||
### `POST /upload`
|
||||
|
||||
| Header / Query | 必填 | 说明 |
|
||||
|----------------|------|------|
|
||||
| `Authorization: Bearer <TOKEN>` | 是 | 与 Worker secret `TOKEN` 完全相等 |
|
||||
| `Content-Type: text/html` | 是 | 不接受其他类型 |
|
||||
| `?ttl=7\|30` | 否 | 默认 7,**只允许 7 或 30**(与 R2 lifecycle prefix 对应) |
|
||||
| `?hash=<custom-id>` | 否 | 自定义 ID,校验 `^[A-Za-z0-9_-]{1,128}$`;指定时覆盖同 ID 旧版本 |
|
||||
| body | 是 | 原始 HTML(`--data-binary @file.html`),≤10MB |
|
||||
|
||||
成功 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "V1StGXR8_Z5jdHi6B-myT",
|
||||
"url": "https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html",
|
||||
"expiresAt": "2026-06-27T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
错误(统一 `{ "error": "<code>" }`,状态码见下):
|
||||
|
||||
| 状态码(直连) | error code | 触发条件 |
|
||||
|--------|------------|----------|
|
||||
| 400 | `invalid_ttl` | `ttl` 非 7 或 30 |
|
||||
| 400 | `invalid_hash` | `hash` 不匹配 `^[A-Za-z0-9_-]{1,128}$` |
|
||||
| 401 | `unauthorized` | 缺 Authorization / token 不匹配 |
|
||||
| 404 | `not_found` | 非 `/upload` 路径或 GET 路径不匹配 `/<7d\|30d>/<id>.html` |
|
||||
| 413 | `payload_too_large` | body > 10MB |
|
||||
| 415 | `unsupported_media_type` | Content-Type 非 `text/html` |
|
||||
|
||||
> **经 Deno Deploy 代理时**:以上所有错误状态码统一返回 **200**,但 body 仍是上表中的 `{error: ...}` JSON。客户端解析逻辑应以 body 的 `error` 字段为准。
|
||||
|
||||
### `GET /<ttl-prefix>/<id>.html`
|
||||
|
||||
`ttl-prefix` 只能是 `7d` 或 `30d`(其他路径返回 404/not_found)。返回 `text/html; charset=utf-8` + `Cache-Control: public, max-age=86400`。任何人拿到 URL 都可访问,hash 即秘密。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 默认随机 ID + 7 天
|
||||
curl -X POST "https://cloud-artifacts.claude-code-best.win/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/t.html
|
||||
|
||||
# 自定义 hash + 30 天(再次上传同 hash 覆盖)
|
||||
curl -X POST "https://cloud-artifacts.claude-code-best.win/upload?ttl=30&hash=my-report" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/report.html
|
||||
|
||||
# 访问
|
||||
curl "https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B-myT.html"
|
||||
```
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
指定 `?hash=` 时:
|
||||
|
||||
1. 校验 hash 字符集(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
2. 删除 `7d/<hash>.html` 和 `30d/<hash>.html` 两个 key(R2 delete 不存在的 key 不报错,零成本)
|
||||
3. 按 `?ttl=` 写入新 key
|
||||
4. 返回新的 `expiresAt`
|
||||
|
||||
不指定 `?hash=` 时:用 `nanoid(21)` 随机 ID,几乎不可能碰撞,不做碰撞检查。
|
||||
|
||||
## 部署
|
||||
|
||||
前置:本机已 `npx wrangler login` 登录目标 Cloudflare 账号。Deno Deploy 代理层由部署者另配(CNAME `cloud-artifacts.<your-domain>` → `alias.deno.net`,并在 Deno Deploy 项目里把上游设为 `https://<worker>.<account>.workers.dev`)。
|
||||
|
||||
```bash
|
||||
cd packages/cloud-artifacts
|
||||
bun install # 在 monorepo 根执行也行(workspace 自动识别)
|
||||
|
||||
cp .dev.vars.example .dev.vars # 填本地 dev 用的 TOKEN(仅 wrangler dev 读)
|
||||
bun run setup # 创建 bucket + 加 lifecycle rule + 设生产 TOKEN secret
|
||||
|
||||
# 绑 Worker custom domain(如要在 Cloudflare 直连域名上访问):
|
||||
# Dashboard: Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
|
||||
|
||||
# 改 wrangler.toml 中 [vars] PUBLIC_URL 为对外出口域名(生产用 https://cloud-artifacts.claude-code-best.win)
|
||||
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
`scripts/test.sh` 覆盖 7 个错误用例 + 3 个成功用例 + R2 写入验证。**支持双模式**:直连 Worker 时按 HTTP status code 断言;经 Deno Deploy 代理(status 抹平为 200)时自动按 body 的 `error` 字段断言(标记 `[via body]`)。
|
||||
|
||||
```bash
|
||||
WORKER_URL=https://cloud-artifacts.claude-code-best.win \
|
||||
TOKEN=<your-token> \
|
||||
bash scripts/test.sh
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cp .dev.vars.example .dev.vars
|
||||
# 编辑 .dev.vars 填 TOKEN
|
||||
|
||||
bun run dev # wrangler dev,启动本地 Miniflare + 本地 R2 模拟
|
||||
curl -X POST "http://localhost:8787/upload" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: text/html" \
|
||||
--data-binary @/tmp/t.html
|
||||
```
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
- **TOKEN 是上传侧唯一鉴权**:值泄露后任何人可上传/覆盖。生产应使用 ≥32 字符的随机串,定期轮换(`wrangler secret put TOKEN` 即时生效,无需 redeploy)。
|
||||
- **GET 完全公开**:URL 形如 `/<ttl>/<id>.html`,hash(21 字符 nanoId)即唯一秘密。不要把 URL 贴到公开频道再期望它"私密"。
|
||||
- **覆盖即写**:知道 hash 的任何持 token 者都能覆盖该 ID 的内容。若需要"创建后不可改"语义,应在客户端自行约束(不传 `?hash=`)。
|
||||
- **不校验 HTML 内容**:上传的 html 会被原样返回,浏览器渲染时会执行其中的 `<script>`。本服务定位是"托管自己产出的 html",不要作为任意用户上传入口。
|
||||
- **TTL 上限 30 天**:lifecycle rule 是 prefix 级全局规则,所有对象最多保留 30 天,无法延长。
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| 现象 | 原因 / 处理 |
|
||||
|------|-------------|
|
||||
| 所有请求返 HTTP 200 但业务出错 | 经 Deno Deploy 代理时正常现象,看 body 的 `error` 字段判断真实状态 |
|
||||
| `curl` 到 `*.workers.dev` 超时 | 国内 DNS 污染 + 路由问题,走 `cloud-artifacts.claude-code-best.win` 出口或挂代理 |
|
||||
| 响应 html 多一段 `<a href="/cdn-cgi/content...">` 和 `<script>` | Cloudflare 默认注入的 Browser Insights(RUM),不影响内容渲染。要纯净响应:dashboard → Workers & Pages → cloud-artifacts → 关 Web Analytics |
|
||||
| 上传 413 但文件不到 10MB | 检查 `Content-Length` header 是否被中间层改写;Worker 同时按 `Content-Length` 和 `arrayBuffer().byteLength` 双重校验 |
|
||||
| `?ttl=14` 返 400 | 设计如此,只允许 7 或 30(对应 R2 lifecycle prefix) |
|
||||
| `wrangler secret list` 看到 TOKEN 但上传 401 | token 值不一致。重新 `wrangler secret put TOKEN` 设正确值 |
|
||||
|
||||
## 依赖
|
||||
|
||||
- `wrangler` ^4 — Cloudflare Workers CLI
|
||||
- `nanoid` ^5 — ID 生成(纯 ESM,Worker 兼容)
|
||||
|
||||
## 不被主 CLI 引用
|
||||
|
||||
这是独立 Cloudflare Worker 服务,类似 `packages/remote-control-server/` 的定位。Monorepo 根 `package.json` 的 `workspaces: ["packages/*", ...]` 自动识别本包,但主 CLI 不会 import 它。
|
||||
19
packages/cloud-artifacts/package.json
Normal file
19
packages/cloud-artifacts/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cloud-artifacts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Cloudflare Worker + R2 HTML artifact host (POST /upload → hash URL)",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"setup": "bash scripts/setup.sh",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
30
packages/cloud-artifacts/scripts/setup.sh
Executable file
30
packages/cloud-artifacts/scripts/setup.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BUCKET="${BUCKET:-cloud-artifacts}"
|
||||
|
||||
echo "==> Creating R2 bucket: $BUCKET"
|
||||
npx wrangler r2 bucket create "$BUCKET" || echo "(already exists or creation deferred)"
|
||||
|
||||
echo "==> Adding lifecycle rule: prefix '7d/' -> expire after 7 days"
|
||||
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-7d "7d/" --expire-days 7 --force
|
||||
|
||||
echo "==> Adding lifecycle rule: prefix '30d/' -> expire after 30 days"
|
||||
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-30d "30d/" --expire-days 30 --force
|
||||
|
||||
echo "==> Setting secret TOKEN (paste value, then Enter)"
|
||||
npx wrangler secret put TOKEN
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
==> Done. Remaining manual steps:
|
||||
|
||||
1. Bind a custom domain to the Worker (POST + GET 都走 Worker,单一域名):
|
||||
Dashboard: Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
|
||||
填入你的 domain(如 artifacts.example.com),Cloudflare 会自动加 DNS 记录和 SSL。
|
||||
|
||||
2. Update wrangler.toml [vars] PUBLIC_URL 为上一步的 domain(带 https://,如 https://artifacts.example.com)。
|
||||
|
||||
3. Deploy:
|
||||
bun run deploy
|
||||
NEXT
|
||||
162
packages/cloud-artifacts/scripts/test.sh
Executable file
162
packages/cloud-artifacts/scripts/test.sh
Executable file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env bash
|
||||
# cloud-artifacts 端到端测试脚本
|
||||
# 用法:
|
||||
# WORKER_URL=https://cloud-artifacts.claude-code-best.workers.dev \
|
||||
# TOKEN=claude-code-best \
|
||||
# bash scripts/test.sh
|
||||
#
|
||||
# 如本机连不上 workers.dev,可通过代理:
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890 bash scripts/test.sh ...
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
WORKER_URL="${WORKER_URL:-https://cloud-artifacts.claude-code-best.win}"
|
||||
TOKEN="${TOKEN:-claude-code-best}"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# 颜色
|
||||
G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[0m'
|
||||
|
||||
# 准备测试 html
|
||||
echo '<!doctype html><title>t</title><h1>hello v1</h1>' > "$TMP/v1.html"
|
||||
echo '<!doctype html><title>t</title><h1>hello v2 (overwritten)</h1>' > "$TMP/v2.html"
|
||||
|
||||
# 11MB 的 html(用于 413 测试)
|
||||
yes '<p>x</p>' | head -c 11000000 > "$TMP/big.html"
|
||||
|
||||
pass=0; fail=0
|
||||
# expect: 主断言 status code;如代理把所有 status 抹平为 200 但 body 仍是 error JSON,
|
||||
# 则按 body 中的 error 字段做 fallback 断言(标 [via body])。
|
||||
expect() {
|
||||
local label="$1" want_code="$2" resp="$3" code="$4" body="$5"
|
||||
if [[ "$code" == "$want_code" ]]; then
|
||||
printf "${G}✓ %s -> HTTP %s${D}\n" "$label" "$code"
|
||||
[[ -n "$resp" ]] && printf " body: %s\n" "$body"
|
||||
pass=$((pass+1))
|
||||
return
|
||||
fi
|
||||
# 代理透传 fallback:HTTP 200 + body 是 {"error":"..."} JSON
|
||||
if [[ "$code" == "200" && "$body" == {\"error\":* ]]; then
|
||||
local want_error=""
|
||||
case "$want_code" in
|
||||
401) want_error="unauthorized" ;;
|
||||
415) want_error="unsupported_media_type" ;;
|
||||
413) want_error="payload_too_large" ;;
|
||||
404) want_error="not_found" ;;
|
||||
400) want_error="invalid_" ;; # invalid_ttl 或 invalid_hash,前缀匹配
|
||||
esac
|
||||
if [[ -z "$want_error" ]] || echo "$body" | grep -q "\"error\":\"$want_error"; then
|
||||
printf "${G}✓ %s -> HTTP 200 [via body] %s${D}\n" "$label" "$body"
|
||||
pass=$((pass+1))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
printf "${R}✗ %s -> HTTP %s (expected %s)${D}\n" "$label" "$code" "$want_code"
|
||||
printf " body: %s\n" "$body"
|
||||
fail=$((fail+1))
|
||||
}
|
||||
|
||||
call() {
|
||||
local label="$1" want="$2"
|
||||
shift 2
|
||||
curl -sS -o "$TMP/resp" -w "%{http_code}" "$@" > "$TMP/code"
|
||||
expect "$label" "$want" "" "$(cat "$TMP/code")" "$(cat "$TMP/resp")"
|
||||
}
|
||||
|
||||
echo "===== 错误用例 ====="
|
||||
|
||||
# 1. 401 未授权
|
||||
call "no token" 401 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 2. 401 token 错
|
||||
call "wrong token" 401 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer wrong" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 3. 415 错误 MIME
|
||||
call "wrong content-type" 415 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" --data-binary '{"x":1}'
|
||||
|
||||
# 4. 400 invalid_ttl
|
||||
call "ttl=999" 400 \
|
||||
-X POST "$WORKER_URL/upload?ttl=999" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 5. 400 invalid_ttl (负数)
|
||||
call "ttl=0" 400 \
|
||||
-X POST "$WORKER_URL/upload?ttl=0" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 6. 400 invalid_hash
|
||||
call "hash=bad/slash" 400 \
|
||||
-X POST "$WORKER_URL/upload?hash=bad/slash" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
# 7. 413 payload_too_large (11MB > 10MB)
|
||||
call "11MB body" 413 \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/big.html"
|
||||
|
||||
# 8. 404 not_found (错路径)
|
||||
call "wrong path" 404 \
|
||||
-X POST "$WORKER_URL/notupload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
|
||||
echo
|
||||
echo "===== 成功用例 ====="
|
||||
|
||||
# 9. 200 随机 ID + 7 天(默认)
|
||||
echo "--- 默认上传(随机 ID + 7 天)---"
|
||||
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||
-X POST "$WORKER_URL/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
cat "$TMP/resp"; echo
|
||||
RANDOM_ID=$(python3 -c "import json,sys;print(json.load(open('$TMP/resp'))['id'])" 2>/dev/null || echo "")
|
||||
[[ -n "$RANDOM_ID" ]] && printf "${G}随机 ID: %s${D}\n" "$RANDOM_ID"
|
||||
|
||||
# 10. 200 自定义 hash + 30 天
|
||||
echo "--- 自定义 hash + 30 天 ---"
|
||||
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
|
||||
cat "$TMP/resp"; echo
|
||||
|
||||
# 11. 覆盖(同 hash)
|
||||
echo "--- 覆盖:同 hash 上传 v2 ---"
|
||||
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
|
||||
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: text/html" --data-binary "@$TMP/v2.html"
|
||||
cat "$TMP/resp"; echo
|
||||
|
||||
echo
|
||||
echo "===== R2 写入验证(不走 CDN,走 Cloudflare API) ====="
|
||||
|
||||
# 用 wrangler r2 object get 验证文件实际写入了 R2
|
||||
if [[ -n "$RANDOM_ID" ]]; then
|
||||
echo "--- 验证随机 ID 文件存在: 7d/$RANDOM_ID.html ---"
|
||||
npx wrangler r2 object get "cloud-artifacts/7d/$RANDOM_ID.html" --remote --file "$TMP/got.html" 2>&1 | tail -5
|
||||
echo "下载内容:" ; cat "$TMP/got.html" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "--- 验证覆盖后 test-artifact-v1 是 v2 内容 ---"
|
||||
npx wrangler r2 object get "cloud-artifacts/30d/test-artifact-v1.html" --remote --file "$TMP/got2.html" 2>&1 | tail -5
|
||||
echo "下载内容:" ; cat "$TMP/got2.html" 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "===== 汇总 ====="
|
||||
printf "${G}pass=%d${D} ${R}fail=%d${D}\n" "$pass" "$fail"
|
||||
[[ "$fail" -gt 0 ]] && exit 1 || exit 0
|
||||
119
packages/cloud-artifacts/src/index.ts
Normal file
119
packages/cloud-artifacts/src/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
// TOKEN 通过 `wrangler secret put TOKEN` 注入,wrangler types 不为 secret 生成类型
|
||||
// 所以这里显式扩展全局 Env(与 worker-configuration.d.ts 合并)
|
||||
declare global {
|
||||
interface Env {
|
||||
TOKEN: string
|
||||
}
|
||||
}
|
||||
|
||||
const HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
||||
const TTL_PREFIXES = ['7d', '30d']
|
||||
const ALLOWED_TTLS = [7, 30]
|
||||
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
|
||||
// GET /<prefix>/<id>.html —— prefix 与 lifecycle rule 对应,限制只能是 7d 或 30d
|
||||
const GET_PATH_PATTERN = /^\/(7d|30d)\/([A-Za-z0-9_-]{1,128})\.html$/
|
||||
|
||||
export default {
|
||||
async fetch(req: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return handleGet(url, env)
|
||||
}
|
||||
if (url.pathname === '/upload' && req.method === 'POST') {
|
||||
return handleUpload(req, env, url)
|
||||
}
|
||||
return json({ error: 'not_found' }, 404)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
// GET /7d/<id>.html 或 /30d/<id>.html —— 从 R2 读,返回 text/html
|
||||
async function handleGet(url: URL, env: Env): Promise<Response> {
|
||||
const match = GET_PATH_PATTERN.exec(url.pathname)
|
||||
if (!match) {
|
||||
return json({ error: 'not_found' }, 404)
|
||||
}
|
||||
const [, prefix, id] = match
|
||||
const obj = await env.BUCKET.get(`${prefix}/${id}.html`)
|
||||
if (obj === null) {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
const headers = new Headers()
|
||||
obj.writeHttpMetadata(headers)
|
||||
headers.set('content-type', HTML_CONTENT_TYPE)
|
||||
headers.set('cache-control', 'public, max-age=86400')
|
||||
return new Response(obj.body, { headers, status: 200 })
|
||||
}
|
||||
|
||||
async function handleUpload(
|
||||
req: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
const auth = req.headers.get('authorization') ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||
if (!env.TOKEN || !token || token !== env.TOKEN) {
|
||||
return json({ error: 'unauthorized' }, 401)
|
||||
}
|
||||
|
||||
const contentType = (req.headers.get('content-type') ?? '').toLowerCase()
|
||||
if (!contentType.startsWith('text/html')) {
|
||||
return json({ error: 'unsupported_media_type' }, 415)
|
||||
}
|
||||
|
||||
const maxBytes = Number.parseInt(env.MAX_BYTES, 10) || 10 * 1024 * 1024
|
||||
const declaredLength = Number.parseInt(
|
||||
req.headers.get('content-length') ?? '',
|
||||
10,
|
||||
)
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
return json({ error: 'payload_too_large' }, 413)
|
||||
}
|
||||
|
||||
const defaultTtl = Number.parseInt(env.DEFAULT_TTL_DAYS, 10) || 7
|
||||
const ttlParam = url.searchParams.get('ttl')
|
||||
const ttl = ttlParam === null ? defaultTtl : Number.parseInt(ttlParam, 10)
|
||||
if (!Number.isFinite(ttl) || !ALLOWED_TTLS.includes(ttl)) {
|
||||
return json({ error: 'invalid_ttl' }, 400)
|
||||
}
|
||||
|
||||
const hashParam = url.searchParams.get('hash')
|
||||
let id: string
|
||||
if (hashParam !== null) {
|
||||
if (!HASH_PATTERN.test(hashParam)) {
|
||||
return json({ error: 'invalid_hash' }, 400)
|
||||
}
|
||||
id = hashParam
|
||||
// 覆盖:先删所有 ttl prefix 下可能的旧 key(R2 delete 不存在的 key 不报错)
|
||||
await Promise.all(
|
||||
TTL_PREFIXES.map(p => env.BUCKET.delete(`${p}/${id}.html`)),
|
||||
)
|
||||
} else {
|
||||
id = nanoid(21)
|
||||
}
|
||||
|
||||
const body = await req.arrayBuffer()
|
||||
if (body.byteLength > maxBytes) {
|
||||
return json({ error: 'payload_too_large' }, 413)
|
||||
}
|
||||
|
||||
const key = `${ttl}d/${id}.html`
|
||||
await env.BUCKET.put(key, body, {
|
||||
httpMetadata: { contentType: HTML_CONTENT_TYPE },
|
||||
})
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 24 * 60 * 60 * 1000)
|
||||
return json(
|
||||
{ id, url: `${env.PUBLIC_URL}/${key}`, expiresAt: expiresAt.toISOString() },
|
||||
200,
|
||||
)
|
||||
}
|
||||
|
||||
function json(body: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
104
packages/cloud-artifacts/src/types.d.ts
vendored
Normal file
104
packages/cloud-artifacts/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Minimal Cloudflare Workers type stubs for cloud-artifacts Worker.
|
||||
*
|
||||
* The canonical types are in worker-configuration.d.ts (generated by `wrangler types`,
|
||||
* gitignored). This file provides just enough so `tsc --noEmit` passes even when that
|
||||
* generated file is absent (e.g. CI, fresh clone).
|
||||
*/
|
||||
|
||||
// -- R2 types ---------------------------------------------------------------
|
||||
|
||||
interface R2Checksums {
|
||||
readonly md5?: ArrayBuffer
|
||||
readonly sha1?: ArrayBuffer
|
||||
readonly sha256?: ArrayBuffer
|
||||
readonly sha384?: ArrayBuffer
|
||||
readonly sha512?: ArrayBuffer
|
||||
}
|
||||
|
||||
interface R2HTTPMetadata {
|
||||
contentType?: string
|
||||
contentLanguage?: string
|
||||
contentDisposition?: string
|
||||
contentEncoding?: string
|
||||
cacheControl?: string
|
||||
cacheExpiry?: Date
|
||||
}
|
||||
|
||||
interface R2Range {
|
||||
offset: number
|
||||
length?: number
|
||||
}
|
||||
|
||||
declare abstract class R2Object {
|
||||
readonly key: string
|
||||
readonly version: string
|
||||
readonly size: number
|
||||
readonly etag: string
|
||||
readonly httpEtag: string
|
||||
readonly checksums: R2Checksums
|
||||
readonly uploaded: Date
|
||||
readonly httpMetadata?: R2HTTPMetadata
|
||||
readonly customMetadata?: Record<string, string>
|
||||
readonly range?: R2Range
|
||||
readonly storageClass: string
|
||||
readonly ssecKeyMd5?: string
|
||||
writeHttpMetadata(headers: Headers): void
|
||||
}
|
||||
|
||||
interface R2ObjectBody extends R2Object {
|
||||
get body(): ReadableStream
|
||||
get bodyUsed(): boolean
|
||||
}
|
||||
|
||||
interface R2PutOptions {
|
||||
httpMetadata?: R2HTTPMetadata | Headers
|
||||
customMetadata?: Record<string, string>
|
||||
}
|
||||
|
||||
interface R2Bucket {
|
||||
head(key: string): Promise<R2Object | null>
|
||||
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>
|
||||
put(
|
||||
key: string,
|
||||
value:
|
||||
| ReadableStream
|
||||
| ArrayBuffer
|
||||
| ArrayBufferView
|
||||
| string
|
||||
| null
|
||||
| Blob,
|
||||
options?: R2PutOptions,
|
||||
): Promise<R2Object>
|
||||
delete(keys: string | string[]): Promise<void>
|
||||
}
|
||||
|
||||
// Empty placeholder — R2GetOptions is unused beyond an optional parameter
|
||||
type R2GetOptions = {}
|
||||
|
||||
// -- ExportedHandler -------------------------------------------------------
|
||||
|
||||
interface ExportedHandler<Env = unknown> {
|
||||
fetch?: (
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
) => Response | Promise<Response>
|
||||
}
|
||||
|
||||
// -- Env -------------------------------------------------------------------
|
||||
// Wrangler-generated worker-configuration.d.ts supplies TOKEN via `wrangler secret put`.
|
||||
// This declaration provides the R2 binding + wrangler vars so the Worker compiles
|
||||
// without the generated file.
|
||||
//
|
||||
// NOTE: 这个文件是脚本(没有 top-level import/export),顶层 interface 自动是 global
|
||||
// ambient,会和 worker-configuration.d.ts 的 `interface Env` 走 interface declaration
|
||||
// merging。不要用 `declare global { ... }` 包裹——脚本文件里那种写法是 TS2669 错误,
|
||||
// 在 .d.ts 里甚至会被静默吞掉,导致 Env 桩完全不生效(CI 上就是这种情况)。
|
||||
interface Env {
|
||||
BUCKET: R2Bucket
|
||||
TOKEN: string
|
||||
MAX_BYTES: string
|
||||
DEFAULT_TTL_DAYS: string
|
||||
PUBLIC_URL: string
|
||||
}
|
||||
17
packages/cloud-artifacts/tsconfig.json
Normal file
17
packages/cloud-artifacts/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||
}
|
||||
16
packages/cloud-artifacts/wrangler.toml
Normal file
16
packages/cloud-artifacts/wrangler.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
name = "cloud-artifacts"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-06-20"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "BUCKET"
|
||||
bucket_name = "cloud-artifacts"
|
||||
|
||||
[vars]
|
||||
PUBLIC_URL = "https://cloud-artifacts.claude-code-best.win"
|
||||
DEFAULT_TTL_DAYS = "7"
|
||||
MAX_TTL_DAYS = "30"
|
||||
MAX_BYTES = "10485760"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'
|
||||
|
||||
// res.json() returns Promise<unknown> in strict mode; this helper narrows to any for test assertions
|
||||
function resJson(res: Response) {
|
||||
return res.json() as Promise<any>
|
||||
}
|
||||
|
||||
// Mock config before imports
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
@@ -87,7 +92,7 @@ describe('Auth Middleware', () => {
|
||||
},
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.username).toBe('alice')
|
||||
})
|
||||
|
||||
@@ -96,7 +101,7 @@ describe('Auth Middleware', () => {
|
||||
headers: { Authorization: 'Bearer test-api-key' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.username).toBe('bob')
|
||||
})
|
||||
|
||||
@@ -107,7 +112,7 @@ describe('Auth Middleware', () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.username).toBe('charlie')
|
||||
})
|
||||
|
||||
@@ -162,7 +167,7 @@ describe('Auth Middleware', () => {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.jwtPayload).not.toBeNull()
|
||||
expect(body.jwtPayload.session_id).toBe('ses_123')
|
||||
})
|
||||
@@ -191,7 +196,7 @@ describe('Auth Middleware', () => {
|
||||
describe('extractWebSocketAuthToken', () => {
|
||||
test('does not read tokens from query params', async () => {
|
||||
const res = await app.request('/ws-auth-token?token=test-api-key')
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.token).toBeNull()
|
||||
})
|
||||
|
||||
@@ -201,7 +206,7 @@ describe('Auth Middleware', () => {
|
||||
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
||||
},
|
||||
})
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.token).toBe('test-api-key')
|
||||
})
|
||||
})
|
||||
@@ -210,7 +215,7 @@ describe('Auth Middleware', () => {
|
||||
test('accepts UUID from query param', async () => {
|
||||
const res = await app.request('/uuid-test?uuid=test-uuid-1')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.uuid).toBe('test-uuid-1')
|
||||
})
|
||||
|
||||
@@ -219,7 +224,7 @@ describe('Auth Middleware', () => {
|
||||
headers: { 'X-UUID': 'test-uuid-2' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.uuid).toBe('test-uuid-2')
|
||||
})
|
||||
|
||||
@@ -232,7 +237,7 @@ describe('Auth Middleware', () => {
|
||||
describe('getUuidFromRequest', () => {
|
||||
test('extracts from query param', async () => {
|
||||
const res = await app.request('/uuid-extract?uuid=from-query')
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.uuid).toBe('from-query')
|
||||
})
|
||||
|
||||
@@ -240,13 +245,13 @@ describe('Auth Middleware', () => {
|
||||
const res = await app.request('/uuid-extract', {
|
||||
headers: { 'X-UUID': 'from-header' },
|
||||
})
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.uuid).toBe('from-header')
|
||||
})
|
||||
|
||||
test('returns undefined when no UUID', async () => {
|
||||
const res = await app.request('/uuid-extract')
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.uuid).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||
|
||||
// res.json() returns Promise<unknown> in strict mode; this helper narrows for test assertions
|
||||
function resJson(res: Response) {
|
||||
return res.json() as Promise<any>
|
||||
}
|
||||
|
||||
// Mock config
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
@@ -106,7 +111,7 @@ describe('V1 Session Routes', () => {
|
||||
body: JSON.stringify({ title: 'Test Session' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.id).toMatch(/^session_/)
|
||||
expect(body.title).toBe('Test Session')
|
||||
expect(body.status).toBe('idle')
|
||||
@@ -127,13 +132,13 @@ describe('V1 Session Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const getRes = await app.request(`/v1/sessions/${id}`, {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(getRes.status).toBe(200)
|
||||
const body = await getRes.json()
|
||||
const body = await resJson(getRes)
|
||||
expect(body.id).toBe(id)
|
||||
})
|
||||
|
||||
@@ -152,13 +157,13 @@ describe('V1 Session Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json()
|
||||
} = await resJson(createRes)
|
||||
|
||||
const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(getRes.status).toBe(200)
|
||||
const body = await getRes.json()
|
||||
const body = await resJson(getRes)
|
||||
expect(body.id).toBe(id)
|
||||
})
|
||||
|
||||
@@ -168,7 +173,7 @@ describe('V1 Session Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const patchRes = await app.request(`/v1/sessions/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -176,7 +181,7 @@ describe('V1 Session Routes', () => {
|
||||
body: JSON.stringify({ title: 'Updated Title' }),
|
||||
})
|
||||
expect(patchRes.status).toBe(200)
|
||||
const body = await patchRes.json()
|
||||
const body = await resJson(patchRes)
|
||||
expect(body.title).toBe('Updated Title')
|
||||
})
|
||||
|
||||
@@ -186,7 +191,7 @@ describe('V1 Session Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
|
||||
method: 'POST',
|
||||
@@ -203,7 +208,7 @@ describe('V1 Session Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json()
|
||||
} = await resJson(createRes)
|
||||
const compatId = toWebSessionId(id)
|
||||
|
||||
const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, {
|
||||
@@ -216,7 +221,7 @@ describe('V1 Session Routes', () => {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(getRes.status).toBe(200)
|
||||
const body = await getRes.json()
|
||||
const body = await resJson(getRes)
|
||||
expect(body.id).toBe(id)
|
||||
expect(body.status).toBe('archived')
|
||||
})
|
||||
@@ -227,7 +232,7 @@ describe('V1 Session Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
|
||||
method: 'POST',
|
||||
@@ -235,7 +240,7 @@ describe('V1 Session Routes', () => {
|
||||
body: JSON.stringify({ events: [{ type: 'user', content: 'hello' }] }),
|
||||
})
|
||||
expect(eventsRes.status).toBe(200)
|
||||
const body = await eventsRes.json()
|
||||
const body = await resJson(eventsRes)
|
||||
expect(body.events).toBe(1)
|
||||
})
|
||||
|
||||
@@ -247,7 +252,7 @@ describe('V1 Session Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json()
|
||||
} = await resJson(createRes)
|
||||
const compatId = toWebSessionId(id)
|
||||
|
||||
const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, {
|
||||
@@ -274,7 +279,7 @@ describe('V1 Session Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ machine_name: 'test' }),
|
||||
})
|
||||
const { environment_id } = await envRes.json()
|
||||
const { environment_id } = await resJson(envRes)
|
||||
|
||||
const sessRes = await app.request('/v1/sessions', {
|
||||
method: 'POST',
|
||||
@@ -282,7 +287,7 @@ describe('V1 Session Routes', () => {
|
||||
body: JSON.stringify({ environment_id }),
|
||||
})
|
||||
expect(sessRes.status).toBe(200)
|
||||
const body = await sessRes.json()
|
||||
const body = await resJson(sessRes)
|
||||
expect(body.environment_id).toBe(environment_id)
|
||||
})
|
||||
|
||||
@@ -293,7 +298,7 @@ describe('V1 Session Routes', () => {
|
||||
body: JSON.stringify({ environment_id: 'env_nonexistent' }),
|
||||
})
|
||||
expect(sessRes.status).toBe(200)
|
||||
const body = await sessRes.json()
|
||||
const body = await resJson(sessRes)
|
||||
expect(body.id).toMatch(/^session_/)
|
||||
})
|
||||
|
||||
@@ -322,7 +327,7 @@ describe('V1 Environment Routes', () => {
|
||||
body: JSON.stringify({ machine_name: 'mac1', directory: '/home' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.environment_id).toMatch(/^env_/)
|
||||
expect(body.status).toBe('active')
|
||||
})
|
||||
@@ -333,7 +338,7 @@ describe('V1 Environment Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { environment_id } = await envRes.json()
|
||||
const { environment_id } = await resJson(envRes)
|
||||
|
||||
const delRes = await app.request(
|
||||
`/v1/environments/bridge/${environment_id}`,
|
||||
@@ -351,7 +356,7 @@ describe('V1 Environment Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { environment_id } = await envRes.json()
|
||||
const { environment_id } = await resJson(envRes)
|
||||
|
||||
const reconnectRes = await app.request(
|
||||
`/v1/environments/${environment_id}/bridge/reconnect`,
|
||||
@@ -377,7 +382,7 @@ describe('V1 Work Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
envId = (await envRes.json()).environment_id
|
||||
envId = (await resJson(envRes)).environment_id
|
||||
})
|
||||
|
||||
test('GET /v1/environments/:id/work/poll — returns 204 when no work', async () => {
|
||||
@@ -394,14 +399,14 @@ describe('V1 Work Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ environment_id: envId }),
|
||||
})
|
||||
const sessionId = (await sessRes.json()).id
|
||||
const sessionId = (await resJson(sessRes)).id
|
||||
|
||||
// Poll for work
|
||||
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(pollRes.status).toBe(200)
|
||||
const work = await pollRes.json()
|
||||
const work = await resJson(pollRes)
|
||||
expect(work.id).toMatch(/^work_/)
|
||||
expect(work.data.id).toBe(sessionId)
|
||||
|
||||
@@ -436,7 +441,7 @@ describe('V1 Work Routes', () => {
|
||||
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
const work = await pollRes.json()
|
||||
const work = await resJson(pollRes)
|
||||
|
||||
const hbRes = await app.request(
|
||||
`/v1/environments/${envId}/work/${work.id}/heartbeat`,
|
||||
@@ -446,7 +451,7 @@ describe('V1 Work Routes', () => {
|
||||
},
|
||||
)
|
||||
expect(hbRes.status).toBe(200)
|
||||
const body = await hbRes.json()
|
||||
const body = await resJson(hbRes)
|
||||
expect(body.lease_extended).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -467,7 +472,7 @@ describe('V2 Code Session Routes', () => {
|
||||
body: JSON.stringify({ title: 'Code Session' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.session.id).toMatch(/^cse_/)
|
||||
expect(body.session.title).toBe('Code Session')
|
||||
})
|
||||
@@ -479,14 +484,14 @@ describe('V2 Code Session Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = (await createRes.json()).session
|
||||
const { id } = (await resJson(createRes)).session
|
||||
|
||||
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
|
||||
method: 'POST',
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(bridgeRes.status).toBe(200)
|
||||
const body = await bridgeRes.json()
|
||||
const body = await resJson(bridgeRes)
|
||||
expect(body.api_base_url).toBe('http://localhost:3000')
|
||||
expect(body.worker_epoch).toBe(1)
|
||||
expect(body.worker_jwt).toBeTruthy()
|
||||
@@ -518,7 +523,7 @@ describe('V2 Worker Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const regRes = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/register`,
|
||||
@@ -528,7 +533,7 @@ describe('V2 Worker Routes', () => {
|
||||
},
|
||||
)
|
||||
expect(regRes.status).toBe(200)
|
||||
const body = await regRes.json()
|
||||
const body = await resJson(regRes)
|
||||
expect(body.worker_epoch).toBe(1)
|
||||
})
|
||||
|
||||
@@ -556,7 +561,7 @@ describe('Web Auth Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
|
||||
method: 'POST',
|
||||
@@ -564,7 +569,7 @@ describe('Web Auth Routes', () => {
|
||||
body: JSON.stringify({ sessionId: id }),
|
||||
})
|
||||
expect(bindRes.status).toBe(200)
|
||||
const body = await bindRes.json()
|
||||
const body = await resJson(bindRes)
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
@@ -574,7 +579,7 @@ describe('Web Auth Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const body = await sessRes.json()
|
||||
const body = await resJson(sessRes)
|
||||
const compatId = toWebSessionId(body.session.id)
|
||||
|
||||
const bindRes = await app.request('/web/bind?uuid=test-uuid', {
|
||||
@@ -583,7 +588,7 @@ describe('Web Auth Routes', () => {
|
||||
body: JSON.stringify({ sessionId: compatId }),
|
||||
})
|
||||
expect(bindRes.status).toBe(200)
|
||||
const bindBody = await bindRes.json()
|
||||
const bindBody = await resJson(bindRes)
|
||||
expect(bindBody.ok).toBe(true)
|
||||
expect(bindBody.sessionId).toBe(compatId)
|
||||
})
|
||||
@@ -625,7 +630,7 @@ describe('Web Session Routes', () => {
|
||||
body: JSON.stringify({ title: 'Web Session' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.id).toMatch(/^session_/)
|
||||
expect(body.source).toBe('web')
|
||||
})
|
||||
@@ -637,11 +642,11 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const listRes = await app.request('/web/sessions?uuid=user-1')
|
||||
expect(listRes.status).toBe(200)
|
||||
const sessions = await listRes.json()
|
||||
const sessions = await resJson(listRes)
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].id).toBe(id)
|
||||
})
|
||||
@@ -653,13 +658,13 @@ describe('Web Session Routes', () => {
|
||||
|
||||
const listRes = await app.request('/web/sessions?uuid=user-1')
|
||||
expect(listRes.status).toBe(200)
|
||||
const sessions = await listRes.json()
|
||||
const sessions = await resJson(listRes)
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].id).toBe(compatId)
|
||||
|
||||
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
||||
expect(allRes.status).toBe(200)
|
||||
const summaries = await allRes.json()
|
||||
const summaries = await resJson(allRes)
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(summaries[0].id).toBe(compatId)
|
||||
})
|
||||
@@ -684,7 +689,7 @@ describe('Web Session Routes', () => {
|
||||
|
||||
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
||||
expect(allRes.status).toBe(200)
|
||||
const sessions = await allRes.json()
|
||||
const sessions = await resJson(allRes)
|
||||
expect(sessions).toHaveLength(1) // only user-1's session, not user-2's
|
||||
})
|
||||
|
||||
@@ -706,14 +711,14 @@ describe('Web Session Routes', () => {
|
||||
|
||||
const listRes = await app.request('/web/sessions?uuid=user-1')
|
||||
expect(listRes.status).toBe(200)
|
||||
const sessions = await listRes.json()
|
||||
const sessions = await resJson(listRes)
|
||||
expect(sessions.map((session: { id: string }) => session.id)).toEqual([
|
||||
open.id,
|
||||
])
|
||||
|
||||
const allRes = await app.request('/web/sessions/all?uuid=user-1')
|
||||
expect(allRes.status).toBe(200)
|
||||
const summaries = await allRes.json()
|
||||
const summaries = await resJson(allRes)
|
||||
expect(summaries.map((session: { id: string }) => session.id)).toEqual([
|
||||
open.id,
|
||||
])
|
||||
@@ -725,7 +730,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`)
|
||||
expect(getRes.status).toBe(200)
|
||||
@@ -739,7 +744,7 @@ describe('Web Session Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await createRes.json()
|
||||
} = await resJson(createRes)
|
||||
storeBindSession(id, 'user-1')
|
||||
|
||||
await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
@@ -762,7 +767,7 @@ describe('Web Session Routes', () => {
|
||||
`/web/sessions/${toWebSessionId(id)}?uuid=user-1`,
|
||||
)
|
||||
expect(getRes.status).toBe(200)
|
||||
const body = await getRes.json()
|
||||
const body = await resJson(getRes)
|
||||
expect(body.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
@@ -777,7 +782,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`)
|
||||
expect(getRes.status).toBe(403)
|
||||
@@ -789,11 +794,11 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
|
||||
expect(histRes.status).toBe(200)
|
||||
const body = await histRes.json()
|
||||
const body = await resJson(histRes)
|
||||
expect(body.events).toEqual([])
|
||||
})
|
||||
|
||||
@@ -803,7 +808,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
publishSessionEvent(
|
||||
id,
|
||||
@@ -817,7 +822,7 @@ describe('Web Session Routes', () => {
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`)
|
||||
expect(histRes.status).toBe(200)
|
||||
const body = await histRes.json()
|
||||
const body = await resJson(histRes)
|
||||
expect(body.events).toHaveLength(1)
|
||||
expect(body.events[0]?.type).toBe('task_state')
|
||||
expect(body.events[0]?.payload.task_list_id).toBe('team-alpha')
|
||||
@@ -833,14 +838,14 @@ describe('Web Session Routes', () => {
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`)
|
||||
expect(getRes.status).toBe(200)
|
||||
const session = await getRes.json()
|
||||
const session = await resJson(getRes)
|
||||
expect(session.id).toBe(compatId)
|
||||
|
||||
const histRes = await app.request(
|
||||
`/web/sessions/${compatId}/history?uuid=user-1`,
|
||||
)
|
||||
expect(histRes.status).toBe(200)
|
||||
const history = await histRes.json()
|
||||
const history = await resJson(histRes)
|
||||
expect(history.events).toEqual([])
|
||||
})
|
||||
|
||||
@@ -850,7 +855,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`)
|
||||
expect(histRes.status).toBe(403)
|
||||
@@ -862,7 +867,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
// Archive/delete the session via v1
|
||||
await app.request(`/v1/sessions/${id}/archive`, {
|
||||
@@ -884,7 +889,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
// Delete the session from store directly
|
||||
const { storeDeleteSession } = await import('../store')
|
||||
@@ -902,7 +907,7 @@ describe('Web Session Routes', () => {
|
||||
})
|
||||
// Session is still created even if work item fails
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.id).toMatch(/^session_/)
|
||||
})
|
||||
|
||||
@@ -912,7 +917,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const eventsRes = await app.request(
|
||||
`/web/sessions/${id}/events?uuid=user-1`,
|
||||
@@ -956,7 +961,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const eventsRes = await app.request(
|
||||
`/web/sessions/${id}/events?uuid=user-2`,
|
||||
@@ -970,7 +975,7 @@ describe('Web Session Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
await app.request(`/v1/sessions/${id}/archive`, {
|
||||
method: 'POST',
|
||||
@@ -979,7 +984,7 @@ describe('Web Session Routes', () => {
|
||||
|
||||
const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`)
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.error.type).toBe('session_closed')
|
||||
})
|
||||
})
|
||||
@@ -1001,7 +1006,7 @@ describe('Web Control Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
sessionId = (await createRes.json()).id
|
||||
sessionId = (await resJson(createRes)).id
|
||||
})
|
||||
|
||||
test('POST /web/sessions/:id/events — sends user message', async () => {
|
||||
@@ -1014,7 +1019,7 @@ describe('Web Control Routes', () => {
|
||||
},
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.event).toBeTruthy()
|
||||
})
|
||||
@@ -1191,7 +1196,7 @@ describe('Web Environment Routes', () => {
|
||||
|
||||
const res = await app.request('/web/environments?uuid=user-1')
|
||||
expect(res.status).toBe(200)
|
||||
const envs = await res.json()
|
||||
const envs = await resJson(res)
|
||||
expect(envs).toHaveLength(1)
|
||||
expect(envs[0].machine_name).toBe('mac1')
|
||||
})
|
||||
@@ -1221,7 +1226,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
|
||||
method: 'POST',
|
||||
@@ -1231,7 +1236,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.status).toBe('ok')
|
||||
})
|
||||
|
||||
@@ -1261,7 +1266,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
const compatId = toWebSessionId(id)
|
||||
|
||||
const res = await app.request(
|
||||
@@ -1292,7 +1297,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
@@ -1380,7 +1385,7 @@ describe('V1 Session Ingress Routes (HTTP)', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
const compatId = toWebSessionId(id)
|
||||
|
||||
publishSessionEvent(id, 'user', { content: 'compat ws replay' }, 'outbound')
|
||||
@@ -1468,7 +1473,7 @@ describe('ACP Routes', () => {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].agent_name).toBe('agent-one')
|
||||
})
|
||||
@@ -1495,7 +1500,7 @@ describe('ACP Routes', () => {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].channel_group_id).toBe('group-one')
|
||||
})
|
||||
@@ -1550,7 +1555,7 @@ describe('ACP Routes', () => {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.channel_group_id).toBe('group-one')
|
||||
expect(body.member_count).toBe(1)
|
||||
})
|
||||
@@ -1579,14 +1584,14 @@ describe('ACP Routes', () => {
|
||||
|
||||
test('ACP relay auth rejects UUID-only auth', async () => {
|
||||
const res = await createRelayAuthApp().request('/relay-auth?uuid=user-1')
|
||||
expect(await res.json()).toEqual({ ok: false })
|
||||
expect(await resJson(res)).toEqual({ ok: false })
|
||||
})
|
||||
|
||||
test('ACP relay auth accepts API key header', async () => {
|
||||
const res = await createRelayAuthApp().request('/relay-auth', {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(await res.json()).toEqual({ ok: true })
|
||||
expect(await resJson(res)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
test('ACP relay auth accepts WebSocket protocol auth', async () => {
|
||||
@@ -1595,7 +1600,7 @@ describe('ACP Routes', () => {
|
||||
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
||||
},
|
||||
})
|
||||
expect(await res.json()).toEqual({ ok: true })
|
||||
expect(await resJson(res)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
test('ACP WebSocket rejects legacy query-token auth on the real upgrade path', async () => {
|
||||
@@ -1845,7 +1850,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
||||
method: 'POST',
|
||||
@@ -1853,7 +1858,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
body: JSON.stringify([{ type: 'assistant', content: 'response' }]),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.status).toBe('ok')
|
||||
expect(body.count).toBe(1)
|
||||
})
|
||||
@@ -1866,7 +1871,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
||||
method: 'POST',
|
||||
@@ -1877,7 +1882,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = await resJson(res)
|
||||
expect(body.count).toBe(1)
|
||||
|
||||
const events = getEventBus(id).getEventsSince(0)
|
||||
@@ -1896,7 +1901,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
|
||||
const putRes = await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
method: 'PUT',
|
||||
@@ -1921,7 +1926,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
expect(getRes.status).toBe(200)
|
||||
const body = await getRes.json()
|
||||
const body = await resJson(getRes)
|
||||
expect(body.worker.worker_status).toBe('running')
|
||||
expect(body.worker.external_metadata.permission_mode).toBe('default')
|
||||
expect(body.worker.external_metadata.automation_state).toEqual({
|
||||
@@ -1949,7 +1954,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
|
||||
const heartbeatRes = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/heartbeat`,
|
||||
@@ -1964,7 +1969,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
headers: AUTH_HEADERS,
|
||||
})
|
||||
const body = await getRes.json()
|
||||
const body = await resJson(getRes)
|
||||
expect(body.worker.last_heartbeat_at).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -1976,7 +1981,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
|
||||
const streamRes = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||
@@ -2016,7 +2021,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const streamRes = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||
@@ -2062,7 +2067,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const streamRes = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||
@@ -2111,7 +2116,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await createRes.json()
|
||||
const { id } = await resJson(createRes)
|
||||
|
||||
const streamRes = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/events/stream`,
|
||||
@@ -2151,7 +2156,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
|
||||
method: 'PUT',
|
||||
@@ -2167,7 +2172,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/external_metadata`,
|
||||
@@ -2186,7 +2191,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const { id } = await sessRes.json()
|
||||
const { id } = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/events/evt123/delivery`,
|
||||
@@ -2207,7 +2212,7 @@ describe('V2 Worker Events Routes', () => {
|
||||
})
|
||||
const {
|
||||
session: { id },
|
||||
} = await sessRes.json()
|
||||
} = await resJson(sessRes)
|
||||
|
||||
const res = await app.request(
|
||||
`/v1/code/sessions/${id}/worker/events/delivery`,
|
||||
|
||||
@@ -179,6 +179,7 @@ import privacySettings from './commands/privacy-settings/index.js'
|
||||
import hooks from './commands/hooks/index.js'
|
||||
import files from './commands/files/index.js'
|
||||
import branch from './commands/branch/index.js'
|
||||
import artifacts from './commands/artifacts/index.js'
|
||||
import agents from './commands/agents/index.js'
|
||||
import plugin from './commands/plugin/index.js'
|
||||
import reloadPlugins from './commands/reload-plugins/index.js'
|
||||
@@ -305,6 +306,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
localMemoryCommand,
|
||||
autonomy,
|
||||
provider,
|
||||
artifacts,
|
||||
agents,
|
||||
branch,
|
||||
btw,
|
||||
|
||||
94
src/commands/artifacts/ArtifactsMenu.tsx
Normal file
94
src/commands/artifacts/ArtifactsMenu.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Text, setClipboard, useInput } from '@anthropic/ink';
|
||||
import type { ArtifactInfo } from './scanner.js';
|
||||
import { openBrowser } from 'src/utils/browser.js';
|
||||
|
||||
type Props = {
|
||||
artifacts: ArtifactInfo[];
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
export function ArtifactsMenu({ artifacts, onExit }: Props): React.ReactElement {
|
||||
const [selected, setSelected] = React.useState(0);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === 'q' || key.escape) {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
if (artifacts.length === 0) return;
|
||||
if (key.upArrow) {
|
||||
setSelected(s => (s - 1 + artifacts.length) % artifacts.length);
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelected(s => (s + 1) % artifacts.length);
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
const target = artifacts[selected];
|
||||
if (target.url) {
|
||||
void openBrowser(target.url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (input === 'c') {
|
||||
const target = artifacts[selected];
|
||||
if (target.url) {
|
||||
void setClipboard(target.url).then(raw => {
|
||||
if (raw) process.stdout.write(raw);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Artifacts ({artifacts.length})</Text>
|
||||
</Box>
|
||||
|
||||
{artifacts.length === 0 ? (
|
||||
<Text color="subtle">No artifacts uploaded this session. Run /use-artifacts to learn how.</Text>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{artifacts.map((a, idx) => (
|
||||
<ArtifactRow key={a.toolUseId} artifact={a} isSelected={idx === selected} />
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color="subtle">{'↑/↓ select · Enter open · c copy URL · Esc exit'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ artifact, isSelected }: { artifact: ArtifactInfo; isSelected: boolean }): React.ReactElement {
|
||||
const marker = isSelected ? '›' : ' ';
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={isSelected ? 'suggestion' : undefined}>{marker} </Text>
|
||||
<Text bold={isSelected} color={artifact.isError ? 'error' : undefined}>
|
||||
{artifact.basename}
|
||||
</Text>
|
||||
{artifact.hash ? <Text color="subtle"> ({artifact.hash})</Text> : null}
|
||||
</Box>
|
||||
{artifact.url ? (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="background">{artifact.url}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="error">{artifact.rawContent}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{artifact.expiresAt ? (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="subtle">expires: {artifact.expiresAt}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
158
src/commands/artifacts/__tests__/scanner.test.ts
Normal file
158
src/commands/artifacts/__tests__/scanner.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { extractArtifacts } from '../scanner.js'
|
||||
import type { Message } from 'src/types/message.js'
|
||||
|
||||
function assistantToolUse(id: string, input: Record<string, unknown>): Message {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use' as const, id, name: 'artifact', input }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function userToolResult(id: string, content: string, isError = false): Message {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: id,
|
||||
content,
|
||||
is_error: isError,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('extractArtifacts', () => {
|
||||
test('returns empty list when no artifact tool_use messages', () => {
|
||||
expect(extractArtifacts([])).toEqual([])
|
||||
expect(
|
||||
extractArtifacts([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text' as const, text: 'hi' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('pairs a successful tool_use with its tool_result and returns parsed fields', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu1',
|
||||
'Artifact uploaded: https://x.test/7d/abc.html (id: abc, expires: 2026-06-27T10:00:00.000Z)',
|
||||
),
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: '/tmp/report.html',
|
||||
hash: 'abc',
|
||||
url: 'https://x.test/7d/abc.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
basename: 'report.html',
|
||||
isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('skips artifact tool_use without a matching tool_result', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||
]
|
||||
|
||||
expect(extractArtifacts(messages)).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps error results with isError=true and no parsed fields', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/missing.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu1',
|
||||
'File does not exist or is not readable: /tmp/missing.html',
|
||||
true,
|
||||
),
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: '/tmp/missing.html',
|
||||
basename: 'missing.html',
|
||||
isError: true,
|
||||
})
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
test('parses url/id/expires from array-form tool_result content', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||
{
|
||||
type: 'user',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'tu1',
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'Artifact uploaded: ' },
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'https://x.test/7d/def.html (id: def, expires: 2026-06-27T10:00:00.000Z)',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: '/tmp/report.html',
|
||||
hash: 'def',
|
||||
url: 'https://x.test/7d/def.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
basename: 'report.html',
|
||||
isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('orders newest first (last in conversation appears at top)', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/a.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu1',
|
||||
'Artifact uploaded: https://x.test/7d/a.html (id: a, expires: 2026-06-27T10:00:00.000Z)',
|
||||
),
|
||||
assistantToolUse('tu2', { file_path: '/tmp/b.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu2',
|
||||
'Artifact uploaded: https://x.test/7d/b.html (id: b, expires: 2026-06-27T10:00:00.000Z)',
|
||||
),
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result.map(r => r.basename)).toEqual(['b.html', 'a.html'])
|
||||
})
|
||||
})
|
||||
11
src/commands/artifacts/artifacts.tsx
Normal file
11
src/commands/artifacts/artifacts.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
|
||||
import type { ToolUseContext } from 'src/Tool.js';
|
||||
import { ArtifactsMenu } from './ArtifactsMenu.js';
|
||||
import { extractArtifacts } from './scanner.js';
|
||||
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
|
||||
const messages = context.messages ?? [];
|
||||
const artifacts = extractArtifacts(messages);
|
||||
return <ArtifactsMenu artifacts={artifacts} onExit={onDone} />;
|
||||
}
|
||||
12
src/commands/artifacts/index.ts
Normal file
12
src/commands/artifacts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const artifacts = {
|
||||
type: 'local-jsx',
|
||||
name: 'artifacts',
|
||||
description:
|
||||
'List HTML artifacts uploaded to cloud-artifacts in this session',
|
||||
isEnabled: () => true,
|
||||
load: () => import('./artifacts.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default artifacts
|
||||
97
src/commands/artifacts/scanner.ts
Normal file
97
src/commands/artifacts/scanner.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { basename } from 'path'
|
||||
import type { Message } from 'src/types/message.js'
|
||||
|
||||
export type ArtifactInfo = {
|
||||
toolUseId: string
|
||||
filePath: string
|
||||
basename: string
|
||||
hash?: string
|
||||
url?: string
|
||||
expiresAt?: string
|
||||
rawContent: string
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s)"',]+\.html\b/
|
||||
const ID_REGEX = /\bid:\s*([A-Za-z0-9_-]+)/
|
||||
const EXPIRES_REGEX = /\bexpires:\s*([0-9T:.Z+-]+)/
|
||||
|
||||
export function extractArtifacts(messages: Message[]): ArtifactInfo[] {
|
||||
const results: ArtifactInfo[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'assistant') continue
|
||||
const content = message.message?.content
|
||||
if (!Array.isArray(content)) continue
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
if (!('type' in block)) continue
|
||||
const b = block as unknown as Record<string, unknown>
|
||||
if (b.type !== 'tool_use') continue
|
||||
if (b.name !== 'artifact') continue
|
||||
|
||||
const toolUseId = b.id as string
|
||||
const input = b.input as { file_path?: string } | undefined
|
||||
const filePath = input?.file_path ?? '<unknown>'
|
||||
|
||||
const resultBlock = findToolResult(messages, toolUseId)
|
||||
if (!resultBlock) continue
|
||||
|
||||
const rawContent =
|
||||
typeof resultBlock.content === 'string'
|
||||
? resultBlock.content
|
||||
: Array.isArray(resultBlock.content)
|
||||
? resultBlock.content
|
||||
.map(c =>
|
||||
typeof c === 'string'
|
||||
? c
|
||||
: 'text' in c
|
||||
? (c as { text: string }).text
|
||||
: '',
|
||||
)
|
||||
.join('')
|
||||
: ''
|
||||
|
||||
const isError = resultBlock.is_error === true
|
||||
const urlMatch = rawContent.match(URL_REGEX)
|
||||
const idMatch = rawContent.match(ID_REGEX)
|
||||
const expiresMatch = rawContent.match(EXPIRES_REGEX)
|
||||
|
||||
results.push({
|
||||
toolUseId,
|
||||
filePath,
|
||||
basename: basename(filePath),
|
||||
hash: idMatch?.[1],
|
||||
url: urlMatch?.[0],
|
||||
expiresAt: expiresMatch?.[1],
|
||||
rawContent,
|
||||
isError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newest first
|
||||
return results.reverse()
|
||||
}
|
||||
|
||||
function findToolResult(
|
||||
messages: Message[],
|
||||
toolUseId: string,
|
||||
): { content: unknown; is_error?: boolean } | null {
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'user') continue
|
||||
const content = message.message?.content
|
||||
if (!Array.isArray(content)) continue
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
if (!('type' in block)) continue
|
||||
const b = block as unknown as Record<string, unknown>
|
||||
if (b.type !== 'tool_result') continue
|
||||
if (b.tool_use_id !== toolUseId) continue
|
||||
return { content: b.content, is_error: b.is_error as boolean | undefined }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
38
src/query.ts
38
src/query.ts
@@ -522,21 +522,35 @@ async function* queryLoop(
|
||||
|
||||
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
|
||||
|
||||
// Release toolUseResult payloads from previous turns. By this point the
|
||||
// UI has already rendered those results and the next API call only needs
|
||||
// message.message.content (tool_result blocks), not the raw output object.
|
||||
// This prevents unbounded memory growth in long sessions before compact
|
||||
// triggers — a single FileRead of a 400KB file would otherwise stay in
|
||||
// mutableMessages forever.
|
||||
for (const msg of messagesForQuery) {
|
||||
// Release toolUseResult payloads from previous turns — the next API call
|
||||
// only needs message.message.content (tool_result blocks), not the raw
|
||||
// output object. This prevents unbounded memory growth in long sessions
|
||||
// before compact triggers (a single FileRead of a 400KB file would
|
||||
// otherwise stay in mutableMessages forever).
|
||||
//
|
||||
// IMPORTANT: shallow-copy rather than mutate. messagesForQuery elements
|
||||
// are references shared with mutableMessages (UI state); deleting
|
||||
// toolUseResult in place strips it from the live message while React may
|
||||
// still be rendering it. The next query can start within milliseconds of
|
||||
// tool_result creation (model immediately calls the next tool), before
|
||||
// the UI commit lands — UserToolSuccessMessage reads
|
||||
// message.toolUseResult to delegate to tool.renderToolResultMessage, so a
|
||||
// mutation race makes tool-result rows render blank. Map to a stripped
|
||||
// copy so mutableMessages keeps the original for the UI; downstream API
|
||||
// transformations (applyToolResultBudget, snip, microcompact) already
|
||||
// build new arrays via .map(), so they compose cleanly with this copy.
|
||||
messagesForQuery = messagesForQuery.map(msg => {
|
||||
if (
|
||||
msg.type === 'user' &&
|
||||
'toolUseResult' in msg &&
|
||||
msg.toolUseResult !== undefined
|
||||
msg.type !== 'user' ||
|
||||
!('toolUseResult' in msg) ||
|
||||
(msg as { toolUseResult?: unknown }).toolUseResult === undefined
|
||||
) {
|
||||
delete (msg as Message & { toolUseResult?: unknown }).toolUseResult
|
||||
return msg
|
||||
}
|
||||
}
|
||||
const copy: typeof msg = { ...msg }
|
||||
delete (copy as Message & { toolUseResult?: unknown }).toolUseResult
|
||||
return copy
|
||||
})
|
||||
|
||||
let tracking = autoCompactTracking
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerKeybindingsSkill } from './keybindings.js'
|
||||
import { registerLoremIpsumSkill } from './loremIpsum.js'
|
||||
import { registerRememberSkill } from './remember.js'
|
||||
import { registerSimplifySkill } from './simplify.js'
|
||||
import { registerUseArtifactsSkill } from './useArtifacts.js'
|
||||
import { registerSkillifySkill } from './skillify.js'
|
||||
import { registerStuckSkill } from './stuck.js'
|
||||
import { registerUltracodeSkill } from './ultracode.js'
|
||||
@@ -34,6 +35,7 @@ export function initBundledSkills(): void {
|
||||
registerSkillifySkill()
|
||||
registerRememberSkill()
|
||||
registerSimplifySkill()
|
||||
registerUseArtifactsSkill()
|
||||
registerBatchSkill()
|
||||
registerStuckSkill()
|
||||
registerUltracodeSkill()
|
||||
|
||||
101
src/skills/bundled/useArtifacts.ts
Normal file
101
src/skills/bundled/useArtifacts.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { registerBundledSkill } from '../bundledSkills.js'
|
||||
|
||||
const USE_ARTIFACTS_PROMPT = `# Using Artifacts
|
||||
|
||||
Artifacts are public HTML pages you upload to a hosting service. They have stable URLs that you can share with the user or open in a browser. Use them to surface work-in-progress, summaries, and reports.
|
||||
|
||||
## When to use artifacts
|
||||
|
||||
**Good artifact content:**
|
||||
- Progress panels / kanbans (task list with status)
|
||||
- Research reports and analysis (data + findings + recommendations)
|
||||
- Design docs / decision records (with context and rationale)
|
||||
- Data visualizations (tables, SVG charts, flow diagrams)
|
||||
- Final deliverables (the "thing the user asked for" rendered as HTML)
|
||||
|
||||
**Do NOT use artifacts for:**
|
||||
- Code snippets — use files directly
|
||||
- One-line answers — keep them in chat
|
||||
- Internal debug logs — keep them in chat
|
||||
- Large data dumps — link to source files instead
|
||||
|
||||
## Cadence — when to upload
|
||||
|
||||
- **Task start**: if the task is complex (multi-step, research, deliverable), upload a skeleton artifact first as scaffolding (placeholder sections).
|
||||
- **Milestones**: when you complete a phase (research done / implementation done / tests pass), update the artifact.
|
||||
- **User asks**: upload immediately.
|
||||
- **Task end**: ship the final artifact as the deliverable.
|
||||
|
||||
**Do NOT upload:**
|
||||
- After every tool call (noise)
|
||||
- Mid-step with no meaningful change (e.g. fixed a typo)
|
||||
|
||||
## How to invoke (deferred tool)
|
||||
|
||||
\`artifact\` is a deferred tool. The first call requires two steps; subsequent calls one step.
|
||||
|
||||
**First upload (creates a new artifact):**
|
||||
\`\`\`
|
||||
1. Use the Write tool to write HTML to a local file (location is your choice).
|
||||
2. SearchExtraTools({ query: "select:artifact" }) // loads the tool schema
|
||||
3. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "<absolute-path>.html" } })
|
||||
4. Save the returned \`id\` from the tool result — this is the hash.
|
||||
\`\`\`
|
||||
|
||||
**Subsequent updates (overwrites in place, URL stays stable):**
|
||||
\`\`\`
|
||||
1. Update the local HTML file.
|
||||
2. ExecuteExtraTool({ tool_name: "artifact", params: { file_path: "<absolute-path>.html", hash: "<id-from-first-call>" } })
|
||||
\`\`\`
|
||||
|
||||
The URL returned on every call is the same when you pass the same \`hash\`. The user can open it at any time to see the latest version.
|
||||
|
||||
## Minimal HTML skeleton
|
||||
|
||||
\`\`\`html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Artifact Title</title>
|
||||
<style>
|
||||
body { font: 14px/1.5 -apple-system, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1, h2 { color: #1a1a1a; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Artifact Title</h1>
|
||||
<!-- content here -->
|
||||
</body>
|
||||
</html>
|
||||
\`\`\`
|
||||
|
||||
The hosting service serves the HTML verbatim (including any \`<script>\` you include), so you can use vanilla JS/SVG/CSS as needed. Do not embed secrets.
|
||||
|
||||
## Notes
|
||||
|
||||
- Artifacts expire (default 7 days; pass \`ttl: 30\` for 30-day retention).
|
||||
- Anyone with the URL can view the artifact — treat the URL as the secret.
|
||||
- The \`/artifacts\` slash command (user-invoked) shows all artifacts uploaded in the current session.
|
||||
`
|
||||
|
||||
export function registerUseArtifactsSkill(): void {
|
||||
registerBundledSkill({
|
||||
name: 'use-artifacts',
|
||||
description:
|
||||
'Teach the agent when and how to use the artifact tool: what content belongs in artifacts, when to upload/update, and the SearchExtraTools + ExecuteExtraTool invocation flow for the deferred artifact tool.',
|
||||
whenToUse:
|
||||
'Use this skill at the start of any complex task that would benefit from a living progress document or a deliverable HTML report.',
|
||||
userInvocable: true,
|
||||
argumentHint: '[optional focus note]',
|
||||
async getPromptForCommand(args) {
|
||||
let prompt = USE_ARTIFACTS_PROMPT
|
||||
if (args && args.trim().length > 0) {
|
||||
prompt += `\n\n## Additional Focus\n\n${args.trim()}\n`
|
||||
}
|
||||
return [{ type: 'text', text: prompt }]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import { TaskOutputTool } from '@claude-code-best/builtin-tools/tools/TaskOutput
|
||||
import { WebSearchTool } from '@claude-code-best/builtin-tools/tools/WebSearchTool/WebSearchTool.js'
|
||||
import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js'
|
||||
import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||
import { ArtifactTool } from '@claude-code-best/builtin-tools/tools/ArtifactTool/ArtifactTool.js'
|
||||
import { TestingPermissionTool } from '@claude-code-best/builtin-tools/tools/testing/TestingPermissionTool.js'
|
||||
import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js'
|
||||
import { TungstenTool } from '@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'
|
||||
@@ -228,6 +229,7 @@ export function getAllBaseTools(): Tools {
|
||||
FileEditTool,
|
||||
FileWriteTool,
|
||||
NotebookEditTool,
|
||||
ArtifactTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
WebSearchTool,
|
||||
|
||||
Reference in New Issue
Block a user