diff --git a/internal-doc/artifacts-design-review.md b/internal-doc/artifacts-design-review.md new file mode 100644 index 00000000..2d26ba19 --- /dev/null +++ b/internal-doc/artifacts-design-review.md @@ -0,0 +1,48 @@ +# JCode Artifacts Architecture Review + +- 日期:2026-08-01 +- 输入:`artifacts-prd.md`、`artifacts-prd-review.md`、`artifacts-design.md` +- 评审者:Grok CLI `grok-4.5`、Kimi CLI 默认 `kimi-code/kimi-for-coding-highspeed` +- 最终门禁:**GO / P0 none**(两者一致) + +## 评审过程 + +首轮 Kimi 认为 metadata/content 线格式、Cloud 状态转换、intent TTL/GC、automation unseen 持久化和 Desktop bridge token 合同不足,结论 NO-GO。补充这些合同后,Kimi 给出 GO。 + +首轮 Grok 在完整设计上进一步发现 lease takeover 竞态:若新旧 PUT 共享 object key,旧请求晚到可能导致 object body 与 DB digest 分裂。设计随后改为: + +- claim CAS 同时绑定随机 `upload_claim_id` 与单调 `upload_generation`; +- 每个 generation 使用不同的 server-only object key; +- uploaded CAS 必须匹配 `state + claim_id + generation`; +- 失败请求只删除自己的 generation object; +- generation 最多 3,GC 可确定性枚举并删除所有代际对象。 + +同时关闭了四个 P1:Desktop token 从 process/child env scrub、`ciphertext_size = plaintext + 28` 硬合同、`internal/artifact.Service` 作为 Registry 唯一 owner,以及 session-wide viewed 与 per-connection focus 的语义拆分。 + +## 最终复审 + +### Grok 4.5 + +- Verdict:GO +- P0:none +- P1:none +- 五项修正:全部 closed +- 强制测试:lease takeover/late PUT、revoke-in-flight、全代际 GC、Desktop env scrub、wire/AAD vectors、Registry 单 owner、multi-tab unseen/focus。 + +### Kimi + +- Verdict:GO +- P0:none +- 五项修正:全部 closed +- 建议:generation 用尽后由用户 Retry 创建新 intent;所有 Viewer 入口统一触发 viewed PATCH。两项已写回 Technical Design。 + +## 实现门禁 + +实现只有在以下条件全部通过后才能合并: + +1. Cloud state/store/API/public page 与跨端 crypto vector; +2. JCode Web-only tool、JSONL/Registry/API/UI/Desktop bridge; +3. CLI/TUI/ACP 负面 schema 测试; +4. 真实 Kimi 模型通过 JCode Web 创建并打开 Artifact; +5. Cloud 在 company K8s 使用真实对象存储完成 share/open/revoke 与 plaintext canary 扫描; +6. 两仓对抗性代码审查没有未关闭 P0/P1。 diff --git a/internal-doc/artifacts-design.md b/internal-doc/artifacts-design.md new file mode 100644 index 00000000..b80cbf9c --- /dev/null +++ b/internal-doc/artifacts-design.md @@ -0,0 +1,1063 @@ +# JCode Artifacts Technical Design(Web / Desktop) + +- 状态:Approved for implementation(Grok 4.5 + Kimi 双评审 GO,2026-08-01) +- 对应 PRD:`internal-doc/artifacts-prd.md` +- 目标:为 Web/Desktop 增加 session-scoped Artifact 登记、回放、预览,以及登录后可选的 E2EE Cloud 分享 +- 关键约束:CLI/TUI、ACP 不注册、不暴露、不需要 enable 配置 + +## 1. 决策摘要 + +1. 新增 Web transport 专用只读工具 `show_artifact`。 +2. Artifact 是会话级元数据,文件仍存放在任务工作区;不扫描整个工作区,不复制文件正文到 session JSONL。 +3. `show_artifact` 完成四件事:严格校验路径、计算/更新 Artifact、写 session entry、发送 WebSocket 事件。 +4. Web 和 Desktop 复用同一 React Artifacts 面板。Desktop 额外通过 Tauri command 打开或 reveal 本地文件。 +5. 不扩展共享 `handler.AgentEventHandler`;Web 工具通过窄回调调用现有 `WebHandler.Emit`。 +6. 工具仅加入 `internal/command/web.go` 的候选列表;Tool Search 路径再由 `ToolTransportWeb` policy 二次约束。`interactive.go` 和 `acp.go` 不加入候选。 +7. MVP 只对本地工作区注册工具。远程工作区等有界流式读取能力完成后再开放。 +8. Phase 3 复用 JCode Cloud sibling repository 的设备登录和对象存储提供显式分享;`show_artifact` 保持纯本地,登录不会触发自动上传,未登录不会阻塞或提示。 +9. 本地 Artifact 的领域模型、路径策略和并发 Registry 放入 `internal/artifact`,避免 `internal/tools`、`internal/web` 和 `internal/session` 互相反向依赖。 +10. UI 采用原型评审后的组合:Docked Workbench 是默认,Inline Quick Look 是紧凑入口,Focus Canvas/fullscreen 是同一 Viewer 的放大 presentation;三者共享选择、renderer 和 share state。 +11. Cloud 分享采用 intent → bounded ciphertext upload → complete 的状态机。每次分享生成独立 Artifact Share Secret(字段 `share_key`),不复用账号 CEK 或 Account Sync Key,也不使用歧义缩写。 + +## 2. 现状与可复用能力 + +### 2.1 共享 Web/Desktop UI + +`web/` 是 Browser Web 与 Tauri Desktop 共用的 React 产品 UI,构建后进入 `internal/web/dist/`。现有右侧面板由以下组件承载: + +- `web/src/App.tsx`:维护 right panel 类型和开关状态。 +- `web/src/components/RightPanel.tsx`:Plan / Files / Changes 标签及内容。 +- `web/src/components/TopBar.tsx`:Browser Web 面板入口。 +- `web/src/components/DesktopTitlebar.tsx`:Desktop 标题栏入口,复用相同状态。 + +因此 Artifact 不需要单独开发 Desktop 前端,只需在同一状态模型中增加 `artifacts` panel,并为 Tauri 环境注入额外 action。 + +已评审 UI 原型位于 `internal-doc/artifacts-ui/`。它验证了 Docked、Focus Canvas、Quick Look、Cloud 登录门和完整分享状态;生产组件遵循该信息架构,但使用现有 Heroicons、i18n 和 Redux runtime,不复制原型中的占位内容。 + +### 2.2 Web 事件通道 + +`internal/handler/web.go` 的 `WebHandler` 已支持通用 `Emit(event, data)`,WebSocket bridge 位于: + +- `web/src/lib/ws.ts` +- `web/src/app/wsBridge.ts` +- `web/src/app/store.ts` + +Artifact 事件可以复用此通道,不需要给所有 transport 的 `AgentEventHandler` 增加方法。现有 `Emit` 是有界 channel 上的 best-effort 通知,队列满时可能丢弃,因此持久化/list API 必须承担对账责任。 + +### 2.3 Transport-scoped tool catalog + +`internal/command/tool_catalog.go` 已使用 `ToolTransportTUI`、`ToolTransportWeb`、`ToolTransportACP` 构建 Tool Search 模式下的最终工具计划。Artifact 应采用 direct、read-class、all-modes、Web-only policy。各 command 的候选注册是静态/eager 路径的隔离边界;catalog policy 是 Tool Search 路径的第二层保护。 + +### 2.4 Session JSONL + +`internal/session/session.go` 已提供 append-only session entry 和 Recorder。Artifact entry 可以沿用同一记录/回放链路,使自动化任务、页面刷新和历史会话都不依赖内存事件。 + +### 2.5 OpenWorker 参考与差异 + +OpenWorker 的实现包含: + +- session artifacts list/read/reveal API; +- RightRail Artifact 列表和 Viewer; +- HTML、Markdown、图片、PDF、CSV、表格等 renderer; +- `[Title](artifact:relative/path)` 对话链接。 + +JCode 保留其 Viewer 与右侧栏思路,但不采用“按后缀扫描 workspace”的列表语义。JCode 的 Artifact 必须来自显式登记,这样才能稳定做到会话隔离、回放、未读状态和后台任务处理。 + +实现时可对照 OpenWorker sibling repository 中的这些位置: + +- `surfaces/gui/src/components/RightRail.tsx`:Artifact 列表、Viewer、HTML/Markdown/image/PDF/CSV/sheet/office 分流; +- `surfaces/gui/src/components/Markdown.tsx`:`artifact:` 链接转 Artifact chip; +- `surfaces/gui/src/api.ts`:list/read/reveal 客户端; +- `coworker/server/app.py`:session artifact HTTP routes; +- `coworker/server/manager.py`:workspace 扫描、类型识别和路径处理; +- `tests/test_artifact_walk.py`、`tests/test_server.py`:扫描和 API 行为测试。 + +其中全工作区扫描、向 Browser UI 返回绝对路径,以及 HTML iframe 同时启用 `allow-scripts allow-same-origin` 都不应照搬;JCode 方案分别以显式 Registry、opaque Artifact ID、opaque-origin sandbox 取代。 + +## 3. 总体架构 + +```mermaid +sequenceDiagram + participant M as "Agent model" + participant T as "show_artifact tool" + participant R as "Artifact registry" + participant S as "Session recorder" + participant W as "WebHandler / WebSocket" + participant U as "Web or Desktop UI" + participant F as "Workspace file" + + M->>T: "show_artifact(path, title, kind, focus)" + T->>F: "validate, stat, MIME sniff" + F-->>T: "canonical file metadata" + T->>R: "upsert(session, relative path)" + R-->>T: "artifact metadata + revision" + T->>S: "append artifact entry" + T->>W: "emit artifact_upserted" + T-->>M: "registered artifact id" + W-->>U: "update list; focus only active task" + U->>R: "GET content by artifact id" + R->>F: "revalidate and stream" + F-->>U: "bounded response" +``` + +Artifact registry 不是新的数据库。运行中它是由 session entries 构建的内存索引;持久化事实来源仍然是 JSONL,文件正文事实来源仍然是 workspace。 + +跨 Cloud 分享是另一条明确由用户触发的链路,不经过模型工具和 device relay: + +```mermaid +sequenceDiagram + participant U as "User" + participant J as "JCode share service" + participant C as "Cloud orchestrator" + participant O as "Object storage" + participant P as "Public share page" + + U->>J: "Share current revision" + J->>J: "revalidate, bounded snapshot, digest, new share_key" + J->>C: "create intent (device token, no key/plaintext)" + C-->>J: "share_id, upload/complete URLs, base share URL" + J->>J: "AES-256-GCM encrypt metadata + content" + J->>C: "PUT bounded ciphertext" + C->>O: "single-object PUT" + J->>C: "complete(encrypted metadata, ciphertext digest)" + C-->>J: "complete share metadata, base URL only" + J-->>U: "base URL + #k=v1." + U->>P: "open URL; fragment stays in browser" + P->>C: "fetch encrypted metadata/content" + P->>P: "WebCrypto decrypt and safe renderer" +``` + +本地 revision 与 Cloud snapshot 不是同一种版本语义: + +| 维度 | 本地 Artifact | Cloud share | +| --- | --- | --- | +| 内容事实来源 | workspace 当前文件 | 创建分享时固定的 ciphertext object | +| revision | 显式登记代数 | 分享绑定的登记 revision | +| 文件未重新登记但已变化 | Viewer 读到新内容,revision 不变 | 已有链接完全不变 | +| 持久化 | session JSONL 只存 metadata | Cloud DB + object store 只存 routing metadata/ciphertext | +| 触发者 | Agent `show_artifact` | 用户 Viewer `Share` | + +## 4. 领域模型 + +建议共享的逻辑模型: + +```go +type ArtifactKind string + +const ( + ArtifactAuto ArtifactKind = "auto" + ArtifactText ArtifactKind = "text" + ArtifactMarkdown ArtifactKind = "markdown" + ArtifactCode ArtifactKind = "code" + ArtifactHTML ArtifactKind = "html" + ArtifactImage ArtifactKind = "image" + ArtifactPDF ArtifactKind = "pdf" + ArtifactCSV ArtifactKind = "csv" + ArtifactBinary ArtifactKind = "binary" +) + +type Artifact struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + RelativePath string `json:"relative_path"` + Title string `json:"title"` + Kind ArtifactKind `json:"kind"` + MediaType string `json:"media_type"` + Size int64 `json:"size"` + Revision int `json:"revision"` + UpdatedAt time.Time `json:"updated_at"` + Status string `json:"status"` // available | missing | unsupported | too_large | error +} +``` + +### 4.1 ID 与幂等性 + +`ID` 应是不可逆、稳定的标识,例如: + +```text +base64url(sha256(session_id + "\x00" + normalized_relative_path))[0:22] +``` + +不要把绝对路径编码进 ID。相同 session + path 得到相同 ID;每次成功登记 revision 加一。标题或 kind 改变也视为一次 revision。 + +### 4.2 路径语义 + +- 工具输入只接受 slash-separated 相对路径。 +- 存储前执行 `filepath.Clean` 并转换为 workspace-relative slash path。 +- ID 计算和 session entry 始终使用 normalized relative path。 +- 绝对路径只在服务端一次请求的校验过程中短暂存在,不返回 Browser Web;Desktop 原生 action 也优先传 Artifact ID,而不是路径。 + +### 4.3 revision、digest 与 share snapshot + +- 本地 revision 只在 `show_artifact` 成功 append 一条 Artifact entry 时增加;文件 watcher 不修改 revision。 +- list/content 每次重新 stat/canonicalize,不能把登记时的 size、media type 或路径校验当作永久授权。 +- 分享开始时在 25 MiB 上限内读取一个有界内存 snapshot,并对 plaintext 计算 SHA-256;读取前后再次 stat,revision、size、mtime/file identity 任一变化都返回 `artifact_changed`。 +- 加密和上传只使用该 snapshot,不再次读取 workspace,因此 share 完成后对应一个不可变 object。 +- plaintext digest 只保存在 JCode 本地 share metadata 中,用于显示与重试;Cloud 只接收 ciphertext size/digest。 + +## 5. `show_artifact` 工具设计 + +### 5.1 输入与输出 + +```go +type ShowArtifactInput struct { + Path string `json:"path"` + Title string `json:"title,omitempty"` + Kind string `json:"kind,omitempty"` + Focus *bool `json:"focus,omitempty"` +} +``` + +JSON schema: + +- `path` required string;描述明确要求相对工作区、文件必须已存在。 +- `title` optional string,建议上限 200 Unicode code points。 +- `kind` optional enum:`auto|text|markdown|code|html|image|pdf|csv|binary`。 +- `focus` optional boolean,缺省 true。 + +成功输出应是简短、模型可理解的纯文本或 JSON 字符串,例如: + +```json +{ + "artifact_id": "Wwm0qdzpWDlUnqEOgc0Q8w", + "path": "reports/analysis.html", + "title": "销售分析报告", + "kind": "html", + "revision": 1, + "message": "Artifact is available in the Artifacts panel." +} +``` + +### 5.2 依赖注入 + +工具不应依赖具体 WebHandler 类型,建议使用窄依赖: + +```go +type ShowArtifactDeps struct { + SessionID func() string + Project func() string + Recorder ArtifactRecorder + Service *artifact.Service + Emit func(event string, data any) +} +``` + +其中 `ArtifactRecorder.RecordArtifact` 必须返回 `error`,而不是沿用部分现有 Recorder helper 的 best-effort `void` 风格;Artifact UI 只有在 metadata 已经 durable 后才能报告成功。`artifact.Service` 位于 `internal/artifact`,拥有路径策略、MIME 分类、session 级写锁和 Registry;它只依赖一个窄 Recorder interface,不依赖 Web handler。 + +`NewShowArtifactTool(deps)` 仍是 `*tools.Env` 的方法,路径解析和执行环境来自 `Env`。这样工具逻辑可单测,且不会让 `internal/tools` 反向依赖 `internal/handler` 或 `internal/web`。 + +### 5.3 执行顺序 + +1. 检查 `Env.IsRemote()`;MVP remote 直接返回不支持错误。正常情况下 remote 根本不会注册该工具。 +2. 校验 path 非空、非绝对路径、清理后不以 `..` 开头。 +3. 以 task workspace 为 root 解析 canonical target。 +4. 使用 symlink-aware containment 检查,确认 target 位于 canonical workspace root 下。 +5. `stat` 确认存在且是 regular file;拒绝目录、socket、device、FIFO。 +6. 读取最多 512 bytes 做 `http.DetectContentType`,结合扩展名映射得到服务端 kind/media type。 +7. 应用敏感路径 deny rules。 +8. Artifact service 在 session 级 mutex 下根据 Registry 计算稳定 ID 和下一个 revision。 +9. 调用返回 error 的 Recorder method append Artifact entry;失败则不更新 Registry、不发 UI 事件,整个工具失败。 +10. append 成功后把相同 record apply 到 Registry,并刷新 `SessionMeta` 中可重建的 Artifact 摘要;任意 durable append 都令 `ArtifactUpdatedAt > ArtifactViewedAt`,因此 `ArtifactUnseen=true`。摘要写失败不回滚已落盘 entry,由下一次 task list/list API reconciliation 修复。 +11. 发出 best-effort `artifact_upserted`。Web channel 满导致事件丢弃不影响工具成功,客户端通过 list/replay 对账。 +12. 返回成功结果。 + +### 5.4 Agent 指令 + +工具 description 应包含足够使用规则,不需要给所有系统提示词加一段全局 Artifact 文本。只有 Web 工具 schema 可见时,模型才会获得说明,从根源避免 CLI/ACP 误用。 + +如果后续实践证明 description 不够,再在 `internal/command/web.go` 构建 Web agent 时注入 Web-only prompt fragment;不得修改所有 transport 共用的基础 prompt。 + +## 6. Transport 隔离 + +### 6.1 Tool catalog policy + +在 `internal/command/tool_catalog.go` 增加专用 transport slice: + +```go +var webOnlyTransports = []string{agent.ToolTransportWeb} +``` + +策略语义: + +```go +"show_artifact": scopedDirectPolicy( + "web.artifact", + allModes, + webOnlyTransports, + "read", +) +``` + +属性: + +- execution:direct; +- risk/access:read; +- modes:normal + plan; +- transports:web only; +- approval:不需要。 + +Plan mode 允许调用是有意义的:Agent 可以在研究/规划过程中生成并展示调查报告或图表。工具本身不修改文件。 + +### 6.2 候选工具注册 + +只在 `internal/command/web.go` 中: + +- `buildAllTools` 候选加入 `tenv.NewShowArtifactTool(...)`; +- plan candidate list 同样加入; +- 本地工作区才加入候选,remote 不加入。 + +明确不修改: + +- `internal/command/interactive.go` 的 all/plan tool lists; +- `internal/command/acp.go` 的 all/plan tool lists; +- ACP capability 或 JSON-RPC schema; +- TUI 组件; +- 通用 `AgentEventHandler`。 + +增加 catalog 单测,确保 Tool Search 路径即使将 `show_artifact` 候选误传给 TUI/ACP build plan,也会被 policy 拒绝。静态/eager 路径另做 CLI/TUI 与 ACP 工具列表测试,因为该路径目前不会调用 `buildCommandToolPlan`。 + +### 6.3 Approval policy + +在 `internal/runner/approval.go` 的 `noApprovalNeeded` 中显式加入 `show_artifact`。虽然工具会写入会话 metadata,但不会修改工作区或外部系统;如果把它留作未知工具,MANUAL mode 会出现与“主动展示结果”相冲突的无意义审批。 + +同时增加 approval 单测,确保 MANUAL mode 自动批准 `show_artifact`。这一 allowlist 不能替代 transport 隔离:工具是否对模型可见仍由 Web 注册列表和 catalog policy 决定。 + +### 6.4 Desktop 的 transport 归属 + +Tauri Desktop 运行 Go sidecar 并消费同一 Web UI/API,因此仍使用 `ToolTransportWeb`,不需要增加 `ToolTransportDesktop`。Desktop 特有动作由前端 `isTauri()` 和原生 command availability 决定,不影响模型工具注册。 + +## 7. Session 持久化与 Registry + +### 7.1 Session entry + +在 `internal/session/session.go` 增加 `EntryArtifact`,并给 `Entry` 增加可选字段: + +```go +ArtifactID string `json:"artifact_id,omitempty"` +ArtifactPath string `json:"artifact_path,omitempty"` +ArtifactTitle string `json:"artifact_title,omitempty"` +ArtifactKind string `json:"artifact_kind,omitempty"` +ArtifactMediaType string `json:"artifact_media_type,omitempty"` +ArtifactSize int64 `json:"artifact_size,omitempty"` +ArtifactRevision int `json:"artifact_revision,omitempty"` +ArtifactFocus bool `json:"artifact_focus,omitempty"` +``` + +Recorder 增加 `RecordArtifact(ArtifactRecord)`。Entry 不保存绝对路径、文件正文、缩略图或 base64。 + +### 7.2 回放算法 + +读取 session entries 时: + +1. 过滤 `EntryArtifact`。 +2. 按 entry 顺序处理。 +3. 以 Artifact ID 为 key,revision 高者覆盖低者。 +4. 当前文件状态在请求 list/content 时重新校验,不相信历史 size/media type。 +5. session 中有 metadata 但文件不存在时返回 `status=missing`。 + +不需要单独迁移老会话;没有 Artifact entry 的会话返回空列表。 + +### 7.3 Registry 生命周期 + +Web command 创建一个进程级 `artifact.Service`,内部按 session UUID 分片保存 Registry;这不是全局可见的领域状态,只有 Web server 与 Web-only tool 持有该 service: + +- task 创建/恢复或首次 API 请求时从 session entries hydrate; +- tool 调用时 upsert; +- inactive task 的 workspace root 从 `session.SessionMeta.Project` 解析,不相信 Browser 提交的 pwd; +- task 关闭后可以按 LRU/显式 release 释放分片;再次访问时从 JSONL 重建; +- API 如果 task runtime 未加载,从 `session.LoadSession` 临时 hydrate。 + +每个 session shard 使用 `sync.RWMutex`,Artifact service 在写锁中串行化“分配 revision → append entry → apply registry”。同一路径并发登记时 revision 必须原子递增,entry append 顺序与返回 revision 一致。读取 list/content 只在复制 metadata 时持有读锁,不能在 stat、文件流或 Cloud 上传期间持锁。 + +### 7.4 Automation/run list 的 durable unseen contract + +`artifact_upserted` WebSocket 不能承担后台 run 的未读事实。`SessionMeta` 增加以下物化字段: + +```go +ArtifactCount int `json:"artifact_count,omitempty"` +ArtifactUnseen bool `json:"artifact_unseen,omitempty"` +ArtifactUpdatedAt time.Time `json:"artifact_updated_at,omitempty"` +ArtifactViewedAt time.Time `json:"artifact_viewed_at,omitempty"` +``` + +- 每次 Artifact entry durable append 后,Registry 的 distinct ID 数写入 `ArtifactCount`,entry 时间写入 `ArtifactUpdatedAt`;只要 `ArtifactUpdatedAt > ArtifactViewedAt`,无论 foreground/background/automation 都把 `ArtifactUnseen` 置为 true。 +- `SessionMeta` 是 task/run list 的快速物化索引,不是事实来源。task list、automation run details 和 reconnect reconciliation 在 `ArtifactUpdatedAt > ArtifactViewedAt` 或摘要缺失/不一致时从 JSONL 重建并修复它。 +- 用户从 automation run header 或 task badge 打开 Artifact Viewer 后,调用 `PATCH /api/tasks/{taskID}/artifacts/viewed`;服务端把当前 latest Artifact entry 时间写入 `ArtifactViewedAt`,再把 `ArtifactUnseen=false`。重复调用幂等。 +- foreground active task 的 `focus=true` 只在 Viewer 确实打开后 clear unseen;后台 tool call 永远不能自行 clear。 +- JSONL append 成功但 `SessionMeta` 更新失败时工具仍可成功,因为 entry 已 durable;必须记录诊断并依赖上述 reconciliation 修复。不得为了重试摘要写入而追加重复 revision。 +- viewed 是 **session 级已读水位**,同一 task 的多个 tab/窗口共享;任一连接成功提交 viewed PATCH 后其他连接的 badge 也在下次 event/reconcile 清除。focus/presentation 仍是 **每个 WebSocket 连接** 的本地 UI 状态,一个 tab 打开 Viewer 不得替另一个 tab 切 panel。 +- 所有进入 Viewer 的入口统一调用一个 `openArtifact(taskID, artifactID, presentation)` action;只有 Viewer 成功挂载后该 action 才提交 viewed PATCH。列表项、工具结果卡片、automation run header、快捷键和 focus event 不得各自实现水位更新。 + +## 8. Web API + +建议路由: + +```text +GET /api/tasks/{taskID}/artifacts +GET /api/tasks/{taskID}/artifacts/{artifactID}/content +GET /api/tasks/{taskID}/artifacts/{artifactID}/download +PATCH /api/tasks/{taskID}/artifacts/viewed +``` + +### 8.1 List + +返回该 task 最新 Artifact metadata 数组。服务端在返回前对每一项做轻量 stat,更新 `status` 和 `size`;不读取正文。 + +### 8.2 Content + +Content endpoint: + +- 只接收 task ID + Artifact ID,不接收 path query; +- 从 Registry 查到相对路径,再执行实时 canonical containment 校验; +- 根据 renderer 和大小限制决定 inline 或返回 `413 artifact_too_large`; +- 设置正确的 `Content-Type`、`X-Content-Type-Options: nosniff` 和按类型的 CSP; +- 支持 HTTP range 以改善 PDF/媒体预览; +- 不把内容包进 JSON/base64。 + +### 8.3 Download + +Download 做相同的 ID、session 和路径检查,使用 `Content-Disposition: attachment`。可以允许比 inline 更大的文件,但仍需要总大小上限、取消传播和流式发送。 + +List API 本身也是显式的 re-detect 操作:每次请求都重新 stat、MIME detect、canonical containment,并返回最新 `available|missing|unsupported|too_large|error`。Viewer 的 “Check again” 只重新请求 list/content,不增加 revision,也不需要额外 mutating endpoint。若文件在读取过程中增长并超过对应上限,stream 立即终止并返回/记录 `artifact_too_large`,不能继续把剩余字节送入 renderer。 + +### 8.4 路由归属 + +Artifact API 不应复用现有 `/api/files/content?path=...`。后者是通用 Files 浏览接口,仍接受 path;Artifact endpoint 需要更严格的 session ownership 和 ID capability 边界。 + +建议新增: + +- `internal/web/artifacts.go`:HTTP handlers、content headers、range/stream; +- `internal/tools/artifact.go`:模型工具、路径校验、类型检测; +- `internal/session/artifact.go`:record DTO 或 recorder helper(如果可保持 session.go 简洁); +- `internal/web/artifact_adapter.go`:HTTP/task runtime 到 `artifact.Service` 的 hydration 适配;不得持有第二套 Registry map。session shard 与 revision 的唯一 owner 是 `internal/artifact.Service`。 + +## 9. WebSocket 事件 + +事件: + +```json +{ + "type": "artifact_upserted", + "task_id": "task-123", + "data": { + "artifact": { + "id": "Wwm0qdzpWDlUnqEOgc0Q8w", + "relative_path": "reports/analysis.html", + "title": "销售分析报告", + "kind": "html", + "media_type": "text/html", + "size": 18304, + "revision": 2, + "status": "available", + "updated_at": "2026-08-01T10:00:00Z" + }, + "focus": true + } +} +``` + +前端规则: + +- event task 等于当前 active task:upsert store;`focus=true` 时打开并选择 Artifact。 +- event task 不是 active task:不改变当前 panel,只给对应 task 标记 artifact unseen。 +- 初次连接或 reconnect 后,通过 list API/session replay 对账,不能把 WebSocket 当唯一事实来源。 +- 事件重复到达必须幂等,以 ID + revision 判断是否更新。 + +## 10. 前端状态与组件 + +### 10.1 类型与 store + +`web/src/lib/types.ts` 增加 `Artifact` 和 session entry 可选字段。Redux 建议按 task 存储: + +```ts +type ArtifactState = { + byTask: Record + order: string[] + selectedId?: string + unseenCount: number + loading: boolean + error?: string + }> +} +``` + +如果当前 store 已按 task 管理 chat state,可把 artifact state 放入对应 task state,避免再建平行生命周期。 + +### 10.2 RightPanel 改动 + +- `PanelType` 增加 `'artifacts'`。 +- `RightPanel.tsx` 增加 Artifacts tab 和 count/badge。 +- `TopBar.tsx`、`DesktopTitlebar.tsx` 的 panel menu 增加 Artifacts。 +- 新增 `ArtifactPanel.tsx`:列表、空状态、missing/too-large/unsupported 状态。 +- 新增 `ArtifactViewer.tsx`:按 kind 路由 renderer。 +- Artifact 模式支持 480px 默认宽度、更大拖拽上限和 fullscreen;其他 panel 保持原行为。 + +UI 原型冻结后的 presentation contract: + +```ts +type ArtifactPresentation = 'docked' | 'inline' | 'focus' | 'fullscreen' + +type ArtifactViewerState = { + taskId: string + selectedId?: string + presentation: ArtifactPresentation + previousPanel?: 'plan' | 'files' | 'changes' +} +``` + +presentation transition 是单一状态机,而不是四套组件各自切换: + +| 当前状态 | 事件 | 下一状态 | 恢复规则 | +| --- | --- | --- | --- | +| closed/其他 panel | open Artifact | docked | 记录 `previousPanel` | +| docked | quick look eligible | inline | selected ID 不变 | +| inline | open | docked | 复用同一 renderer cache | +| docked/inline | focus | focus | main canvas 接管 presentation | +| docked/focus | fullscreen | fullscreen | 记住调用前 presentation | +| fullscreen | Esc/close | 调用前状态 | focus 返回触发按钮 | +| focus | back | docked | 恢复 conversation scroll/focus | +| 任意 | task changed | 该 task 保存的状态或 docked | 不沿用另一 task 的 selected ID | + +- `docked` 是默认:RightPanel 上部是 Artifact index,下部是 Viewer;宽度初值 480px,上限为 viewport 的 80%。 +- `inline` 是 tool result card 的 Quick Look;只对短 Markdown/text/CSV 和图片启用,HTML/PDF 的 Open 仍进入 docked,避免在对话流中运行主动内容。 +- `focus` 把 Viewer 提升为 main 区域画布,保留 Back to conversation 与紧凑 Artifact strip;不创建第二个 renderer instance state。 +- `fullscreen` 是同一 Viewer 的 modal presentation,Esc/close 恢复上一个 presentation,focus 返回触发按钮。 +- selected ID、zoom、source/render mode 与 share state 归一化存储;presentation 组件不能各自 fetch 一份内容并造成 revision 漂移。 +- `show_artifact(focus=true)` 只把当前浏览器连接 active task 的 presentation 切到 docked;后台 task 只增加 unseen。 +- `Shift+Cmd/Ctrl+A` 打开/关闭 Artifacts。关闭后 TopBar/DesktopTitlebar 入口继续显示 count/unseen。 + +### 10.3 Renderer + +建议拆分: + +```text +web/src/components/artifacts/ + ArtifactPanel.tsx + ArtifactViewer.tsx + MarkdownArtifact.tsx + TextArtifact.tsx + HtmlArtifact.tsx + ImageArtifact.tsx + PdfArtifact.tsx + CsvArtifact.tsx +``` + +渲染要求: + +- Markdown:复用项目已有 Markdown renderer;MVP 明确关闭 raw HTML(不采用“可选 sanitizer”分支),link protocol 只允许 `https/http/mailto`,外链使用 `noopener noreferrer`。 +- Text/code:按需 fetch;超过行数后虚拟化;编码不是 UTF-8 时降级为 binary。 +- HTML:只使用 sandbox iframe,固定 `sandbox="allow-scripts"`,不加 `allow-same-origin`、`allow-forms`、`allow-popups`、`allow-top-navigation`。本地 Viewer 以受保护 content endpoint 作为 iframe `src`,该响应固定 network-blocking CSP;公开分享页解密后使用 `srcdoc`,在原文之前注入等价 CSP ``,仍保持 opaque origin,绝不把 HTML 插入主 DOM。 +- Image:使用受控 content URL;SVG 只能作为 image document,不能内联 DOM。 +- PDF:优先浏览器内建 viewer;不可用时显示下载/外部打开。 +- CSV/TSV:有界解析,默认最多 10,000 行或 5 MiB;展示截断提示。 +- Binary/Office:显示 metadata,Web 下载,Desktop 外部打开。 + +### 10.4 工具结果卡片 + +`internal/handler/web.go` 的 tool display metadata 增加 `show_artifact` case,使 `web` 的工具消息可以识别 `artifact_id`。前端用专用紧凑卡片显示“Open artifact”,点击时只按 ID 打开,不解析路径。 + +automation run replay 使用同一个 card renderer。`AutomationRunReplay` header 增加 Artifact count/unseen 入口,点击后将该 run 的 session ID 设为 Viewer task ID;它不需要把 automation 变成当前 chat task,也不会触发后台 focus。 + +## 11. Desktop 原生能力 + +Desktop 增加两个 Tauri commands: + +```text +open_artifact(task_id, artifact_id) +reveal_artifact(task_id, artifact_id) +``` + +command 通过 sidecar 的受保护 Desktop bridge 用 ID 换取已验证 canonical path。具体合同: + +1. Tauri 启动 sidecar 前生成 32-byte 随机 token,以 base64url-no-padding 编码,只通过 child env `JCODE_DESKTOP_BRIDGE_TOKEN` 交给 Go sidecar,并保存在 Rust managed state;不得进入 WebView、localStorage、前端启动参数或日志。 +2. Go command startup 只读取一次该 env,decode 后存入进程内 `ServerConfig.DesktopBridgeToken`,随即 `os.Unsetenv("JCODE_DESKTOP_BRIDGE_TOKEN")`;只在 token 存在且恰为 32 bytes 时注册 private resolve route。比较使用 constant-time compare,错误统一返回 404/unauthorized,不记录 header/token 值。所有 `execute`/tool/sidecar 子进程的环境构造还必须显式 scrub 该变量,不能只依赖一次 `Unsetenv`。 +3. Rust command 只接受 `task_id + artifact_id + action`,向 loopback `POST /api/desktop/artifacts/{taskID}/{artifactID}/resolve` 发送 `Authorization: Bearer `。普通 Browser API client 和 WebView JavaScript 都不持有该 header。 +4. Go handler 验证 bridge token、session ownership、local workspace、canonical containment 和 regular file,才向 Rust 响应 canonical path;token 缺失、长度错误、比较失败或 Desktop mode 未启用时都不得返回路径。 +5. Rust 收到 path 后调用平台 opener/reveal;Browser Web 没有 Tauri invoke 能力,也拿不到 bridge token。 + +不得暴露通用 `open_path(path)`,也不得把 canonical path 返回普通 Browser API 或写入 session/WS。 + +执行前必须再次确认: + +- 当前是本地 task; +- Artifact ID 属于 task; +- 文件仍在 task workspace 内; +- target 是 regular file; +- path 没有发生 symlink swap/escape。 + +操作系统行为: + +- macOS:默认应用打开;Finder reveal。 +- Windows:ShellExecute;Explorer select。 +- Linux:`xdg-open`;可用文件管理器 reveal 或退化为打开父目录。 + +前端通过现有 `web/src/lib/useDesktop.ts` 暴露 typed methods。Browser Web 不渲染 reveal/open-native 按钮,只显示 download。 + +## 12. 安全设计 + +### 12.1 路径与 symlink + +不能只使用 `filepath.Rel` 做 lexical containment。最低要求: + +1. canonicalize workspace root; +2. canonicalize target 的已有路径; +3. 再次 `filepath.Rel(canonicalRoot, canonicalTarget)`; +4. 拒绝 `..`、绝对 rel、非 regular file; +5. content/open 时重复检查,不能只相信注册时结果。 + +如果平台支持,content 读取采用 openat/no-follow 风格,减少检查后替换的 TOCTOU 窗口。否则需要在打开后对 fd 做 stat,并记录剩余风险。 + +### 12.2 敏感文件 + +Artifact 只用于生成结果,不应成为绕过 Files 访问控制的通道。MVP 建议拒绝: + +- `.git/**`、`.jcode/**`; +- 已知 credential/key 文件,例如 `.env`、`*.pem`、`*.key`、SSH key; +- socket、device、FIFO 和目录; +- workspace 外任意文件。 + +deny rule 应集中在服务端并单测。未来若需要预览 `.env.example`,应精确 allow,而不是取消整个规则。 + +### 12.3 MIME 与主动内容 + +- `kind` 只是 Agent hint,服务端扩展名 + sniff 结果为准。 +- 所有内容响应加 `nosniff`。 +- HTML 默认 CSP:`default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'`。 +- HTML 不允许同源身份,因此即使存在脚本也不能读父页面、cookie、localStorage 或调用 JCode API。 +- 禁止在主 React DOM 中直接注入 Artifact HTML/SVG。 +- Markdown raw HTML 默认关闭。 + +MVP HTML 应定位为“单文件、自包含预览”。需要多文件静态站点时,后续设计单独的 asset manifest 和 capability URL,不能放宽为任意 workspace 路由。 + +### 12.4 大小与资源限制 + +建议默认: + +| 内容 | Inline 上限 | 行为 | +| --- | ---: | --- | +| text/markdown/code/csv/html | 5 MiB | 超限不 inline | +| image/pdf | 25 MiB | 超限不 inline | +| download | 250 MiB | 超限提示外部打开(Desktop) | + +服务端流式返回并尊重 request cancellation。CSV 解析放在 Web Worker 或做渐进/有界解析,避免阻塞 React 主线程。 + +### 12.5 Web 与 Desktop 信任边界 + +- Browser Web 永远看不到绝对路径。 +- WebView 不能提交任意路径给 Rust opener。 +- Artifact ID 不是永久授权;每次操作都要做 session ownership 和实时路径验证。 +- 日志只记录 ID、相对路径和错误,不记录内容。 + +## 13. Remote、Automation 与 Cloud + +### 13.1 Remote workspace + +当前通用 Executor 的 `ReadFile` 不提供安全的有界流式 metadata/read 契约,现有 `/api/files` 也主要面向本地文件。MVP 在 `Env.IsRemote()` 时不将 `show_artifact` 加入工具候选。 + +后续启用 remote 前需要: + +- Executor 增加 stat size/media metadata; +- range/stream 或明确的 bounded read; +- remote canonical path containment; +- download cancellation 与响应上限; +- Desktop native open 明确禁用,仅允许 Web stream/download。 + +### 13.2 Automation/background run + +Web automation 可以登记 Artifact,因为 session entry 是持久化事实;但 WebSocket 事件只能对当前 active task 执行 focus。后台 task 只增加 unseen count。没有任何连接时,工具仍应成功,因为 Recorder 已写入。 + +### 13.3 Cloud Artifact sharing(Phase 3) + +Phase 3 增加的是用户显式触发的“分享一个固定 revision”,不是 Artifact 自动同步,也不是 `show_artifact` 的副作用。可复用能力包括 JCode 的 device credentials/Cloud client,以及 Cloud 的 device principal、S3-compatible object store 和 bounded attachment proxy;通用 Artifact share 仍使用独立领域对象,不能复用 run diff/review artifact。 + +#### 13.3.1 产品、登录与隐私边界 + +- 前端只在 `/api/cloud/status.logged_in=true` 时渲染 Share;未登录时没有 disabled button、登录提示或分享 API 请求。 +- 登录只意味着可以发起分享。分享不读取或修改 session sync、`cloud-sessions.json`、relay `auto_connect` 或 transcript。 +- `auto_connect=false` 时仍使用 device token 发起一次性 HTTP 请求;Cloud 不可达/401 只更新 share error,不影响 Viewer 和本地 Registry。 +- 不新增 `share_artifact` 模型工具;CLI/TUI 与 ACP 没有 Artifact 分享入口。 +- 完整 URL 只在用户明确创建/复制 share 时由本地 API 返回当前 Web/Desktop 页面;不得进入 JSONL、WebSocket、Cloud event、日志、错误上报或遥测。 + +#### 13.3.2 JCode 本地 API、快照与 secret store + +```text +POST /api/tasks/{taskID}/artifacts/{artifactID}/shares +GET /api/tasks/{taskID}/artifacts/{artifactID}/shares +DELETE /api/tasks/{taskID}/artifacts/{artifactID}/shares/{shareID} +``` + +POST body: + +```json +{"revision":3,"expires_in_seconds":604800} +``` + +执行流程: + +1. 校验实时 Cloud credentials 与请求中的 revision;stale UI 返回 `artifact_revision_conflict`。 +2. 重新做 session ownership、canonical containment、sensitive-path、regular-file 和 25 MiB 上限检查。 +3. 有界读取 snapshot,计算 plaintext SHA-256,并用读取前后 stat/file identity 防止 TOCTOU;变化返回 `artifact_changed`。 +4. 用 CSPRNG 生成独立 32-byte Artifact Share Secret,变量和 JSON 字段统一命名 `shareKey` / `share_key`,禁止使用其他缩写。 +5. 创建 Cloud intent,取得 server-generated `share_id`;用该 ID 参与 AAD 后分别加密 metadata 和 snapshot。 +6. PUT ciphertext,再调用 complete;任何失败都 best-effort DELETE intent,且不写成功状态。 +7. Cloud 只返回 base URL;JCode 在内存中组合 `base_url#k=v1.` 并仅响应当前显式 POST。 +8. 成功后本地记录 share metadata 与 secret,Viewer 可在重启后 copy/revoke;旧 share 与新 revision 独立。 + +本地持久化分离: + +- `~/.jcode/artifact-shares.json`(0600)只存 `share_id/cloud_url/base_url/artifact_id/revision/plaintext_sha256/expires_at/status`,不存 key 或完整 URL。 +- `share_key` 存现有 Cloud secret service `net.j-code.jcode.cloud` 下的独立 account `artifact-share-secrets`;沿用 `JCODE_CLOUD_SECRET_BACKEND=file` 测试开关,file backend 为 `~/.jcode/cloud.artifact-share-secrets.json`(0600),不得新增第二套后端选择逻辑。 +- 普通 logout 不删除 share secret,也不撤销链接;重新登录同一 Cloud account 后可恢复 copy/revoke。显式 `cloud forget` 删除本地 share secrets,但不伪装成 Cloud revoke。 +- Cloud owner list 可恢复管理 metadata;本地 key 缺失时仍可 revoke,但 UI 必须显示“secret unavailable”,不能重建完整链接。owner list 必须按 authenticated user 查询,不能只按当前 device 查询,否则 device 重登/替换后无法恢复管理。 + +#### 13.3.3 Cloud device API 状态机 + +所有 owner mutation/list route 使用现有 `authed` middleware 后再 `requireDevice`,严格 JSON decode: + +```text +POST /internal/v1/device/artifact-shares/intents +PUT /internal/v1/device/artifact-shares/{shareID}/content +POST /internal/v1/device/artifact-shares/{shareID}/complete +GET /internal/v1/device/artifact-shares?artifact_id={opaqueID} +DELETE /internal/v1/device/artifact-shares/{shareID} +``` + +Intent request/response: + +```json +{ + "protocol": "jcode-artifact-share-v1", + "artifact_id": "Wwm0qdzpWDlUnqEOgc0Q8w", + "revision": 3, + "ciphertext_size": 18332, + "expires_in_seconds": 604800 +} +``` + +```json +{ + "share_id": "opaque-128-bit-id", + "upload_url": "/internal/v1/device/artifact-shares/.../content", + "complete_url": "/internal/v1/device/artifact-shares/.../complete", + "base_url": "https://cloud.example/s/opaque-128-bit-id", + "expires_at": "2026-08-08T00:00:00Z" +} +``` + +状态机:`pending -> uploading -> uploaded -> complete -> revoked`;`expired` 是由时间判定并由 GC 物化的终态。唯一公开可读状态是 `complete && now < expires_at && revoked_at IS NULL`。 + +| 操作 | 前置状态 | 成功后 | 重试/并发合同 | +| --- | --- | --- | --- | +| create intent | 无 | pending | 原子执行 per-user active count/bytes quota;生成 `intent_expires_at=now+1h`,不分配可写 object key | +| claim content PUT | pending,或上传 lease 已过期的 uploading | uploading | CAS 递增 `upload_generation`(最多 3),生成随机 `upload_claim_id`,写 `upload_claimed_at`、`upload_lease_expires_at=now+5m` 和 generation-specific server-only object key;其他并发 PUT 返回 409 | +| upload success | 当前请求持有 uploading lease | uploaded | Cloud proxy 计算 digest/size 后以 `state + upload_claim_id + upload_generation` 做 CAS;CAS 失败必须删除本 claim 的独立 object,不能改 DB digest | +| upload failure/cancel | 当前请求持有 uploading lease | pending | best-effort 删除本 generation object,并以 claim ID 释放 claim;总重试最多 3 次且受 intent TTL 限制 | +| complete | uploaded | complete | body digest/size/metadata 完全相同的重复 complete 返回同一结果;不同 payload 返回 409,不能覆盖 | +| revoke/delete | pending/uploading/uploaded/complete | revoked | 先 CAS `revoked_at`,重复 DELETE 204;in-flight upload 的后续 CAS 必须失败并删除对象 | +| expiry/GC | 非 revoked 且时间到期 | expired/revoked materialization | pending/uploading/uploaded 使用 `intent_expires_at`;complete 使用 `expires_at`;先使 read 404,再删除 object/释放 quota | + +- content PUT 必须 ownership 匹配、state 可 claim、`Content-Length == ciphertext_size`;Cloud 通过 `MaxBytesReader + LimitReader + TeeReader(SHA-256)` **代理**到单对象存储,不把 presigned URL、object key 或 S3 redirect 返回客户端。 +- 每次 claim 使用不同 object key,例如 server 生成的 `artifact-shares/{share_id}/{upload_generation}`;旧 claim 与新 claim 不能覆盖同一对象。takeover 后旧请求即使晚到,其 uploaded CAS 也因 claim/generation 不匹配而失败,只删除自己的 generation object,不能删除新 generation object。 +- 第 3 个 generation 仍失败或 server 返回 `artifact_share_retry_exhausted` 时,JCode 不在旧 intent 上继续 PUT;它 best-effort revoke 旧 intent,并仅在用户点击 Retry 后创建全新的 intent/share ID。自动无限重建 intent 被禁止,避免绕过 quota 与制造 orphan。 +- upload 成功记录 server-computed ciphertext digest 和 `uploaded_at`;读取超过声明长度、客户端断开或 object store 失败都按 upload failure 处理。 +- complete body 只含 `ciphertext_sha256` 与有界 `encrypted_metadata` JSON envelope;必须与 upload 记录一致,才原子切为 complete。 +- intent/上传 lease/完成过期都由同一个周期 GC 扫描;进程重启后残留 pending/uploading/uploaded 不能永久占 quota 或留下 orphan object。 +- public read 在 complete 之前以及 expired/revoked 之后统一 404。 +- DELETE 先原子写 `revoked_at` 使 public read 立即失效,再由 GC 删除 object;撤销幂等。 + +#### 13.3.4 Cloud domain、migration 与 object lifecycle + +新增 migration `0071_device_artifact_shares.sql` 和独立 `domain.ArtifactShare`: + +```text +device_artifact_shares( + id PK, user_id FK users ON DELETE CASCADE, + device_id FK devices ON DELETE SET NULL, + artifact_id, revision, protocol, state, + object_key UNIQUE, upload_generation, upload_claim_id, + ciphertext_size, ciphertext_sha256, + encrypted_metadata, intent_expires_at, + upload_claimed_at, upload_lease_expires_at, + expires_at, uploaded_at, completed_at, + revoked_at, object_deleted_at, created_at +) +``` + +- `artifact_id` 是 JCode 生成的 opaque ID;不存 local session ID、title、relative path、MIME、plaintext size 或 plaintext digest。 +- `encrypted_metadata` 最大 64 KiB;ciphertext object 最大为 25 MiB plaintext + v1 envelope overhead。 +- memory store 与 PG store 实现同一 create/claim/uploaded/complete/list/revoke/GC interface,并覆盖 CAS 冲突、claim/generation 绑定、complete payload idempotency、lease takeover、intent TTL、orphan object 和 ownership 测试。 +- 复用现有 `AttachmentObjectStore` 的 per-object PresignPut/PresignGet/Stat/Delete seam,不向 device/browser返回 object key 或 S3 credentials。 +- `upload_generation` 最大为 3,generation object key 可由 `share_id + generation` 确定性重建且永不暴露。reconciler 对 revoked/expired row 枚举 `1..upload_generation` 删除所有代际对象,再写 `object_deleted_at`;失败保留 row 与 generation 上界供下轮重试。历史 row 暂不硬删除,以支持 owner 生命周期 UI 和审计。 + +#### 13.3.5 `jcode-artifact-share-v1` 加密合同 + +- `share_key`:32 random bytes;fragment 编码为无 padding base64url:`#k=v1.<43-char-key>`。 +- 算法:AES-256-GCM;metadata/content 分别使用独立 random 12-byte nonce,tag 为 GCM 标准 16 bytes。 +- AAD 是 UTF-8 精确字节:`jcode-artifact-share-v1\n{share_id}\n{part}\n{artifact_id}\n{revision}\n{plaintext_length}`,其中 `part` 只能是 `metadata` 或 `content`,数字使用无前导零十进制。 +- metadata plaintext 是 UTF-8 JSON `{title,relative_path,media_type,kind,size}`。complete request 中 `encrypted_metadata` **明确是 JSON object**:`{"nonce":"","ciphertext":"","plaintext_length":123}`;它作为有界 DB JSON/JSONB 字段保存,不上传对象存储。public metadata endpoint 原样返回该 JSON envelope。 +- content wire format **明确是 binary body** `12-byte nonce || AES-GCM ciphertext || 16-byte tag`;不套 JSON、不做 base64。Cloud 只校验总长度与 ciphertext SHA-256,public content endpoint 以 `application/octet-stream` 代理原始字节。 +- v1 的硬恒等式是 `ciphertext_size = content_plaintext_length + 12 + 16`,其中 `ciphertext_size` **包含 nonce 与 tag**,intent 的 `ciphertext_size`、HTTP `Content-Length`、实际 body byte length 三者必须完全相等。 +- 解密端从 metadata envelope 的 `plaintext_length` 重建 metadata AAD;content plaintext length 固定由 `ciphertext_size - 12 - 16` 推导并重建 content AAD。声明/实际长度不等、长度小于 28、metadata nonce 不是 12 bytes、base64 解码长度不匹配或 GCM tag 失败都拒绝。 +- metadata/content 解密失败统一显示 `artifact_decrypt_failed`,不回传 key、AAD 或 plaintext 片段。 +- shared test vector 固定 share ID、artifact ID、revision、key、nonce、plaintext、AAD 和输出,Go 与 WebCrypto 两端都必须读取同一文件。 + +v1 单 envelope 的 plaintext 上限为 25 MiB,不支持 range。未来大文件必须新增 `jcode-artifact-share-v2` chunked envelope,不能改变 v1 nonce/AAD/wire format。 + +#### 13.3.6 Public share page 与 unauthenticated read + +```text +GET /s/{shareID} # Console SPA public route +GET /api/v1/shared-artifacts/{shareID} # encrypted metadata/lifecycle +GET /api/v1/shared-artifacts/{shareID}/content # ciphertext stream +``` + +- `/s/:shareID` 在 React `App` 最外层绕过 `OnboardingGate`、`AppShell` 和 authenticated API providers;不能因为 `/api/v1/me` 为 401 跳转到登录。 +- nginx 为 `/s/` HTML 增加 page-scoped CSP、`Referrer-Policy: no-referrer`、`X-Content-Type-Options: nosniff`、`Cache-Control: no-store` 和 `frame-ancestors 'none'`。 +- public metadata/content API 不接受 fragment/key,不设置身份 cookie;metadata 响应只含 `share_id/protocol/artifact_id/revision/encrypted_metadata/ciphertext_size/ciphertext_sha256/expires_at`,content 响应只含 raw ciphertext。两者固定 `Cache-Control: no-store`、`Referrer-Policy: no-referrer`、`X-Content-Type-Options: nosniff`,且不返回 object key/presigned URL。 +- page 从 `location.hash` 解析 key 后立即用 `history.replaceState` 清理地址栏 fragment;key 只驻留内存。 +- WebCrypto 解密后复用安全 renderer contract:Markdown 禁 raw HTML;HTML 使用第二层 `sandbox="allow-scripts"` opaque-origin `srcdoc` iframe,并在原文前注入 network-blocking CSP meta;SVG 不进主 DOM;Office/unknown 只 client-side decrypt download。 +- plaintext/Blob 不写 IndexedDB、Cache Storage、service worker、日志或错误遥测;unmount 时 revoke Blob URL。 +- expired、revoked、not-found 对公开访问统一返回 404,降低枚举信息;owner device API 仍返回精确 lifecycle。 + +#### 13.3.7 跨仓库发布顺序 + +Cloud orchestrator 使用严格请求解码,因此先落 Cloud:migration/domain/store/API/object GC/public share page/test vector,再落 JCode client/UI。Artifact ciphertext 不走 device relay command/event,也不占用现有 32 MiB command body。 + +发布门禁顺序:Cloud 分支单测与 PG suite → build/push Cloud images → company K8s migration/rollout/public ciphertext smoke → JCode 本地/浏览器 E2E → 真实 Kimi 生成与 `show_artifact` → logged-in share/revoke against deployed Cloud。Cloud 失败时 JCode local Artifact 必须继续通过。 + +## 14. 失败语义 + +工具错误必须帮助 Agent 自我修正: + +- `artifact path must be relative to the workspace` +- `artifact file does not exist: reports/result.html` +- `artifact path resolves outside the workspace` +- `artifact path is a directory, expected a regular file` +- `artifact type is blocked because it may contain credentials` +- `artifact preview is not available for remote workspaces yet` +- `artifact metadata could not be recorded; retry after the session recorder recovers` + +HTTP 使用稳定错误码字段,例如 `artifact_not_found`、`artifact_missing`、`artifact_too_large`、`artifact_forbidden`、`artifact_unsupported`。前端按 code 呈现,不解析英文 message。 + +分享路径额外使用:`cloud_not_logged_in`、`artifact_revision_conflict`、`artifact_changed`、`artifact_share_too_large`、`artifact_share_quota_exceeded`、`artifact_share_unavailable`、`artifact_share_conflict`、`artifact_decrypt_failed`。Cloud 401 由 JCode 映射为 `cloud_not_logged_in` 并刷新 status,但不得删除本地 Artifact;公开 share 对 not-found/pending/revoked/expired 统一 404。 + +## 15. 测试方案 + +### 15.1 Go 单元测试 + +`internal/tools/artifact_test.go`: + +- 正常相对路径登记; +- 缺省 title/kind/focus; +- 同路径 revision 与稳定 ID; +- 不存在、目录、绝对路径、`../`; +- symlink 逃逸; +- blocked credential 路径; +- MIME 与扩展名冲突; +- recorder 失败时不 emit; +- emit 失败时记录仍成功; +- remote 拒绝。 + +`internal/command/tool_catalog_test.go`: + +- Web normal/plan 包含; +- TUI normal/plan 不包含; +- ACP normal/plan 不包含; +- Web remote candidate 不加入。 + +`internal/runner/approval_test.go`: + +- MANUAL mode 中 `show_artifact` 自动批准; +- 加入 allowlist 不会让名称相近的未知工具自动批准。 + +`internal/web/artifacts_test.go`: + +- task/session ownership; +- list hydration; +- missing 状态; +- forged ID; +- content type、CSP、nosniff; +- range/cancellation; +- inline size cap; +- 注册后 symlink swap。 +- 并发同路径登记严格得到连续 revision;recorder append 失败不污染 Registry。 +- inactive/automation session 从 JSONL + SessionMeta.Project hydrate,Browser 提交的伪造 pwd 不生效。 +- background/automation registration 持久化 `ArtifactCount/ArtifactUnseen/ArtifactUpdatedAt`;进程重启后从 JSONL 修复缺失摘要,viewed PATCH 幂等 clear 且不切换 foreground task。 +- “Check again” 重新检测 missing/MIME/size;读取过程中越过上限会停止 stream,不泄漏后续字节。 + +所有涉及 config HOME 的测试必须 `t.Setenv("HOME", t.TempDir())`。 + +### 15.2 Frontend 测试 + +- reducer 按 ID/revision 幂等 upsert; +- active task focus 与 background no-focus; +- reconnect list reconciliation; +- panel count/unseen/missing 状态; +- renderer routing; +- HTML sandbox attributes; +- Desktop-only action visibility; +- tool result card 通过 ID 打开。 +- Docked / Inline / Focus / Fullscreen 共享 selected ID 与 renderer state;Esc/focus restore。 +- 未登录时 Share DOM 不存在;logged-in 的 uploading/shared/stale/expired/revoked 状态不覆盖本地 Viewer。 +- automation run Artifact 入口与 unseen clear,不切换 foreground chat task。 + +### 15.3 Desktop 测试 + +- 合法 Artifact 的 open/reveal; +- forged task/artifact ID; +- 删除后操作; +- symlink escape; +- Browser 环境无法调用 native action。 +- 缺失/错误/非 32-byte `JCODE_DESKTOP_BRIDGE_TOKEN` 时 private route 不注册或 resolve 失败;constant-time auth path 不把 token 写日志,WebView 永远拿不到 canonical path。 +- Go startup 读取后 process env 被清除,`execute`/tool 创建的子进程即使显式打印 env 也看不到 bridge token。 + +### 15.4 端到端验收 + +至少覆盖:HTML 报告、Markdown 文档、PNG、PDF、CSV、Office fallback;页面刷新恢复;会话切换隔离;后台任务不抢焦点;CLI/ACP 工具 schema 快照不包含 Artifact。Browser E2E 在 1440×900 与 1024×768 检查三种 presentation、keyboard/focus、200% zoom 和 `prefers-reduced-motion`。 + +真实模型门禁使用 JCode 配置中的 Kimi 模型启动 `jcode web`,从网页发送“生成自包含 HTML 报告与 Markdown/CSV 摘要”;断言真实 tool call 名为 `show_artifact`、Viewer 自动打开、刷新后恢复,且录制的 JSONL/WS 不含文件正文或绝对路径。 + +### 15.5 Phase 3 Cloud 分享测试 + +- logged out 时 Share action 不渲染,`show_artifact` 不产生 Cloud 请求; +- logged in + session sync off 仍可显式分享,sync store 保持不变; +- token expired、Cloud offline、upload retry 不影响本地 Registry; +- 上传对象和 metadata 不包含 title/path/content 明文; +- URL fragment 从不进入 Cloud request、日志、analytics 或 referrer; +- Cloud/JCode/share-page 使用同一 E2EE test vector; +- 上传过程中本地文件变化会失败,不生成可读 share; +- revision immutable、expiry、revoke、object GC; +- share size/quota、伪造 ID、跨用户读取与 object key 泄露测试。 +- intent/upload/complete 的 strict decode、state conflict、并发 claim、5 分钟 lease takeover、重复 complete/revoke 幂等性;重复 complete payload 不同返回 409。 +- lease takeover 使用不同 generation object key;旧 PUT 晚到时 claim/generation CAS 失败且只删除旧对象,active object 与 DB digest 永远一致。 +- pending/uploading/uploaded 超过 1 小时 intent TTL 后由 GC 释放 quota 并删除 orphan object;in-flight upload 与 revoke 竞争时不可复活 share。 +- metadata JSON envelope 与 content raw binary wire 分别做 golden HTTP 测试;断言 `ciphertext_size = plaintext_length + 28 = Content-Length = actual body length`,public content 必须由 Cloud proxy 且无 redirect/presigned URL。 +- public endpoint 在非 complete/revoked/expired 时统一 404;所有公开响应验证 `no-store/no-referrer/nosniff`。 +- keyring/file-backend 分离:metadata 文件无 key/full URL,logout 保留、forget 删除本地 secret,Cloud 永远无法恢复 key。 +- Go 与 WebCrypto 读取同一 `jcode-artifact-share-v1` vector,逐字节验证 AAD、nonce、ciphertext 和 tag。 +- K8s migration/rollout 后实际上传 ciphertext,下载对象与 API/Pod 日志做 plaintext canary 扫描;公开页面在未登录浏览器完成解密、预览、下载、expiry 与 revoke。 + +## 16. 实施顺序 + +### Step 1:领域与 transport boundary + +- 定义 model、registry interface、tool schema。 +- 增加 Web-only tool policy 和 registration tests。 +- 确认 CLI/ACP schema 快照不变。 + +### Step 2:持久化与服务端 + +- 增加 session artifact entry/recorder。 +- 实现 registry hydration/upsert。 +- 实现 list/content/download API。 +- 实现 `artifact_upserted` event。 + +### Step 3:Web UI + +- 增加 state、WS bridge、API client。 +- 增加 Artifacts panel、Viewer 和工具结果卡片。 +- 完成 HTML/Markdown/text/image/PDF/CSV renderer。 + +### Step 4:Desktop + +- 增加安全的 open/reveal commands。 +- 在 Tauri 环境显示原生 actions。 + +### Step 5:Hardening + +- 完成安全测试、大文件和 reconnect 测试。 +- 跑 `go test ./...`、`make lint-web`、`make build-web`、`make lint`。 + +### Step 6:Phase 3 Cloud 分享(跨仓库) + +- 先在 Cloud sibling repository 实现 schema、device API、object lifecycle、E2EE test vectors 和分享页。 +- 完成 Cloud unit/PG/browser 测试并部署 K8s,确认 strict contract 与 public route 可用。 +- 再在 JCode 实现 logged-in gate、本地 share service/secret store 和 Viewer actions。 +- 验证未登录、session sync off、connector off、离线和 token 过期降级。 +- 以真实 Kimi Web session 创建 Artifact,并对已部署 Cloud 完成 share/copy/open/revoke 的跨仓库 E2E。 + +## 17. 预计改动地图 + +后端: + +- `internal/artifact/*` +- `internal/tools/artifact.go` +- `internal/tools/artifact_test.go` +- `internal/session/session.go` +- `internal/web/artifact_adapter.go`(只适配 `artifact.Service`,不拥有 Registry) +- `internal/web/artifacts.go` +- `internal/web/server.go` +- `internal/handler/web.go` +- `internal/command/tool_catalog.go` +- `internal/command/web.go` +- `internal/runner/approval.go` +- `internal/cloud/artifact_share_client.go` +- `internal/cloud/artifact_share_store.go` + +前端: + +- `web/src/lib/types.ts` +- `web/src/lib/api.ts` +- `web/src/lib/ws.ts` +- `web/src/app/wsBridge.ts` +- `web/src/app/store.ts` +- `web/src/App.tsx` +- `web/src/components/RightPanel.tsx` +- `web/src/components/TopBar.tsx` +- `web/src/components/DesktopTitlebar.tsx` +- `web/src/components/artifacts/*` + +Desktop: + +- `desktop/src-tauri/src/*` 中的 bridge token、command 注册与原生 open/reveal +- `web/src/lib/useDesktop.ts` + +Cloud sibling repository: + +- `orchestrator/internal/store/migrations/0071_device_artifact_shares.sql` +- `orchestrator/internal/domain/artifact_share.go` +- `orchestrator/internal/store/artifact_shares*.go` +- `orchestrator/internal/api/device_artifact_shares.go` +- `orchestrator/internal/api/shared_artifacts.go` +- `orchestrator/internal/reconcile/*` 的 object GC wiring +- `console/src/pages/SharedArtifactPage.tsx` 与安全 renderer +- `console/nginx/default.conf.template` 的 `/s/` headers +- Cloud/JCode 共用的 protocol test vector + +不应改动: + +- `internal/command/interactive.go` 的 tool registration +- `internal/command/acp.go` 的 tool registration/protocol +- `internal/handler/handler.go` 的通用 handler contract +- `internal/tui/*` + +## 18. 风险与取舍 + +| 风险 | 取舍/缓解 | +| --- | --- | +| 任意 HTML 带来 XSS 或本地 API 访问 | opaque-origin sandbox + CSP + 禁止同源/导航/联网 | +| Artifact 变成第二个 Files 浏览器 | 只接受显式登记,不扫描工作区 | +| ephemeral WebSocket 丢失 | JSONL 是事实来源,reconnect 通过 list/replay 对账 | +| Desktop opener 扩大本地执行面 | 只接受 task + Artifact ID,每次重新校验,不提供通用 path opener | +| 文件变化但本地 revision 不自动增加 | list/content 实时 stat 并明确本地 revision 是登记代数;Cloud 分享前读取有界 snapshot 并校验文件 identity/digest | +| 后台任务抢 UI | 只有 active task 可响应 focus,其他任务只标未查看 | +| transport 以后重构误暴露到 ACP/TUI | 静态路径用注册列表测试,Tool Search 路径用 catalog policy,最终再做 schema snapshot test | +| Remote 读取一次性吃入大文件 | MVP 不注册;先补 bounded streaming Executor 契约 | +| 登录后自动上传造成隐私意外 | `show_artifact` 永远本地;只有用户点击 Share 才上传 | +| 公开分享无法使用账号 CEK | 每个 share 使用独立 Artifact Share Secret,key 只放 URL fragment,Cloud 只存 ciphertext | +| 分享链接内容随 workspace 变化 | 上传绑定 revision + digest,生成不可变 Cloud 快照 | +| Cloud sync 与单文件分享耦合 | share 记录不依赖 `device_sessions`,不得修改 session sync 开关 | +| fragment key 泄露到日志/分析 | no-referrer + 禁止记录完整 URL + 前端错误上报清洗 + e2e 测试 | + +## 19. 明确保留的后续问题 + +以下不阻塞 MVP,但实施 Phase 2/3 前需要单独决策: + +- 是否允许用户从 Files 面板手动“标记为 Artifact”; +- Artifact revision 是否需要内容快照,还是继续指向工作区当前文件; +- 多文件 HTML/site 是否采用 manifest; +- XLSX 是浏览器内渲染还是只提供 Desktop 外部打开; +- Cloud 分享链接是否需要密码、一次性访问或账号访问控制等额外策略; +- 25 MiB 以上 Artifact 的 chunked E2EE/range 协议; +- Remote Executor 的 range/stream 标准接口。 diff --git a/internal-doc/artifacts-prd-review.md b/internal-doc/artifacts-prd-review.md new file mode 100644 index 00000000..7308d509 --- /dev/null +++ b/internal-doc/artifacts-prd-review.md @@ -0,0 +1,66 @@ +# JCode Artifacts PRD 双模型评审 + +- 评审日期:2026-08-01 +- 被评审文档:`internal-doc/artifacts-prd.md` +- Kimi CLI:`kimi-code/kimi-for-coding-highspeed`(OAuth 默认模型) +- Grok CLI:请求模型 `grok-4.5` +- 评审方式:两个 CLI 独立只读仓库与 PRD,不允许修改文件 +- 原始结论:Kimi = NO-GO;Grok = NO-GO + +## 1. 共识结论 + +两位评审都认为产品方向正确,尤其认可以下边界: + +- Artifact 必须显式登记,不能扫描整个工作区; +- Web/Desktop 共用产品 UI,CLI/TUI/ACP 不暴露工具; +- `show_artifact` 只做本地交付,不因 Cloud 登录自动上传; +- Cloud 分享由用户显式触发,独立于 session sync; +- Cloud 分享必须是不可变 revision 快照,Cloud 只保存密文。 + +当前版本仍不能冻结为工程合同,必须先修复以下 P0: + +1. 明确本地 revision 只是“登记代数”,不是内容快照;本地 Viewer 始终读取工作区当前文件。 +2. 明确 Web 的 `task_id` 与 session UUID 的映射,避免 API、JSONL 和前端 store 分裂。 +3. 补齐 automation/background run 的工具注册、未读入口和回放 UI。 +4. 把真实 Kimi Web E2E 与 Cloud K8s 部署写成发布门禁。 +5. Artifact 分享密钥不能简称 ASK;Cloud 已使用 ASK 表示 Account Sync Key。 +6. PRD 必须写清 URL fragment `share_key`、Cloud 无明文密钥、禁止完整分享 URL 进入日志/事件/session。 + +## 2. Kimi 评审重点 + +- MVP 与 Phase 3 内容混写,需要显式阶段标签和分开的验收门禁。 +- 当前 `RightPanel` 只有 Plan/Files/Changes 且宽度上限 600px;Artifact list 与沉浸式 Viewer 应拆成可验证的两种呈现状态。 +- 自动化能持久化 Artifact 不等于用户在 automation run 页面能发现它;需要写清入口。 +- HTML/SVG/Markdown 的 sandbox、CSP、`nosniff` 和 raw HTML 策略必须提升到 PRD,而不是只存在设计文档。 +- CLI/TUI/ACP 负面验收需要同时覆盖 eager/static list 与 Tool Search catalog。 +- Cloud 分享必须在上传期间锁定 revision/digest,防止生成半旧半新的密文对象。 + +## 3. Grok 评审重点 + +- 本地 revision 与 Cloud immutable snapshot 是两种语义,必须用表格明确区分。 +- `Artifact Share Key (ASK)` 与现有 Account Sync Key 冲突,统一改为 `Artifact Share Secret` / `share_key`。 +- 分享动作只依赖有效 device 登录,不依赖 relay online、`auto_connect` 或 session sync。 +- 分享前后 `cloud-sessions.json` / `/api/cloud/sync` 状态必须保持不变,作为隐私负面验收。 +- 未登录本地 happy path、后台 automation、同路径多 revision、大文件、远程切换和多窗口 focus 都需要完整用户流程。 +- PRD 中不能写本机绝对仓库路径,应引用 JCode Cloud 合同而不是个人目录。 + +## 4. 放行条件 + +PRD 修订后满足以下条件才可进入架构冻结: + +- Phase 1 与 Phase 3 的需求、验收、测试门禁分离; +- local revision、Cloud snapshot、task/session identity 已无歧义; +- 至少三种 UI 展示方式有可点击原型并完成专家评审; +- Kimi/Grok 对修订后的架构设计给出无 P0 的结论; +- 测试矩阵覆盖 unit、integration、browser E2E、Kimi 真模型、Cloud K8s 与 ciphertext audit。 + +## 5. 修订后门禁复审 + +同日使用相同两个 CLI 对修订后的 PRD 再次进行只读 P0 门禁评审: + +| Reviewer | 上一轮 P0 | 新 P0 | 结论 | +| --- | --- | --- | --- | +| Kimi CLI | 全部 closed | 无 | GO | +| Grok CLI (`grok-4.5`) | 全部 closed | 无 | GO | + +复审确认 local revision、task/session identity、automation、真实 Kimi Web E2E、Cloud K8s、`share_key`、URL fragment E2EE 和 Phase 1/3 边界均已进入产品合同。PRD 可以作为 UI 与架构冻结输入。 diff --git a/internal-doc/artifacts-prd.md b/internal-doc/artifacts-prd.md new file mode 100644 index 00000000..04e8be46 --- /dev/null +++ b/internal-doc/artifacts-prd.md @@ -0,0 +1,354 @@ +# JCode Artifacts PRD(Web / Desktop) + +- 状态:Draft +- 目标版本:Phase 1 MVP + Phase 3 Cloud sharing +- 适用端:Web、Desktop +- 明确不适用:CLI/TUI、ACP +- 相关设计:`internal-doc/artifacts-design.md` +- 相关评审:`internal-doc/artifacts-prd-review.md` +- UI 原型与评审:`internal-doc/artifacts-ui/index.html`、`internal-doc/artifacts-ui/design-notes.md` + +## 1. 背景 + +JCode 已经具备文件读写、代码修改、命令执行、Web 会话、Tauri Desktop、右侧 Files / Changes / Plan 面板,以及会话 JSONL 记录能力。但当 Agent 生成一个可直接消费的结果,例如 HTML 报告、Markdown 文档、图片、PDF 或 CSV 时,用户仍需要从对话中找到路径,再进入 Files 面板手动定位和打开。 + +OpenWorker 已经验证了 Artifact 交互的价值:Agent 在最终回复中提供 `artifact:` 链接,右侧栏列出工作区中可预览文件,并根据类型打开 HTML、Markdown、图片、PDF、CSV 等 Viewer。它的不足也很明确:Artifact 列表来自对整个工作区的后缀扫描,因此“产物”和“普通文件”没有真正的领域边界,也无法可靠表达某个会话明确交付了哪些结果。 + +OpenHands 的 `canvas_ui_control.show_preview(path)` 则提供了另一个有价值的方向:Agent 可以显式要求客户端展示一个文件,而不是只把文件路径写进文本。 + +JCode 应结合两者: + +1. 采用 OpenHands 式的显式 `show_artifact` 工具,让 Agent 能把结果交付给界面。 +2. 采用 OpenWorker 式的右侧 Artifact Viewer 和多格式预览。 +3. 不扫描整个工作区,把 Artifact 建模为“会话显式登记的工作区文件”。 +4. 利用 JCode 的 Web/Desktop 共用 React UI、会话回放和 Tauri 原生能力,提供 Web 预览与 Desktop 打开/定位文件。 + +## 2. 问题定义 + +当前交付链路存在四个断点: + +- Agent 知道哪个文件是最终结果,界面不知道。 +- Files 面板展示整个工作区,用户难以区分“源文件”和“交付物”。 +- 对话中的路径只是文本,不能稳定触发预览或在会话恢复后重建产物列表。 +- Web 与 Desktop 有可视化承载能力,但 CLI/TUI 和 ACP 没有统一的 Artifact UI 契约,强行暴露工具只会造成不可完成的工具调用。 + +## 3. 产品定义 + +Artifact 是由 Agent 或未来的用户操作显式登记、属于某个 JCode 会话、实际内容存放在该会话工作区中的文件。 + +Artifact 不是: + +- 工作区内所有“看起来可预览”的文件; +- 文件内容在会话 JSONL 中的副本; +- 新的云存储或附件系统; +- 代码变更的替代品; +- CLI/ACP 协议中的通用输出类型。 + +Web 产品中的 `task_id` 与 Recorder 的 session UUID 是同一个标识;文档后续写作 `task_id/session_id`。Artifact 的最小身份由 `task_id/session_id + normalized_relative_path` 决定。同一会话再次展示同一路径时,更新同一个 Artifact,并增加 revision;会话记录保留登记历史,界面默认展示最新登记。 + +本地 revision 是“显式登记代数”,不是文件内容快照。JSONL 只记录元数据,Viewer 每次都重新校验并读取工作区当前文件;因此文件在未再次登记时发生变化,revision 不会自动增加,但 Viewer 仍会看到当前内容。只有 Phase 3 Cloud 分享会把某个 revision 的内容复制为不可变密文快照。 + +## 4. 目标 + +### 4.1 用户目标 + +- Agent 完成主要产物后,可以主动在右侧打开预览,而不是只回复文件路径。 +- 用户能在当前会话中快速查看所有明确交付的产物。 +- 刷新页面、重启 Desktop 或恢复历史会话后,Artifact 列表仍然存在。 +- Web 可以安全预览常用格式;Desktop 可以进一步用系统默认应用打开文件或在 Finder/Explorer 中定位。 +- **[Phase 3]** 在 JCode 已登录 Cloud 时,用户可以显式生成 Artifact 分享链接;未登录时维持纯本地体验,不出现登录阻塞。 + +### 4.2 平台目标 + +- Artifact 只在 Web transport 注册;Desktop 复用 Web sidecar,因此自然获得该能力。 +- CLI/TUI 与 ACP 的工具列表、提示词和协议均不出现 `show_artifact`。 +- 不增加需要用户设置的 `artifact.enable` 开关;能力是否存在由 transport 和运行环境决定。 +- 不修改所有 transport 共享的 `AgentEventHandler` 接口。 +- 文件内容仍以工作区为唯一事实来源,会话只持久化安全、可回放的元数据。 +- `show_artifact` 永远只负责本地交付;Cloud 上传必须由用户显式触发,不能因登录状态自动发生。 + +## 5. 非目标 + +MVP 不包含: + +- 自动扫描工作区并推断 Artifact; +- 在 CLI/TUI 中做终端内预览; +- 在 ACP 中增加 Artifact notification 或 capability negotiation; +- MVP 阶段跨机器同步或分享 Artifact 文件内容(规划在 Phase 3); +- Artifact 评论、协同编辑或版本 diff; +- 通用 Office 在线渲染; +- 远程 SSH/Docker 工作区的二进制流式预览; +- 用 Artifact 替代 Files、Changes 或 Plan 面板。 + +## 6. Surface 范围 + +| Surface | 是否提供 | 行为 | +| --- | --- | --- | +| Browser Web(`jcode web`) | 是 | 注册工具、显示 Artifacts 面板、浏览器内预览、下载 | +| Tauri Desktop | 是 | 继承 Web 能力,并增加系统默认应用打开、文件管理器定位 | +| CLI/TUI(interactive) | 否 | 不注册工具、不注入说明、不增加 UI | +| ACP | 否 | 不注册工具、不改变 JSON-RPC 协议 | +| Web automation/background run | 是 | 复用 `ToolTransportWeb` 工具计划;可以登记并持久化,automation run 详情提供 Artifacts 入口,非前台任务不得抢占当前面板 | +| Remote workspace | MVP 否 | 不向模型注册工具;后续在有界流式读取完成后启用 | +| JCode Cloud | Phase 3 | 已登录时提供显式 E2EE 分享;未登录时不显示分享动作、不影响本地 Artifact | + +## 7. 核心用户故事 + +### 7.1 生成并预览报告 + +用户说:“分析这些数据,给我一个交互式 HTML 报告。” + +1. Agent 生成并验证 `reports/analysis.html`。 +2. Agent 调用 `show_artifact`。 +3. 当前 Web/Desktop 会话自动打开 Artifacts 面板并展示报告。 +4. Agent 最终回复中同时给出可点击的 Artifact 卡片或链接。 + +### 7.2 查看会话交付物 + +用户稍后打开历史会话,Artifacts 标签显示数量。点击后可以看到该会话曾交付的报告、图表和说明文档;文件缺失时保留元数据并显示 missing 状态,下载、分享和原生打开动作禁用,同时提供“重新检测”。恢复文件后用户可重新检测并继续预览。 + +### 7.3 Desktop 深度使用 + +用户在 Desktop 预览 PDF 后,选择“在默认应用中打开”或“在 Finder 中显示”。原生层只接受已经由服务端验证过的 Artifact ID,不能由 WebView 直接打开任意路径。 + +### 7.4 后台任务完成 + +自动化任务使用与 Web chat 相同的 `ToolTransportWeb` Artifact 工具计划。生成 Artifact 时,系统记录产物并在 automation run 列表和对应 session/task 上显示未查看状态,但不会切换用户正在查看的会话或右侧面板。用户进入 `automation-run` 详情后,通过该页面自己的 Artifacts 入口打开列表和 Viewer;查看后清除该 run 的未读状态。 + +### 7.5 登录后分享 + +用户已经登录 JCode Cloud,在 Artifact Viewer 中点击“分享”。JCode 对当前 revision 重新校验,端到端加密后上传到 Cloud 对象存储,并返回一个可复制、可撤销的分享链接。未登录用户看不到该动作;Artifact 仍然可以本地预览、下载和外部打开,系统不会弹登录框,也不会让 Agent 等待。 + +Artifact 分享是独立、明确的单文件授权。它不自动打开该会话的 Cloud sync,也不上传会话历史、其他 Artifact 或整个 workspace。 + +## 8. 交互设计 + +### 8.1 右侧面板 + +Artifact 使用三层展示结构,后续 UI 原型在不改变该信息架构的前提下探索视觉与交互变体: + +1. 对话内工具结果卡:完成时即时发现和一键打开。 +2. 右侧 Artifacts tab:会话产物索引、状态和历史登记列表。 +3. Expanded / fullscreen Viewer:用于 HTML、PDF、表格和需要大画布的产物;不把 Files/Changes/Plan 一起放大。 + +在现有 Plan / Files / Changes 后增加 Artifacts: + +- 标签显示当前会话的 Artifact 数量;有未查看更新时显示圆点。 +- 默认按最近更新时间倒序排列。 +- 每项显示标题、相对路径、类型、更新时间和状态。 +- 点击列表项打开 Viewer。 +- `show_artifact(focus=true)` 来自当前前台会话时,自动打开 Artifacts 面板并选中该项。 +- 用户可以关闭面板、返回列表、全屏预览或回到先前面板。 +- TopBar 和 DesktopTitlebar 都提供 Artifacts 入口;建议快捷键为 `Shift+Cmd/Ctrl+A`。 +- 关闭右栏后,面板总入口仍显示数量/未读点,避免产物失去发现路径。 +- 多浏览器标签各自只响应本连接当前 active task 的 `focus=true`;不得切换另一个标签或任务。 + +Artifacts 列表继续使用当前 RightPanel 的宽度模型。选中产物后可进入 expanded Viewer:默认宽度建议 480px,允许拖拽到视口宽度的 80%,并提供全屏 overlay。Files / Changes / Plan 保持现有宽度规则。 + +### 8.2 对话中的表现 + +成功调用 `show_artifact` 后,工具结果渲染为紧凑 Artifact 卡片:标题、类型、路径和“打开”操作。最终回复可以引用已登记的 Artifact,但不要求模型手写特殊 `artifact:` URL。 + +第一版以工具结果卡片为主,避免仅依赖 Markdown 自定义协议。后续可支持受控链接形式,例如 `jcode-artifact:`,但不能接受未经登记的任意路径。 + +### 8.3 文件类型与降级 + +| 类型 | Web MVP | Desktop MVP | +| --- | --- | --- | +| Markdown | 渲染预览,可查看源码 | 同 Web,可默认应用打开 | +| Text / source code | 等宽文本、语法高亮 | 同 Web,可默认应用打开 | +| HTML | 沙箱 iframe,默认禁止联网 | 同 Web,可外部浏览器打开 | +| PNG/JPEG/WebP/GIF/SVG | 图片预览、缩放 | 同 Web,可默认应用打开 | +| PDF | 内嵌 PDF 预览或浏览器 fallback | 同 Web,可默认应用打开 | +| CSV/TSV | 有界表格预览、原始文本下载 | 同 Web,可默认应用打开 | +| XLSX/Office/其他 | 元数据 + 下载 | 元数据 + 默认应用打开/定位 | + +超出内嵌预览大小限制的文件仍可作为 Artifact 登记,但 Viewer 显示“文件过大,无法内嵌预览”,并提供下载或 Desktop 外部打开。unsupported、missing、loading、too-large 和 error 都必须是显式状态,不能退化为空白 Viewer。 + +### 8.4 Cloud 分享动作(Phase 3) + +- UI 以现有 `/api/cloud/status` 的 `logged_in` 为唯一展示门:`false` 时完全隐藏分享动作。 +- `logged_in=true` 且 device token 仍有效时,Viewer 提供“分享”按钮;第一次点击才上传当前 revision。分享不依赖 relay online、`auto_connect` 或 session sync。 +- 分享成功后提供复制链接、查看过期时间和撤销分享。 +- 文件更新为新 revision 后,旧链接继续指向旧快照并明确标记;用户需要再次分享才能生成新链接。 +- token 过期或 Cloud 暂时不可达时,只显示可重试错误,绝不影响本地 Artifact 状态。 +- 不提供“登录后自动分享”设置,也不让 `show_artifact` 隐式上传。 +- 每次分享生成独立的 Artifact Share Secret,字段名统一为 `share_key`;不得简称 ASK,也不得复用账号 CEK 或 Account Sync Key。 +- `share_key` 只保存在私有本地 secret store 和分享 URL fragment `#k=v1.`。Cloud 数据库、对象存储、HTTP 请求、日志和事件都不能获得该 key。 +- 完整分享 URL 不得写入 session JSONL、WebSocket、Cloud event、日志或遥测;Cloud 只返回不含 fragment 的 base URL。 +- 分享前锁定当前 revision 和内容摘要;上传期间文件发生变化时以 `artifact_changed` 失败,不得生成半旧半新的对象。 + +## 9. Agent 工具 + +工具名:`show_artifact` + +建议输入: + +```json +{ + "path": "reports/analysis.html", + "title": "销售分析报告", + "kind": "auto", + "focus": true +} +``` + +- `path`:必填,只接受相对当前任务工作区的文件路径。 +- `title`:可选;缺省时使用文件名。 +- `kind`:可选提示,默认 `auto`;服务端 MIME 检测拥有最终决定权。 +- `focus`:可选,默认 `true`;只对当前前台会话生效。 + +工具是直接执行、无需审批的 UI 交付工具。它不会创建或修改工作区文件,只验证文件、记录会话元数据并通知界面;审批策略把它加入明确的 auto-approved 工具集合。 + +工具在 Web normal mode 与 plan mode 都可见;plan mode 只能登记已存在的产物。工具 description 必须包含下面的使用规则。若真实模型测试证明仅靠 schema 不足,可在构建 Web agent 时增加 Web-only prompt fragment,但不得修改共用 prompt。 + +Agent 使用规则: + +- 只在文件已经写完并完成必要验证后调用。 +- 用于用户可以直接消费的主要结果,不为每个源码文件、临时文件、日志或构建产物调用。 +- 同一路径有实质更新后可以再次调用。 +- 普通代码修改继续由 Changes / Files 表达,不应登记为 Artifact。 +- 工具成功后,最终回复简短说明产物已经可预览。 +- 无论 Cloud 是否登录,工具本身都不上传或分享 Artifact。 +- Web 会话运行中切换为 remote 环境后,已有工具调用必须返回可修正的 `artifact preview is not available for remote workspaces yet`;新建 remote agent 时不注册工具。 + +## 10. 功能需求 + +### FR-1:显式登记 + +Web transport 的 Agent 可以调用 `show_artifact` 登记当前工作区中的已有文件。不存在、是目录、越界、符号链接逃逸或不允许的路径必须失败并给出可自我修正的错误。 + +### FR-2:会话隔离 + +Artifact 必须属于一个明确的 `task_id/session UUID`(Web 中二者同值)。切换会话后,列表、未读状态和当前选择随之切换;不能看到其他会话登记的路径。 + +### FR-3:持久化与回放 + +每次登记写入会话 JSONL 的 Artifact entry。恢复历史会话时从 entry 重建最新 Artifact 索引,不复制文件正文。文件已被删除或工作区不可用时保留元数据并标记 missing。 + +### FR-4:实时通知 + +当前前台会话成功登记后,通过已有 WebSocket 通道发送 `artifact_upserted`。界面在 500ms 内更新列表;`focus=true` 时打开 Viewer。后台或非当前会话只更新未查看状态,不能抢焦点。automation run 详情和 reconnect 必须从 session entries/list API 对账,不能依赖 WebSocket 不丢事件。 + +### FR-5:安全预览 + +Web 只能通过 Artifact ID 获取经过再次校验的内容,不接受任意绝对路径。每次 content/download/open 都重新验证 session ownership、canonical containment、regular file 和 symlink,Artifact ID 不是永久授权。 + +安全预览的最低合同:HTML iframe 仅允许 `sandbox="allow-scripts"`,禁止 `allow-same-origin`、form、popup、top navigation 和联网;SVG 只能作为 image document;Markdown 默认禁用 raw HTML;不得把 Artifact HTML/SVG 注入主 React DOM;内容响应必须带 `X-Content-Type-Options: nosniff` 和对应 CSP。`.git/**`、`.jcode/**`、`.env`、private key 等敏感路径必须拒绝,Artifact 不能成为 Files 权限旁路。 + +### FR-6:Desktop 原生操作 + +Desktop 为已登记且再次验证的本地 Artifact 提供“默认应用打开”和“在文件管理器中显示”。Web 端不显示不可用的原生操作。 + +### FR-7:Transport 隔离 + +CLI/TUI 与 ACP 的工具目录中不存在 `show_artifact`。静态/eager 工具路径依靠各 transport 独立的注册列表隔离;启用 Tool Search 时,catalog transport policy 还必须拒绝 Web 以外的暴露。验收必须同时覆盖 `interactive.go`/`acp.go` 候选工具列表、`tool_catalog` policy 和最终 schema snapshot。 + +### FR-8:无 enable 配置 + +不新增用户级或项目级 Artifact enable 开关。Web 本地工作区自动具备能力;Desktop 继承。CLI/TUI、ACP 和 MVP 远程工作区通过注册范围天然不具备能力。 + +### FR-9:可选 Cloud 分享(Phase 3) + +只有 `cloud.status.logged_in=true` 时,Web/Desktop 才显示用户触发的分享操作。分享使用 JCode Cloud 的设备认证与对象存储合同,并以 Artifact revision + 内容摘要创建不可变上传快照。未登录、未开启 session sync、`auto_connect=false` 或 Cloud connector 未常驻,都不能阻止 Artifact 的本地登记和预览;分享不得修改 session sync store。 + +## 11. 非功能需求 + +- 预览元数据更新 P95 小于 500ms;大文件内容按需加载,不阻塞 Agent run。 +- Artifact 登记必须是幂等操作,同一路径重试不会产生重复列表项。 +- 文件大小采用三档合同:文本/HTML/CSV inline 5 MiB,图片/PDF inline 25 MiB;本地 download 250 MiB;Phase 3 Cloud share 25 MiB。超限分别返回 `artifact_too_large` 或 `artifact_share_too_large`,不影响本地登记。 +- 不在 JSONL、WebSocket 或日志中写入 Artifact 文件正文。 +- 所有诊断使用 `config.Logger()`,不得污染 TUI stdout/stderr。 +- API 和前端状态要支持一个会话至少 100 个 Artifact,列表仍保持可用。 +- Cloud 分享内容必须端到端加密;Cloud 服务端和对象存储不得获得文件明文或 URL fragment 中的解密密钥。 +- 所有新 UI 文案必须走现有 i18n;交互支持键盘、ARIA、focus ring 和 `prefers-reduced-motion`。 + +## 12. 成功指标 + +MVP 发布后关注: + +- Agent 生成用户可消费文件后成功登记 Artifact 的比例。 +- 从 Agent 完成文件到用户首次打开产物的时间。 +- `show_artifact` 失败率及主要失败原因。 +- 会话恢复后 Artifact 重建成功率。 +- 用户从 Artifact Viewer 回退到 Files 手动找文件的比例。 + +这些指标第一版可先通过结构化日志获得,不要求立即接入新的遥测产品。事件字段至少包括 `artifact_registered`、`artifact_open_latency_ms`、`show_artifact_error_code`、`artifact_missing`,且不记录绝对路径或内容。 + +## 13. Phase 1 MVP 验收标准 + +1. 在 Web/Desktop 中要求 Agent 生成 HTML、Markdown、图片、PDF 或 CSV,Agent 可以调用工具并自动打开对应 renderer;分别验证 HTML sandbox、Markdown raw HTML 禁止、图片缩放、PDF fallback 和 CSV 有界表格。 +2. 刷新 Web、重启 Desktop、切换后再返回会话,Artifact 列表可以从 session entries 恢复。 +3. 同一路径连续登记两次只显示一个列表项,revision 增加且内容刷新。 +4. 文件删除后,历史 Artifact 仍在列表中并显示 missing。 +5. 非当前会话或后台任务产生 Artifact 时,不改变当前右侧面板;automation run 列表出现未读点,进入 run 后能打开并查看 Artifact。 +6. `../`、绝对路径、工作区外符号链接和伪造 Artifact ID 均无法读取。 +7. 恶意 HTML 无法访问父页面、JCode API、文件系统或外部网络。 +8. Desktop 能打开和定位合法本地 Artifact;Web 只提供预览/下载。 +9. CLI/TUI 的工具 schema、ACP 工具 schema 和对应提示词中均找不到 `show_artifact`。 +10. 不需要任何 enable 配置即可在本地 Web/Desktop 会话使用。 +11. 使用 JCode 配置中的真实 Kimi 模型完成至少 HTML + Markdown/CSV 两类真实生成,断言模型调用 `show_artifact`、网页自动打开 Viewer、刷新后列表恢复。 + +### 13.1 Phase 3 Cloud 分享验收 + +1. 未登录 Cloud 时不显示分享按钮,生成和预览 Artifact 全程不出现登录提示或失败。 +2. 登录 Cloud 但关闭当前 session sync 时,用户仍可显式分享单个 Artifact,且不会因此上传会话历史。 +3. `show_artifact` 成功不会产生任何 Cloud 请求;只有用户点击分享才开始上传。 +4. 分享链接打开后可以预览/下载被分享的固定 revision,Cloud 数据库、对象存储和访问日志中没有明文内容或 `share_key`;分享页请求中不包含 URL fragment。 +5. Artifact 后续更新不会悄悄改变旧分享链接的内容。 +6. 用户撤销后链接失效;Cloud 上传失败、token 过期或离线不影响本地 Artifact。 +7. 分享前后 `/api/cloud/sync` sessions map 保持不变,且 Cloud E2E/数据库审计证明 transcript、标题、路径和文件正文没有明文泄露。 + +## 14. 测试与发布门禁 + +### 14.1 Phase 1:JCode + +| 层级 | 必过门禁 | +| --- | --- | +| Go unit | path/symlink/sensitive deny;stable ID/revision;recorder failure;approval auto-pass;Web/TUI/ACP tool catalog;content CSP/nosniff/size/range | +| Frontend unit | artifact reducer/replay;active/background focus;RightPanel/expanded/fullscreen;所有 renderer 状态;i18n/accessibility | +| Integration | list/content/download API + WebSocket upsert + session replay + automation no-focus + CLI/ACP schema negative snapshots | +| Real-model Web E2E | 启动真实 `jcode web`,使用当前配置的 Kimi 模型生成至少 HTML 与 Markdown/CSV;浏览器断言 tool call、Artifacts 入口、sandbox、内容、刷新恢复 | +| Desktop smoke | Tauri 环境 open/reveal 合法 Artifact;伪造 ID、missing、symlink swap 失败 | + +Phase 1 不要求把 JCode Web 部署到 K8s;它以本地 Web/Desktop 为目标。真实模型测试不得使用 mock provider 代替 Kimi。 + +### 14.2 Phase 3:JCode Cloud + +| 层级 | 必过门禁 | +| --- | --- | +| Orchestrator unit/store | migration、owner isolation、intent/upload/complete、quota、expiry/revoke、object GC、user delete cascade | +| Console/share-page | fragment 解密、renderer sandbox、no-referrer、expired/revoked/error、无 plaintext cache/telemetry | +| Cross-implementation crypto | JCode Go、Cloud Go、浏览器 WebCrypto 共享 versioned test vectors | +| Integration | logged-in gate;session sync off;auto_connect off;revision pin;upload retry;完整 share/revoke 流 | +| K8s deployment | 部署到目标集群,确认 migration、object store、Ingress 分享页、公开密文下载、撤销/过期、pod rollout 后状态 | +| Adversarial audit | psql/object/log zero-plaintext grep;fragment 不进请求;伪造 share ID/跨用户/重放/篡改 ciphertext 全部失败 | + +Cloud K8s 测试必须使用真实对象存储和部署配置;mock 仅用于单元测试,不能作为部署验收。 + +## 15. 发布分期 + +### Phase 1:MVP + +- Web-only 工具注册与 transport policy +- Artifact session entry、索引与 WebSocket 事件 +- Artifacts 面板和常用格式 Viewer +- Web 下载 +- Desktop 默认应用打开与文件管理器定位 +- 安全、回放、隔离和 transport 测试 + +### Phase 2:增强 + +- XLSX 表格预览与更多 Office 元数据 +- Artifact 全屏、多 Artifact 对比、手动“标记为 Artifact” +- 受控的对话内 Artifact 链接 +- 内容摘要、缩略图和更精细的未读体验 + +### Phase 3:扩展 + +- SSH/Docker 远程工作区的有界流式读取 +- 登录 JCode Cloud 后显式创建 E2EE Artifact 分享链接 +- 未登录时隐藏分享能力并保持完整本地体验 +- 分享 revision 快照、过期时间、复制链接和撤销 +- Cloud Console/分享页的安全预览与下载 +- Artifact 历史 revision 查看和导出包 diff --git a/internal-doc/artifacts-ui/design-notes.md b/internal-doc/artifacts-ui/design-notes.md new file mode 100644 index 00000000..436d0abf --- /dev/null +++ b/internal-doc/artifacts-ui/design-notes.md @@ -0,0 +1,106 @@ +# Artifact UI 设计说明 + +- 状态:Prototype review +- 原型:`internal-doc/artifacts-ui/index.html` +- 目标 surface:Browser Web、Tauri Desktop +- 明确排除:CLI/TUI、ACP + +## 1. 设计假设 + +- 叙事角色:Agent delivery workbench。Artifact 是一次任务的可消费交付物,不是第二套文件浏览器。 +- 观看距离:桌面/笔记本约 1 米,优先高信息密度、稳定布局和键盘可达。 +- 视觉语气:沿用 JCode 当前克制、工具化、单色为主的界面;橙色只用于主动作和关键焦点。 +- 信息密度:对话、Artifact 历史、预览内容和分享状态经常需要同时可见,不能依赖只有一张大卡片的低密度布局。 + +## 2. 三种交互变体 + +### A. Docked Workbench(推荐) + +Artifact 使用现有右栏的信息架构。列表固定在右栏顶部,Viewer 在同一栏内展开;用户可把栏宽拖到约 480px,并进一步全屏。 + +- 优点:与 Plan / Files / Changes 一致;对话与交付物可并排核对;实现风险最低。 +- 缺点:窄屏或复杂 HTML/PDF 仍需切到全屏。 +- 适用:默认路径、持续查看日志/报告、绝大多数 coding-agent 任务。 + +### B. Focus Canvas + +打开 Artifact 后,Viewer 成为主工作区画布,顶部保留 Artifact strip 和“返回对话”。右栏索引退化为一条紧凑 rail。 + +- 优点:最大化 HTML、PDF、图片和 CSV 的可读面积。 +- 缺点:打断对话上下文,频繁往返时成本更高。 +- 适用:深度阅读、演示报告、视觉验收。 + +### C. Inline Quick Look + +点击 tool result card 后,在对话中直接展开有界预览;右侧只保留可展开的 Artifact 索引。用户可从 Quick Look 升级到右栏或全屏。 + +- 优点:几乎不打断阅读流;“Agent 刚交付什么”最清楚。 +- 缺点:长内容会挤压对话,历史 Artifact 的可发现性弱于 A。 +- 适用:Markdown 摘要、小图、短 CSV、快速确认。 + +## 3. 推荐组合 + +生产实现采用 A 作为默认框架,同时吸收 B 和 C: + +1. `show_artifact(focus=true)` 打开 A 的右栏 Viewer。 +2. tool result card 提供 C 的小型 Quick Look,仅对适合的短内容启用。 +3. Viewer 的 Expand 动作进入 B 的 Focus Canvas;再次 Expand 才进入浏览器 fullscreen overlay。 +4. 三种方式共享同一个选中 Artifact、renderer 和分享状态,不创建三套数据模型。 + +## 4. 分享状态设计 + +- 未登录:整个分享动作不存在,不显示锁、登录提示或 disabled button。 +- 已登录、未分享:Viewer header 显示 `Share`。 +- 上传中:显示进度、文件 revision 和取消;本地预览保持可用。 +- 已分享:提供 Copy link、expiry 和 Revoke。 +- stale share:同时展示 `Shared rev 2` 与 `Current rev 3`,旧链接继续有效;明确按钮为 `Share latest`。 +- expired / revoked:保留历史状态,不制造仍可访问的错觉;可以重新分享当前 revision。 +- Cloud 错误:只影响分享区域,不覆盖 Viewer,不改变本地 Artifact 状态。 + +## 5. 关键视觉规则 + +- Artifacts tab 与现有 panel tab 同权,不做全局主导航。 +- 列表行优先显示标题;relative path 使用单行 mono 辅助文本。 +- 类型与 revision 使用低强调 chip;missing/error 使用语义色但不整块染红。 +- Viewer header 始终保留文件身份、revision、Download/Expand;Desktop 才增加 Open/Reveal。 +- HTML 预览里显示“Sandboxed · network blocked”作为安全状态,而不是技术报错。 +- 动画只用于 panel/overlay 过渡,支持 `prefers-reduced-motion`。 + +## 6. 原型交互覆盖 + +- 切换三种展示方案。 +- 从对话 tool card 打开 Artifact。 +- 在三个 Artifact 间切换,覆盖 HTML、Markdown、CSV。 +- 切换 Cloud 登录状态;验证未登录时 Share 完全隐藏。 +- 切换 unshared、uploading、shared、stale、expired、revoked 状态。 +- Copy link、Revoke、Share latest、Expand、返回对话。 +- 全屏 Viewer、关闭 Viewer、重新打开。 +- 桌面宽屏和较窄 Web viewport。 + +## 7. 评审评分 + +已使用 Chromium 在 1440×900 和 1024×768 viewport 执行交互回归,并逐张检查 Docked、Focus Canvas、Quick Look、logged-out、shared 和 stale-share 截图。自动化脚本为 `internal-doc/artifacts-ui/prototype.test.cjs`。 + +| 维度 | 分数(10) | 结论 | +| --- | ---: | --- | +| 信息架构 | 9.2 | 三层结构和三种 presentation 共用同一 Artifact 选择;默认路径明确。 | +| 视觉层级 | 8.8 | 列表、Viewer、内容层级稳定;分享 popover 不会夺走整个页面。 | +| 交互完整度 | 9.0 | 已覆盖打开、切换、全屏、登录门、上传、复制、撤销、stale/expired/revoked。 | +| 产品一致性 | 9.1 | 沿用 JCode sidebar、titlebar、right panel、黑灰表面和克制橙色。 | +| 无障碍与韧性 | 8.6 | 支持键盘 focus、Esc、快捷键、reduced motion 和窄桌面布局;生产实现仍需完整 i18n/ARIA 测试。 | + +综合:8.94 / 10,UI 架构可以冻结进入工程设计。 + +### 评审后修改 + +- 增加全局 `[hidden]` 规则,保证未登录时 Share 在任何按钮 display 样式下都真正消失。 +- Focus Canvas 的安全状态改为随 renderer 更新,Markdown/CSV 不再错误显示 HTML sandbox 文案。 +- 将 Artifact path/revision 辅助文字提高到至少 10px,避免高密度布局中不可读。 +- 窄 viewport 只保留 active panel tab,避免 tab strip 把 Viewer action 挤出屏幕。 +- stale-share 同时显示 shared revision、current revision 和旧链接语义,避免“Share latest”像覆盖原链接。 + +### 生产实现注意 + +- 原型中的字符图标只是交互占位;生产 React 组件必须使用现有 Heroicons outline。 +- 原型内的报告内容明确标注为 sample,不可当成真实发布数据或遥测。 +- 生产浏览器测试需增加 screen reader name、focus trap、200% zoom 和真正 `prefers-reduced-motion` 断言。 diff --git a/internal-doc/artifacts-ui/index.html b/internal-doc/artifacts-ui/index.html new file mode 100644 index 00000000..42f1452b --- /dev/null +++ b/internal-doc/artifacts-ui/index.html @@ -0,0 +1,986 @@ + + + + + + JCode Artifact UI directions + + + +
+
JArtifact UI · Design review
+
+ + + +
+
+ + +
+ +
+
+ + +
+
+ Artifact delivery workflow + jcode / main +
+
+ +
+
+
Analyze the release test results and create an interactive HTML readiness report plus a Markdown summary.
+
+ +
+
JJCode
+
I reviewed the unit, browser, and deployment checks. The report separates local Artifact delivery from Cloud sharing and calls out the remaining release gate.
+
+
+ HTML +
Release readiness reportreports/release-readiness.html · rev 3
+ + +
+
+
Quick Look·reports/release-readiness.html● Sandboxed
+
+
+
+
The HTML report and two supporting files are available in Artifacts. The currently shared link still points to revision 2; revision 3 is only local until you choose “Share latest.”
+
+
+ +
+
+
Ask JCode or type / for commands
+
Full accessKimiHigh speed
+
+
+ +
+
+ +
+ + + +
+ +
+
reports/release-readiness.html● Sandboxed · network blocked
+
+
+ + +
+
+ +
+
Release readiness reportreports/release-readiness.html · rev 3
+
Interactive HTML● Sandboxed · network blocked
+
+
Link copied
+ + + + + + + + + + diff --git a/internal-doc/artifacts-ui/product-facts.md b/internal-doc/artifacts-ui/product-facts.md new file mode 100644 index 00000000..0a60d31b --- /dev/null +++ b/internal-doc/artifacts-ui/product-facts.md @@ -0,0 +1,36 @@ +# Artifact UI 产品事实 + +更新时间:2026-08-01 + +本文件只记录 UI 原型可依赖的已验证事实,避免在设计稿中虚构能力。 + +## 公开产品事实 + +- JCode 是一个开源 coding agent,面向 Terminal、Desktop 和 Browser 三个 surface。 +- Desktop 提供 workspace、automation 和 channel 等产品入口。 +- Browser 通过 `jcode web` 启动。 +- 三个 surface 共享同一个 agent engine、session 和 tool set;Artifact PRD 进一步把新能力限定在 Web transport,因此 Desktop 通过 Web sidecar 继承,CLI/TUI 和 ACP 不暴露。 + +来源:[JCode 官方网站](https://www.j-code.net/) + +## 仓库实现事实 + +- Web UI 使用 React 18、Vite、Redux Toolkit,以及 `jcode-ui` / `jcode-ui-core`。 +- 当前右栏已有 Plan、Files、Changes 三个 tab,宽度可在 220–600px 之间拖拽。 +- Browser 通过 `TopBar` 的 panel menu 打开右栏;Desktop 在 `DesktopTitlebar` 中复用同一入口。 +- 主题色来自 `internal/theme/palette.go` 生成的 CSS 变量。默认深色基线为背景 `#111827`、面板 `#1A2333`、主色 `#FF8400`;产品 Web 的基础深色 token 也保持黑灰表面和橙色主动作。 +- 产品组件使用 Heroicons outline,不在 React 组件中手写 SVG。 +- Desktop 和 Web 共用 `web/` 产品 UI;Tauri 只增加原生文件动作。 + +来源:本仓库 `AGENTS.md`、`internal/theme/palette.go`、`web/src/App.tsx`、`web/src/components/RightPanel.tsx`、`web/src/components/TopBar.tsx`、`web/src/components/DesktopTitlebar.tsx`。 + +## Artifact 合同事实 + +- Artifact 是会话显式登记的工作区文件,不扫描整个 workspace。 +- UI 有三层:对话内 tool result card、Artifacts 索引、expanded/fullscreen Viewer。 +- 未登录 Cloud 时分享动作完全隐藏;已登录才允许用户显式分享。 +- 分享不会启用 session sync,也不会由 `show_artifact` 自动触发。 +- Cloud 分享是不可变 revision 快照;工作区新 revision 不会修改旧链接。 +- 必须可表达 available、loading、missing、unsupported、too-large、error、uploading、shared、expired、revoked 和 stale-share。 + +来源:`internal-doc/artifacts-prd.md`。 diff --git a/internal-doc/artifacts-ui/prototype.test.cjs b/internal-doc/artifacts-ui/prototype.test.cjs new file mode 100644 index 00000000..739b8e8f --- /dev/null +++ b/internal-doc/artifacts-ui/prototype.test.cjs @@ -0,0 +1,68 @@ +const { chromium } = require('playwright') +const fs = require('node:fs') +const path = require('node:path') + +const baseURL = process.env.PROTOTYPE_URL || 'http://127.0.0.1:4179/internal-doc/artifacts-ui/index.html' +const outputDir = path.join(__dirname, 'screenshots') +fs.mkdirSync(outputDir, { recursive: true }) + +function check(condition, message) { + if (!condition) throw new Error(message) +} + +;(async () => { + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }) + const pageErrors = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await page.goto(baseURL, { waitUntil: 'networkidle' }) + + check(await page.locator('#viewer-title').textContent() === 'Release readiness report', 'default Artifact was not selected') + check(await page.locator('#share-button').isVisible(), 'Share should be visible while logged in') + await page.screenshot({ path: path.join(outputDir, '01-docked-workbench.png'), fullPage: true }) + + await page.getByRole('button', { name: 'B · Focus canvas' }).click() + check(await page.locator('.focus-shell').isVisible(), 'Focus canvas did not open') + await page.getByRole('button', { name: 'Summary', exact: true }).click() + check(await page.locator('.focus-preview h1').textContent() === 'Artifact release summary', 'Focus canvas did not switch Artifact') + await page.screenshot({ path: path.join(outputDir, '02-focus-canvas.png'), fullPage: true }) + + await page.getByRole('button', { name: 'C · Quick Look' }).click() + check(await page.locator('.quick-preview').isVisible(), 'Quick Look did not open') + await page.locator('.artifact-row[data-artifact="html"]').click() + await page.screenshot({ path: path.join(outputDir, '03-inline-quick-look.png'), fullPage: true }) + + await page.getByRole('button', { name: 'A · Docked' }).click() + await page.locator('#login-toggle').uncheck() + check(await page.locator('#share-button').isHidden(), 'Share must be hidden while logged out') + await page.screenshot({ path: path.join(outputDir, '04-logged-out.png'), fullPage: true }) + + await page.locator('#login-toggle').check() + await page.locator('#share-state').selectOption('stale') + check(await page.getByText('Newer local revision').isVisible(), 'Stale share state was not shown') + await page.screenshot({ path: path.join(outputDir, '06-stale-share.png'), fullPage: true }) + await page.getByRole('button', { name: 'Share latest' }).click() + check(await page.getByText('Encrypting and uploading').isVisible(), 'Share latest did not enter uploading state') + + await page.locator('#share-state').selectOption('shared') + await page.getByRole('button', { name: 'Copy link', exact: true }).click() + check(await page.getByRole('status').textContent() === 'Encrypted share link copied', 'Copy link feedback missing') + + await page.locator('.fullscreen-open').last().click() + check(await page.locator('#fullscreen').isVisible(), 'Fullscreen Viewer did not open') + await page.locator('#fullscreen-close').click() + check(await page.locator('#fullscreen').isHidden(), 'Fullscreen Viewer did not close') + + await page.setViewportSize({ width: 1024, height: 768 }) + await page.getByRole('button', { name: 'A · Docked' }).click() + const appBox = await page.locator('#app').boundingBox() + check(appBox && appBox.width <= 1008, 'Prototype overflowed the narrow desktop viewport') + await page.screenshot({ path: path.join(outputDir, '05-narrow-web.png'), fullPage: true }) + + check(pageErrors.length === 0, `Page errors: ${pageErrors.join('; ')}`) + await browser.close() + process.stdout.write('prototype checks passed\n') +})().catch((error) => { + process.stderr.write(`${error.stack || error}\n`) + process.exit(1) +}) diff --git a/internal-doc/artifacts-ui/screenshots/01-docked-workbench.png b/internal-doc/artifacts-ui/screenshots/01-docked-workbench.png new file mode 100644 index 00000000..8fee3440 Binary files /dev/null and b/internal-doc/artifacts-ui/screenshots/01-docked-workbench.png differ diff --git a/internal-doc/artifacts-ui/screenshots/02-focus-canvas.png b/internal-doc/artifacts-ui/screenshots/02-focus-canvas.png new file mode 100644 index 00000000..b1786083 Binary files /dev/null and b/internal-doc/artifacts-ui/screenshots/02-focus-canvas.png differ diff --git a/internal-doc/artifacts-ui/screenshots/03-inline-quick-look.png b/internal-doc/artifacts-ui/screenshots/03-inline-quick-look.png new file mode 100644 index 00000000..c84d6e02 Binary files /dev/null and b/internal-doc/artifacts-ui/screenshots/03-inline-quick-look.png differ diff --git a/internal-doc/artifacts-ui/screenshots/04-logged-out.png b/internal-doc/artifacts-ui/screenshots/04-logged-out.png new file mode 100644 index 00000000..8754a266 Binary files /dev/null and b/internal-doc/artifacts-ui/screenshots/04-logged-out.png differ diff --git a/internal-doc/artifacts-ui/screenshots/05-narrow-web.png b/internal-doc/artifacts-ui/screenshots/05-narrow-web.png new file mode 100644 index 00000000..43eedd50 Binary files /dev/null and b/internal-doc/artifacts-ui/screenshots/05-narrow-web.png differ diff --git a/internal-doc/artifacts-ui/screenshots/06-stale-share.png b/internal-doc/artifacts-ui/screenshots/06-stale-share.png new file mode 100644 index 00000000..fcb17654 Binary files /dev/null and b/internal-doc/artifacts-ui/screenshots/06-stale-share.png differ diff --git a/internal/artifact/service.go b/internal/artifact/service.go new file mode 100644 index 00000000..4fcf690f --- /dev/null +++ b/internal/artifact/service.go @@ -0,0 +1,476 @@ +// Package artifact owns session-scoped, explicitly registered workspace +// deliverables. It stores metadata only; file content remains in the workspace. +package artifact + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" +) + +type Kind string + +const ( + KindAuto Kind = "auto" + KindText Kind = "text" + KindMarkdown Kind = "markdown" + KindCode Kind = "code" + KindHTML Kind = "html" + KindImage Kind = "image" + KindPDF Kind = "pdf" + KindCSV Kind = "csv" + KindBinary Kind = "binary" +) + +var ErrTooLarge = errors.New("artifact is too large") + +type Status string + +const ( + StatusAvailable Status = "available" + StatusMissing Status = "missing" + StatusUnsupported Status = "unsupported" + StatusTooLarge Status = "too_large" + StatusError Status = "error" +) + +const ( + MaxInlineTextSize int64 = 5 << 20 + MaxInlineBinarySize int64 = 25 << 20 + MaxDownloadSize int64 = 250 << 20 + MaxShareSize int64 = 25 << 20 + maxTitleRunes = 200 +) + +// Record is the metadata persisted in a session entry and exposed to the Web +// UI. It intentionally contains neither an absolute path nor file content. +type Record struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + RelativePath string `json:"relative_path"` + Title string `json:"title"` + Kind Kind `json:"kind"` + MediaType string `json:"media_type"` + Size int64 `json:"size"` + Revision int `json:"revision"` + UpdatedAt time.Time `json:"updated_at"` + Status Status `json:"status"` + Focus bool `json:"focus,omitempty"` +} + +type RegisterRequest struct { + SessionID string + Workspace string + RelativePath string + Title string + Kind Kind + Focus bool +} + +// Recorder is the durable boundary. Register never publishes a revision until +// this append succeeds. +type Recorder interface { + RecordArtifact(Record) error +} + +type Loader func(sessionID string) ([]Record, error) + +type Service struct { + loader Loader + now func() time.Time + mu sync.Mutex + shards map[string]*sessionShard +} + +type sessionShard struct { + mu sync.RWMutex + loaded bool + records map[string]Record +} + +func NewService(loader Loader, now func() time.Time) *Service { + if now == nil { + now = time.Now + } + return &Service{loader: loader, now: now, shards: make(map[string]*sessionShard)} +} + +func (s *Service) shard(sessionID string) *sessionShard { + s.mu.Lock() + defer s.mu.Unlock() + shard := s.shards[sessionID] + if shard == nil { + shard = &sessionShard{records: make(map[string]Record)} + s.shards[sessionID] = shard + } + return shard +} + +func (s *Service) hydrateLocked(shard *sessionShard, sessionID string) error { + if shard.loaded { + return nil + } + if s.loader != nil { + records, err := s.loader(sessionID) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for _, record := range records { + current, exists := shard.records[record.ID] + if !exists || record.Revision > current.Revision { + shard.records[record.ID] = record + } + } + } + shard.loaded = true + return nil +} + +func (s *Service) Register(ctx context.Context, req RegisterRequest, recorder Recorder) (Record, error) { + if err := ctx.Err(); err != nil { + return Record{}, err + } + if req.SessionID == "" || recorder == nil { + return Record{}, fmt.Errorf("artifact registration requires a session recorder") + } + validated, err := validateWorkspaceFile(req.Workspace, req.RelativePath) + if err != nil { + return Record{}, err + } + kind, mediaType, err := classifyFile(validated.absolutePath, req.Kind) + if err != nil { + return Record{}, err + } + title := strings.TrimSpace(req.Title) + if title == "" { + title = filepath.Base(validated.relativePath) + } + if utf8.RuneCountInString(title) > maxTitleRunes { + return Record{}, fmt.Errorf("artifact title exceeds %d characters", maxTitleRunes) + } + + shard := s.shard(req.SessionID) + shard.mu.Lock() + defer shard.mu.Unlock() + if err := s.hydrateLocked(shard, req.SessionID); err != nil { + return Record{}, fmt.Errorf("load artifact registry: %w", err) + } + id := stableID(req.SessionID, validated.relativePath) + revision := 1 + if current, exists := shard.records[id]; exists { + revision = current.Revision + 1 + } + record := Record{ + ID: id, SessionID: req.SessionID, RelativePath: validated.relativePath, + Title: title, Kind: kind, MediaType: mediaType, Size: validated.info.Size(), + Revision: revision, UpdatedAt: s.now().UTC(), Status: StatusAvailable, Focus: req.Focus, + } + if err := recorder.RecordArtifact(record); err != nil { + return Record{}, fmt.Errorf("persist artifact metadata: %w", err) + } + shard.records[id] = record + return record, nil +} + +func (s *Service) List(ctx context.Context, sessionID, workspace string) ([]Record, error) { + if sessionID == "" { + return nil, fmt.Errorf("session id is required") + } + shard := s.shard(sessionID) + shard.mu.Lock() + if err := s.hydrateLocked(shard, sessionID); err != nil { + shard.mu.Unlock() + return nil, err + } + records := make([]Record, 0, len(shard.records)) + for _, record := range shard.records { + records = append(records, record) + } + shard.mu.Unlock() + + for i := range records { + if err := ctx.Err(); err != nil { + return nil, err + } + validated, err := validateWorkspaceFile(workspace, records[i].RelativePath) + switch { + case err == nil: + records[i].Status = StatusAvailable + records[i].Size = validated.info.Size() + case errors.Is(err, os.ErrNotExist): + records[i].Status = StatusMissing + default: + records[i].Status = StatusError + } + } + sort.Slice(records, func(i, j int) bool { + if records[i].UpdatedAt.Equal(records[j].UpdatedAt) { + return records[i].ID < records[j].ID + } + return records[i].UpdatedAt.After(records[j].UpdatedAt) + }) + return records, nil +} + +// Resolve revalidates a registered artifact at the time of use and returns its +// server-only absolute path. Callers must never serialize absolutePath. +func (s *Service) Resolve(ctx context.Context, sessionID, workspace, artifactID string) (Record, string, error) { + if err := ctx.Err(); err != nil { + return Record{}, "", err + } + shard := s.shard(sessionID) + shard.mu.Lock() + if err := s.hydrateLocked(shard, sessionID); err != nil { + shard.mu.Unlock() + return Record{}, "", err + } + record, ok := shard.records[artifactID] + shard.mu.Unlock() + if !ok { + return Record{}, "", os.ErrNotExist + } + validated, err := validateWorkspaceFile(workspace, record.RelativePath) + if err != nil { + return record, "", err + } + record.Size = validated.info.Size() + record.Status = StatusAvailable + return record, validated.absolutePath, nil +} + +// Open returns a read-only file descriptor constrained by os.Root. Unlike a +// validate-then-os.Open sequence, Root.Open prevents a concurrent symlink swap +// from redirecting the read outside the workspace. +func (s *Service) Open(ctx context.Context, sessionID, workspace, artifactID string) (Record, *os.File, error) { + if err := ctx.Err(); err != nil { + return Record{}, nil, err + } + shard := s.shard(sessionID) + shard.mu.Lock() + if err := s.hydrateLocked(shard, sessionID); err != nil { + shard.mu.Unlock() + return Record{}, nil, err + } + record, ok := shard.records[artifactID] + shard.mu.Unlock() + if !ok || sensitivePath(record.RelativePath) { + return Record{}, nil, os.ErrNotExist + } + validated, err := validateWorkspaceFile(workspace, record.RelativePath) + if err != nil { + return Record{}, nil, err + } + root, err := os.OpenRoot(workspace) + if err != nil { + return Record{}, nil, fmt.Errorf("open artifact workspace: %w", err) + } + defer func() { _ = root.Close() }() + file, err := root.Open(filepath.FromSlash(record.RelativePath)) + if err != nil { + return Record{}, nil, fmt.Errorf("open artifact file: %w", err) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return Record{}, nil, fmt.Errorf("stat artifact file: %w", err) + } + if !info.Mode().IsRegular() { + _ = file.Close() + return Record{}, nil, fmt.Errorf("artifact path must identify a regular file") + } + if !os.SameFile(validated.info, info) || hasMultipleHardLinks(info) { + _ = file.Close() + return Record{}, nil, fmt.Errorf("artifact path changed or has multiple hard links") + } + record.Size = info.Size() + record.Status = StatusAvailable + return record, file, nil +} + +func stableID(sessionID, relativePath string) string { + digest := sha256.Sum256([]byte(sessionID + "\x00" + relativePath)) + return base64.RawURLEncoding.EncodeToString(digest[:])[:22] +} + +type validatedFile struct { + relativePath string + absolutePath string + info os.FileInfo +} + +func validateWorkspaceFile(workspace, requested string) (validatedFile, error) { + requested = strings.TrimSpace(requested) + if workspace == "" || requested == "" || filepath.IsAbs(requested) || strings.Contains(requested, "\\") { + return validatedFile{}, fmt.Errorf("artifact path must be a relative slash-separated workspace file") + } + clean := filepath.Clean(filepath.FromSlash(requested)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return validatedFile{}, fmt.Errorf("artifact path escapes the workspace") + } + relativePath := filepath.ToSlash(clean) + if sensitivePath(relativePath) { + return validatedFile{}, fmt.Errorf("artifact path is sensitive and cannot be registered") + } + canonicalRoot, err := filepath.EvalSymlinks(filepath.Clean(workspace)) + if err != nil { + return validatedFile{}, fmt.Errorf("resolve artifact workspace: %w", err) + } + absolute := filepath.Join(canonicalRoot, clean) + if err := rejectArtifactSymlinks(canonicalRoot, clean); err != nil { + return validatedFile{}, err + } + canonicalTarget, err := filepath.EvalSymlinks(absolute) + if err != nil { + return validatedFile{}, fmt.Errorf("resolve artifact file: %w", err) + } + rel, err := filepath.Rel(canonicalRoot, canonicalTarget) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return validatedFile{}, fmt.Errorf("artifact path escapes the workspace through a symbolic link") + } + if sensitivePath(filepath.ToSlash(rel)) { + return validatedFile{}, fmt.Errorf("artifact target is sensitive and cannot be registered") + } + info, err := os.Stat(canonicalTarget) + if err != nil { + return validatedFile{}, fmt.Errorf("stat artifact file: %w", err) + } + if !info.Mode().IsRegular() { + return validatedFile{}, fmt.Errorf("artifact path must identify a regular file") + } + if hasMultipleHardLinks(info) { + return validatedFile{}, fmt.Errorf("artifact files with multiple hard links are not supported") + } + return validatedFile{relativePath: relativePath, absolutePath: canonicalTarget, info: info}, nil +} + +func rejectArtifactSymlinks(root, relative string) error { + current := root + for _, segment := range strings.Split(relative, string(filepath.Separator)) { + current = filepath.Join(current, segment) + info, err := os.Lstat(current) + if err != nil { + return fmt.Errorf("inspect artifact path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("artifact path cannot contain symbolic links") + } + } + return nil +} + +func hasMultipleHardLinks(info os.FileInfo) bool { + if info == nil || info.Sys() == nil { + return false + } + value := reflect.ValueOf(info.Sys()) + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if !value.IsValid() || value.Kind() != reflect.Struct { + return false + } + links := value.FieldByName("Nlink") + if !links.IsValid() { + return false + } + switch links.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return links.Uint() > 1 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return links.Int() > 1 + default: + return false + } +} + +func sensitivePath(relativePath string) bool { + segments := strings.Split(strings.ToLower(relativePath), "/") + for _, segment := range segments { + if segment == ".git" || segment == ".jcode" || segment == ".ssh" { + return true + } + } + base := segments[len(segments)-1] + if base == ".env" || strings.HasPrefix(base, ".env.") || base == "id_rsa" || base == "id_ed25519" { + return true + } + switch strings.ToLower(filepath.Ext(base)) { + case ".pem", ".key", ".p12", ".pfx": + return true + default: + return false + } +} + +func classifyFile(path string, hint Kind) (Kind, string, error) { + if hint == "" { + hint = KindAuto + } + if !validKind(hint) { + return "", "", fmt.Errorf("unsupported artifact kind %q", hint) + } + ext := strings.ToLower(filepath.Ext(path)) + mediaType := mime.TypeByExtension(ext) + file, err := os.Open(path) + if err != nil { + return "", "", err + } + defer func() { _ = file.Close() }() + var sample [512]byte + n, readErr := file.Read(sample[:]) + if readErr != nil && !errors.Is(readErr, io.EOF) && n == 0 { + return "", "", readErr + } + if mediaType == "" { + mediaType = http.DetectContentType(sample[:n]) + } + detected := kindForExtension(ext, mediaType) + if hint != KindAuto { + detected = hint + } + return detected, strings.Split(mediaType, ";")[0], nil +} + +func validKind(kind Kind) bool { + switch kind { + case KindAuto, KindText, KindMarkdown, KindCode, KindHTML, KindImage, KindPDF, KindCSV, KindBinary: + return true + default: + return false + } +} + +func kindForExtension(ext, mediaType string) Kind { + switch ext { + case ".md", ".markdown": + return KindMarkdown + case ".html", ".htm": + return KindHTML + case ".csv", ".tsv": + return KindCSV + case ".pdf": + return KindPDF + case ".go", ".rs", ".py", ".js", ".jsx", ".ts", ".tsx", ".css", ".scss", ".json", ".yaml", ".yml", ".toml", ".sql", ".sh": + return KindCode + } + if strings.HasPrefix(mediaType, "image/") { + return KindImage + } + if strings.HasPrefix(mediaType, "text/") { + return KindText + } + return KindBinary +} diff --git a/internal/artifact/service_test.go b/internal/artifact/service_test.go new file mode 100644 index 00000000..b9cc5451 --- /dev/null +++ b/internal/artifact/service_test.go @@ -0,0 +1,154 @@ +package artifact + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +type recordSink struct { + records []Record + err error +} + +func (s *recordSink) RecordArtifact(record Record) error { + if s.err != nil { + return s.err + } + s.records = append(s.records, record) + return nil +} + +func TestRegisterUsesStableIDAndOnlyAdvancesAfterDurableAppend(t *testing.T) { + workspace := t.TempDir() + if err := os.MkdirAll(filepath.Join(workspace, "reports"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "reports", "result.md"), []byte("# one"), 0o600); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + service := NewService(nil, func() time.Time { return now }) + sink := &recordSink{} + + first, err := service.Register(context.Background(), RegisterRequest{ + SessionID: "session-a", Workspace: workspace, RelativePath: "reports/result.md", Focus: true, + }, sink) + if err != nil { + t.Fatal(err) + } + if first.ID == "" || first.Revision != 1 || first.Kind != KindMarkdown || first.RelativePath != "reports/result.md" { + t.Fatalf("first=%+v", first) + } + + sink.err = errors.New("disk full") + if _, err := service.Register(context.Background(), RegisterRequest{ + SessionID: "session-a", Workspace: workspace, RelativePath: "reports/result.md", Title: "failed revision", + }, sink); err == nil { + t.Fatal("recorder failure must fail registration") + } + sink.err = nil + second, err := service.Register(context.Background(), RegisterRequest{ + SessionID: "session-a", Workspace: workspace, RelativePath: "reports/result.md", Title: "Result", + }, sink) + if err != nil { + t.Fatal(err) + } + if second.ID != first.ID || second.Revision != 2 || second.Title != "Result" { + t.Fatalf("second=%+v first=%+v", second, first) + } + if len(sink.records) != 2 { + t.Fatalf("durable records=%d want 2", len(sink.records)) + } +} + +func TestRegisterRejectsWorkspaceEscapeSensitiveAndNonRegularPaths(t *testing.T) { + workspace := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, ".env"), []byte("TOKEN=secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(workspace, "escape.txt")); err != nil { + t.Fatal(err) + } + service := NewService(nil, time.Now) + for _, relativePath := range []string{"../secret.txt", filepath.Join(outside, "secret.txt"), ".env", "escape.txt", "."} { + t.Run(relativePath, func(t *testing.T) { + _, err := service.Register(context.Background(), RegisterRequest{ + SessionID: "session-a", Workspace: workspace, RelativePath: relativePath, + }, &recordSink{}) + if err == nil { + t.Fatalf("path %q should be rejected", relativePath) + } + }) + } +} + +func TestRegisterRejectsLinksToSensitiveWorkspaceFiles(t *testing.T) { + workspace := t.TempDir() + secret := filepath.Join(workspace, ".env") + if err := os.WriteFile(secret, []byte("TOKEN=secret"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(".env", filepath.Join(workspace, "report.txt")); err != nil { + t.Fatal(err) + } + if _, err := NewService(nil, time.Now).Register(context.Background(), RegisterRequest{ + SessionID: "session-a", Workspace: workspace, RelativePath: "report.txt", + }, &recordSink{}); err == nil { + t.Fatal("symlink to a sensitive in-workspace file must be rejected") + } + + hardlink := filepath.Join(workspace, "hardlink.txt") + if err := os.Link(secret, hardlink); err != nil { + t.Skipf("hard links are unavailable: %v", err) + } + if _, err := NewService(nil, time.Now).Register(context.Background(), RegisterRequest{ + SessionID: "session-b", Workspace: workspace, RelativePath: "hardlink.txt", + }, &recordSink{}); err == nil { + t.Fatal("hard link to a sensitive file must be rejected") + } +} + +func TestRegisterRejectsSymlinksEvenWhenTargetStaysInWorkspace(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "target.txt"), []byte("safe"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target.txt", filepath.Join(workspace, "report.txt")); err != nil { + t.Fatal(err) + } + if _, err := NewService(nil, time.Now).Register(context.Background(), RegisterRequest{ + SessionID: "session-a", Workspace: workspace, RelativePath: "report.txt", + }, &recordSink{}); err == nil { + t.Fatal("artifact paths containing symbolic links must be rejected") + } +} + +func TestListHydratesLatestRevisionAndReportsMissingFiles(t *testing.T) { + workspace := t.TempDir() + loaded := []Record{ + {ID: "same", SessionID: "history", RelativePath: "report.csv", Title: "old", Kind: KindCSV, MediaType: "text/csv", Size: 10, Revision: 1}, + {ID: "same", SessionID: "history", RelativePath: "report.csv", Title: "latest", Kind: KindCSV, MediaType: "text/csv", Size: 12, Revision: 2}, + } + service := NewService(func(sessionID string) ([]Record, error) { + if sessionID != "history" { + t.Fatalf("load session=%q", sessionID) + } + return loaded, nil + }, time.Now) + + records, err := service.List(context.Background(), "history", workspace) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].Revision != 2 || records[0].Title != "latest" || records[0].Status != StatusMissing { + t.Fatalf("records=%+v", records) + } +} diff --git a/internal/cloud/artifact_share.go b/internal/cloud/artifact_share.go new file mode 100644 index 00000000..4961d571 --- /dev/null +++ b/internal/cloud/artifact_share.go @@ -0,0 +1,318 @@ +package cloud + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + ArtifactShareProtocolV1 = "jcode-artifact-share-v1" + artifactShareMaxSize = 25 << 20 + artifactShareMinExpiry = time.Hour + artifactShareMaxExpiry = 30 * 24 * time.Hour +) + +// ArtifactShareMetadata is encrypted locally. Cloud only receives its opaque +// AES-GCM envelope and therefore cannot read the title, path, type, or size. +type ArtifactShareMetadata struct { + Title string `json:"title"` + RelativePath string `json:"relative_path"` + MediaType string `json:"media_type"` + Kind string `json:"kind"` + Size int64 `json:"size"` +} + +type ArtifactShareInput struct { + ArtifactID string + Revision int + Title string + RelativePath string + MediaType string + Kind string + Content []byte + ExpiresIn time.Duration +} + +type ArtifactShareResult struct { + ShareID string `json:"share_id"` + URL string `json:"url"` + ExpiresAt time.Time `json:"expires_at"` +} + +type ArtifactShareSummary struct { + ShareID string `json:"share_id"` + ArtifactID string `json:"artifact_id"` + Revision int `json:"revision"` + State string `json:"state"` + CiphertextSize int64 `json:"ciphertext_size"` + CiphertextSHA256 string `json:"ciphertext_sha256,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ArtifactSharePublisher performs the explicit device-authenticated upload. +// It is independent of relay connectivity: valid login credentials are enough. +type ArtifactSharePublisher struct { + httpClient *http.Client + rand io.Reader +} + +func NewArtifactSharePublisher(httpClient *http.Client) *ArtifactSharePublisher { + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Minute} + } + return &ArtifactSharePublisher{httpClient: httpClient, rand: rand.Reader} +} + +type artifactShareIntent struct { + ShareID string `json:"share_id"` + UploadURL string `json:"upload_url"` + CompleteURL string `json:"complete_url"` + BaseURL string `json:"base_url"` + ExpiresAt time.Time `json:"expires_at"` +} + +type encryptedArtifactMetadata struct { + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` + PlaintextLength int64 `json:"plaintext_length"` +} + +func (p *ArtifactSharePublisher) Publish(ctx context.Context, creds *Credentials, input ArtifactShareInput) (_ *ArtifactShareResult, retErr error) { + client, token, err := p.client(creds) + if err != nil { + return nil, err + } + if !validArtifactShareID(input.ArtifactID) || input.Revision <= 0 || strings.TrimSpace(input.Title) == "" || + strings.TrimSpace(input.MediaType) == "" || strings.TrimSpace(input.Kind) == "" { + return nil, fmt.Errorf("artifact share input is invalid") + } + if len(input.Content) > artifactShareMaxSize { + return nil, fmt.Errorf("artifact exceeds the 25 MiB share limit") + } + if input.ExpiresIn == 0 { + input.ExpiresIn = 7 * 24 * time.Hour + } + if input.ExpiresIn < artifactShareMinExpiry || input.ExpiresIn > artifactShareMaxExpiry { + return nil, fmt.Errorf("artifact share expiry must be between 1 hour and 30 days") + } + // Freeze caller-owned bytes before creating remote state. + content := bytes.Clone(input.Content) + key := make([]byte, 32) + if _, err := io.ReadFull(p.rand, key); err != nil { + return nil, fmt.Errorf("generate artifact share key: %w", err) + } + + var intent artifactShareIntent + err = client.post(ctx, "/internal/v1/device/artifact-shares/intents", token, map[string]any{ + "protocol": ArtifactShareProtocolV1, "artifact_id": input.ArtifactID, + "revision": input.Revision, "ciphertext_size": len(content) + 28, + "expires_in_seconds": int64(input.ExpiresIn / time.Second), + }, &intent) + if err != nil { + return nil, fmt.Errorf("create artifact share intent: %w", err) + } + if !validArtifactShareID(intent.ShareID) { + return nil, fmt.Errorf("artifact share service returned an invalid share id") + } + completed := false + defer func() { + if completed { + return + } + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + _ = p.revoke(cleanupCtx, client, token, intent.ShareID) + }() + wantUpload := "/internal/v1/device/artifact-shares/" + intent.ShareID + "/content" + wantComplete := "/internal/v1/device/artifact-shares/" + intent.ShareID + "/complete" + if intent.UploadURL != wantUpload || intent.CompleteURL != wantComplete { + return nil, fmt.Errorf("artifact share service returned invalid upload endpoints") + } + + contentNonce, contentCiphertext, err := p.seal(key, content, + artifactShareAAD(intent.ShareID, "content", input.ArtifactID, input.Revision, int64(len(content)))) + if err != nil { + return nil, err + } + contentWire := append(bytes.Clone(contentNonce), contentCiphertext...) + digest := sha256.Sum256(contentWire) + + metadataPlaintext, err := json.Marshal(ArtifactShareMetadata{ + Title: input.Title, RelativePath: input.RelativePath, MediaType: input.MediaType, + Kind: input.Kind, Size: int64(len(content)), + }) + if err != nil { + return nil, fmt.Errorf("encode artifact share metadata: %w", err) + } + metadataNonce, metadataCiphertext, err := p.seal(key, metadataPlaintext, + artifactShareAAD(intent.ShareID, "metadata", input.ArtifactID, input.Revision, int64(len(metadataPlaintext)))) + if err != nil { + return nil, err + } + + if err := p.upload(ctx, client, token, wantUpload, contentWire); err != nil { + return nil, fmt.Errorf("upload encrypted artifact: %w", err) + } + var complete ArtifactShareSummary + if err := client.post(ctx, wantComplete, token, map[string]any{ + "ciphertext_sha256": hex.EncodeToString(digest[:]), + "encrypted_metadata": encryptedArtifactMetadata{ + Nonce: base64.RawURLEncoding.EncodeToString(metadataNonce), + Ciphertext: base64.RawURLEncoding.EncodeToString(metadataCiphertext), + PlaintextLength: int64(len(metadataPlaintext)), + }, + }, &complete); err != nil { + return nil, fmt.Errorf("complete artifact share: %w", err) + } + shareURL, err := artifactShareURL(intent.BaseURL, key) + if err != nil { + return nil, err + } + completed = true + return &ArtifactShareResult{ShareID: intent.ShareID, URL: shareURL, ExpiresAt: intent.ExpiresAt}, nil +} + +func (p *ArtifactSharePublisher) List(ctx context.Context, creds *Credentials, artifactID string) ([]ArtifactShareSummary, error) { + client, token, err := p.client(creds) + if err != nil { + return nil, err + } + path := "/internal/v1/device/artifact-shares" + if artifactID != "" { + if !validArtifactShareID(artifactID) { + return nil, fmt.Errorf("artifact id is invalid") + } + path += "?artifact_id=" + url.QueryEscape(artifactID) + } + var out struct { + Shares []ArtifactShareSummary `json:"shares"` + } + if _, err := client.get(ctx, path, token, &out); err != nil { + return nil, err + } + return out.Shares, nil +} + +func (p *ArtifactSharePublisher) Revoke(ctx context.Context, creds *Credentials, shareID string) error { + client, token, err := p.client(creds) + if err != nil { + return err + } + return p.revoke(ctx, client, token, shareID) +} + +func (p *ArtifactSharePublisher) client(creds *Credentials) (*Client, string, error) { + if creds == nil || strings.TrimSpace(creds.DeviceToken) == "" { + return nil, "", fmt.Errorf("cloud login is required") + } + baseURL, err := ValidateCloudURL(creds.CloudURL) + if err != nil { + return nil, "", err + } + client := NewClient(baseURL) + client.HTTPClient = p.httpClient + return client, creds.DeviceToken, nil +} + +func (p *ArtifactSharePublisher) upload(ctx context.Context, client *Client, token, path string, content []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, client.BaseURL+path, bytes.NewReader(content)) + if err != nil { + return err + } + req.ContentLength = int64(len(content)) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := p.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + return nil +} + +func (p *ArtifactSharePublisher) revoke(ctx context.Context, client *Client, token, shareID string) error { + if !validArtifactShareID(shareID) { + return fmt.Errorf("artifact share id is invalid") + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, + client.BaseURL+"/internal/v1/device/artifact-shares/"+shareID, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := p.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("revoke artifact share: HTTP %d", resp.StatusCode) + } + return nil +} + +func (p *ArtifactSharePublisher) seal(key, plaintext, aad []byte) ([]byte, []byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(p.rand, nonce); err != nil { + return nil, nil, fmt.Errorf("generate artifact share nonce: %w", err) + } + return nonce, gcm.Seal(nil, nonce, plaintext, aad), nil +} + +func artifactShareAAD(shareID, part, artifactID string, revision int, plaintextLength int64) []byte { + return []byte(ArtifactShareProtocolV1 + "\n" + shareID + "\n" + part + "\n" + artifactID + "\n" + + strconv.Itoa(revision) + "\n" + strconv.FormatInt(plaintextLength, 10)) +} + +func artifactShareURL(baseURL string, key []byte) (string, error) { + u, err := url.Parse(baseURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Fragment != "" { + return "", fmt.Errorf("artifact share service returned an invalid public URL") + } + u.Fragment = "k=v1." + base64.RawURLEncoding.EncodeToString(key) + return u.String(), nil +} + +func validArtifactShareID(value string) bool { + if len(value) < 1 || len(value) > 128 { + return false + } + for i := range len(value) { + char := value[i] + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && + (char < '0' || char > '9') && char != '-' && char != '_' { + return false + } + } + return true +} diff --git a/internal/cloud/artifact_share_test.go b/internal/cloud/artifact_share_test.go new file mode 100644 index 00000000..596fe1ce --- /dev/null +++ b/internal/cloud/artifact_share_test.go @@ -0,0 +1,229 @@ +package cloud + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" +) + +func TestArtifactShareCanonicalCrossRuntimeVector(t *testing.T) { + raw, err := os.ReadFile("../../../cloud/shared/artifact-share-v1.json") + if err != nil { + t.Skipf("cloud artifact vector is not available: %v", err) + } + var vector struct { + Protocol string `json:"protocol"` + ShareID string `json:"share_id"` + ArtifactID string `json:"artifact_id"` + Revision int `json:"revision"` + Key string `json:"key_b64url"` + MetadataPlaintext string `json:"metadata_plaintext"` + MetadataNonce string `json:"metadata_nonce_b64url"` + MetadataCiphertext string `json:"metadata_ciphertext_b64url"` + ContentPlaintext string `json:"content_plaintext_b64url"` + ContentWire string `json:"content_wire_b64url"` + ContentWireSHA256 string `json:"content_wire_sha256"` + } + if err := json.Unmarshal(raw, &vector); err != nil { + t.Fatal(err) + } + decode := func(value string) []byte { + decoded, decodeErr := base64.RawURLEncoding.DecodeString(value) + if decodeErr != nil { + t.Fatal(decodeErr) + } + return decoded + } + key := decode(vector.Key) + metadataNonce := decode(vector.MetadataNonce) + metadataCiphertext := decode(vector.MetadataCiphertext) + metadata := openTestEnvelope(t, key, metadataNonce, metadataCiphertext, + artifactShareAAD(vector.ShareID, "metadata", vector.ArtifactID, vector.Revision, int64(len(vector.MetadataPlaintext)))) + if string(metadata) != vector.MetadataPlaintext { + t.Fatalf("metadata = %s", metadata) + } + var metadataValue ArtifactShareMetadata + if err := json.Unmarshal(metadata, &metadataValue); err != nil { + t.Fatal(err) + } + canonical, _ := json.Marshal(metadataValue) + if string(canonical) != vector.MetadataPlaintext { + t.Fatalf("metadata encoding = %s", canonical) + } + + wire := decode(vector.ContentWire) + if len(wire) < 28 { + t.Fatal("content wire is too short") + } + contentWant := decode(vector.ContentPlaintext) + content := openTestEnvelope(t, key, wire[:12], wire[12:], + artifactShareAAD(vector.ShareID, "content", vector.ArtifactID, vector.Revision, int64(len(contentWant)))) + if string(content) != string(contentWant) { + t.Fatalf("content = %q", content) + } + digest := sha256.Sum256(wire) + if hex.EncodeToString(digest[:]) != vector.ContentWireSHA256 { + t.Fatalf("digest mismatch") + } +} + +func TestArtifactSharePublisherEncryptsContentAndMetadataWithFragmentKey(t *testing.T) { + t.Parallel() + var mu sync.Mutex + var intentBody, uploaded, completeBody []byte + mux := http.NewServeMux() + mux.HandleFunc("POST /internal/v1/device/artifact-shares/intents", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer device-token" { + t.Fatalf("intent authorization = %q", r.Header.Get("Authorization")) + } + body, _ := io.ReadAll(r.Body) + mu.Lock() + intentBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"share_id":"share-123","upload_url":"/internal/v1/device/artifact-shares/share-123/content","complete_url":"/internal/v1/device/artifact-shares/share-123/complete","base_url":"https://share.example/s/share-123","expires_at":"2026-08-08T00:00:00Z"}`) + }) + mux.HandleFunc("PUT /internal/v1/device/artifact-shares/share-123/content", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer device-token" { + t.Fatalf("upload authorization = %q", r.Header.Get("Authorization")) + } + body, _ := io.ReadAll(r.Body) + mu.Lock() + uploaded = body + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("POST /internal/v1/device/artifact-shares/share-123/complete", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + completeBody = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"share_id":"share-123","state":"complete","artifact_id":"artifact_1","revision":2,"expires_at":"2026-08-08T00:00:00Z"}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + random := make([]byte, 32+12+12) + for i := range random { + random[i] = byte(i + 1) + } + publisher := NewArtifactSharePublisher(server.Client()) + publisher.rand = strings.NewReader(string(random)) + input := ArtifactShareInput{ + ArtifactID: "artifact_1", Revision: 2, Title: "Private benchmark", + RelativePath: "reports/private.html", MediaType: "text/html", Kind: "html", + Content: []byte("

secret result

"), ExpiresIn: 7 * 24 * time.Hour, + } + result, err := publisher.Publish(context.Background(), &Credentials{ + CloudURL: server.URL, DeviceToken: "device-token", + }, input) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if result.ShareID != "share-123" || result.URL != "https://share.example/s/share-123#k=v1."+base64.RawURLEncoding.EncodeToString(random[:32]) { + t.Fatalf("result = %#v", result) + } + + mu.Lock() + defer mu.Unlock() + for name, body := range map[string][]byte{"intent": intentBody, "upload": uploaded, "complete": completeBody} { + if strings.Contains(string(body), "Private benchmark") || strings.Contains(string(body), "secret result") || strings.Contains(string(body), "reports/private") { + t.Fatalf("%s leaked plaintext: %s", name, body) + } + } + var intent struct { + Protocol string `json:"protocol"` + ArtifactID string `json:"artifact_id"` + Revision int `json:"revision"` + CiphertextSize int64 `json:"ciphertext_size"` + } + if err := json.Unmarshal(intentBody, &intent); err != nil { + t.Fatal(err) + } + if intent.Protocol != ArtifactShareProtocolV1 || intent.ArtifactID != input.ArtifactID || intent.Revision != 2 || intent.CiphertextSize != int64(len(input.Content)+28) { + t.Fatalf("intent = %#v", intent) + } + + key := random[:32] + content := openTestEnvelope(t, key, uploaded[:12], uploaded[12:], artifactShareAAD("share-123", "content", input.ArtifactID, input.Revision, int64(len(input.Content)))) + if string(content) != string(input.Content) { + t.Fatalf("content = %q", content) + } + var complete struct { + CiphertextSHA256 string `json:"ciphertext_sha256"` + EncryptedMetadata struct { + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` + PlaintextLength int64 `json:"plaintext_length"` + } `json:"encrypted_metadata"` + } + if err := json.Unmarshal(completeBody, &complete); err != nil { + t.Fatal(err) + } + nonce, _ := base64.RawURLEncoding.DecodeString(complete.EncryptedMetadata.Nonce) + ciphertext, _ := base64.RawURLEncoding.DecodeString(complete.EncryptedMetadata.Ciphertext) + metadata := openTestEnvelope(t, key, nonce, ciphertext, artifactShareAAD("share-123", "metadata", input.ArtifactID, input.Revision, complete.EncryptedMetadata.PlaintextLength)) + var decoded ArtifactShareMetadata + if err := json.Unmarshal(metadata, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Title != input.Title || decoded.RelativePath != input.RelativePath || decoded.Size != int64(len(input.Content)) { + t.Fatalf("metadata = %#v", decoded) + } +} + +func TestArtifactSharePublisherRevokesIntentWhenUploadFails(t *testing.T) { + t.Parallel() + revoked := false + mux := http.NewServeMux() + mux.HandleFunc("POST /internal/v1/device/artifact-shares/intents", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"share_id":"share-fail","upload_url":"/internal/v1/device/artifact-shares/share-fail/content","complete_url":"/internal/v1/device/artifact-shares/share-fail/complete","base_url":"https://share.example/s/share-fail","expires_at":"2026-08-08T00:00:00Z"}`) + }) + mux.HandleFunc("PUT /internal/v1/device/artifact-shares/share-fail/content", func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "failed", http.StatusBadGateway) }) + mux.HandleFunc("DELETE /internal/v1/device/artifact-shares/share-fail", func(w http.ResponseWriter, _ *http.Request) { revoked = true; w.WriteHeader(http.StatusNoContent) }) + server := httptest.NewServer(mux) + defer server.Close() + publisher := NewArtifactSharePublisher(server.Client()) + _, err := publisher.Publish(context.Background(), &Credentials{CloudURL: server.URL, DeviceToken: "token"}, ArtifactShareInput{ + ArtifactID: "artifact", Revision: 1, Title: "x", MediaType: "text/plain", Kind: "text", Content: []byte("x"), ExpiresIn: time.Hour, + }) + if err == nil { + t.Fatal("Publish unexpectedly succeeded") + } + if !revoked { + t.Fatal("failed upload did not revoke the incomplete intent") + } +} + +func openTestEnvelope(t *testing.T, key, nonce, ciphertext, aad []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + plain, err := gcm.Open(nil, nonce, ciphertext, aad) + if err != nil { + t.Fatal(err) + } + return plain +} diff --git a/internal/command/tool_catalog.go b/internal/command/tool_catalog.go index 242da5a2..63da082c 100644 --- a/internal/command/tool_catalog.go +++ b/internal/command/tool_catalog.go @@ -35,6 +35,9 @@ var commandToolPolicies = map[string]commandToolPolicy{ ), "load_skill": directPolicy("skills", normalMode, "read"), "subagent": directPolicy("delegation.subagent", normalMode, "orchestration"), + "show_artifact": scopedDirectPolicy( + "web.artifact", allModes, webTransport, "read", + ), "goal_set": deferredPolicy("session.goal", normalMode, "session"), "goal_get": deferredPolicy("session.goal", allModes, "read"), @@ -72,6 +75,7 @@ var ( allModes = []string{agent.ToolModeNormal, agent.ToolModePlan} tuiTransport = []string{agent.ToolTransportTUI} + webTransport = []string{agent.ToolTransportWeb} tuiAndWebTransports = []string{agent.ToolTransportTUI, agent.ToolTransportWeb} ) diff --git a/internal/command/tool_catalog_test.go b/internal/command/tool_catalog_test.go index cf57960e..b7cd4bbc 100644 --- a/internal/command/tool_catalog_test.go +++ b/internal/command/tool_catalog_test.go @@ -29,7 +29,7 @@ var allCommandBuiltinNames = []string{ "team_list", "team_delete", "browser_open", "browser_snapshot", "browser_screenshot", "browser_act", "browser_read", "browser_tabs", "browser_eval", "computer_open", "computer_snapshot", "computer_screenshot", "computer_act", - "computer_read", "computer_apps", + "computer_read", "computer_apps", "show_artifact", } func TestBuildCommandToolPlanMatrix(t *testing.T) { @@ -38,6 +38,11 @@ func TestBuildCommandToolPlanMatrix(t *testing.T) { "read", "subagent", "todoread", "todowrite", "write", } planDirect := []string{"ask_user", "execute", "grep", "read", "todoread", "todowrite"} + webNormalDirect := []string{ + "ask_user", "check_background", "edit", "execute", "grep", "load_skill", + "read", "show_artifact", "subagent", "todoread", "todowrite", "write", + } + webPlanDirect := []string{"ask_user", "execute", "grep", "read", "show_artifact", "todoread", "todowrite"} normalDeferred := []string{ "automation_create", "browser_act", "browser_eval", "browser_open", "browser_read", "browser_screenshot", "browser_snapshot", "browser_tabs", "computer_act", "computer_apps", @@ -61,14 +66,14 @@ func TestBuildCommandToolPlanMatrix(t *testing.T) { {name: "tui normal", transport: agent.ToolTransportTUI, mode: agent.ToolModeNormal, direct: normalDirect, deferred: normalDeferred}, {name: "web normal", transport: agent.ToolTransportWeb, mode: agent.ToolModeNormal, - direct: normalDirect, deferred: withoutPrefixes(normalDeferred, "team_")}, + direct: webNormalDirect, deferred: withoutPrefixes(normalDeferred, "team_")}, {name: "acp normal", transport: agent.ToolTransportACP, mode: agent.ToolModeNormal, direct: withoutNames(normalDirect, "ask_user"), deferred: withoutPrefixes(normalDeferred, "browser_", "team_")}, {name: "tui plan", transport: agent.ToolTransportTUI, mode: agent.ToolModePlan, direct: planDirect, deferred: planDeferred}, {name: "web plan", transport: agent.ToolTransportWeb, mode: agent.ToolModePlan, - direct: planDirect, deferred: planDeferred}, + direct: webPlanDirect, deferred: planDeferred}, {name: "acp plan", transport: agent.ToolTransportACP, mode: agent.ToolModePlan, direct: withoutNames(planDirect, "ask_user"), deferred: withoutPrefixes(planDeferred, "browser_")}, diff --git a/internal/command/web.go b/internal/command/web.go index 070c924d..9ec50616 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "github.com/cnjack/jcode/internal/agent" + "github.com/cnjack/jcode/internal/artifact" "github.com/cnjack/jcode/internal/automation" "github.com/cnjack/jcode/internal/browser" "github.com/cnjack/jcode/internal/channel" @@ -381,6 +382,10 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo defer func() { _ = computerMgr.Close() }() } + // One process-wide metadata registry is shared by all local Web/Desktop task + // engines. Its source of truth is still the session JSONL loader. + artifactService := artifact.NewService(session.LoadArtifactRecords, time.Now) + // Automation store (definitions + scheduler state). Skipped in setup mode. // Created before buildWebTask so every per-task Env shares this one live // store — the automation_create tool must write through it (not a throwaway) @@ -628,6 +633,15 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo } all = append(all, tenv.NewBrowserTools()...) all = append(all, tenv.NewComputerTools()...) + if exec == nil { + all = append(all, tenv.NewShowArtifactTool(&tools.ShowArtifactDeps{ + SessionID: trec.UUID, + Recorder: trec, + Service: artifactService, + Emit: twh.Emit, + ForceNoFocus: excludeInteractive, + })) + } // Automation runs are unattended — drop interactive tools that would // otherwise block on a human who isn't there (see dropInteractiveTools). if excludeInteractive { @@ -650,6 +664,15 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo // Plan mode gets the read-only browser subset (look, don't change). plan = append(plan, tenv.NewBrowserPlanTools()...) plan = append(plan, tenv.NewComputerPlanTools()...) + if exec == nil { + plan = append(plan, tenv.NewShowArtifactTool(&tools.ShowArtifactDeps{ + SessionID: trec.UUID, + Recorder: trec, + Service: artifactService, + Emit: twh.Emit, + ForceNoFocus: excludeInteractive, + })) + } return plan } @@ -1089,6 +1112,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo }, BLEController: bleProxy, CloudSupervisor: cloudSup, + ArtifactService: artifactService, }) // Start the periodic automation scheduler. A single process owns periodic diff --git a/internal/command/web_tools_test.go b/internal/command/web_tools_test.go index a4084e21..18c37f9d 100644 --- a/internal/command/web_tools_test.go +++ b/internal/command/web_tools_test.go @@ -2,6 +2,10 @@ package command import ( "context" + "os" + "path/filepath" + "runtime" + "strings" "testing" "github.com/cloudwego/eino/components/tool" @@ -14,6 +18,29 @@ type stubTool struct { name string } +func TestShowArtifactCandidateIsRegisteredOnlyByWebTransport(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source") + } + dir := filepath.Dir(currentFile) + read := func(name string) string { + body, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + return string(body) + } + if count := strings.Count(read("web.go"), "NewShowArtifactTool"); count != 2 { + t.Fatalf("web candidate count=%d want all+plan", count) + } + for _, name := range []string{"interactive.go", "acp.go"} { + if strings.Contains(read(name), "NewShowArtifactTool") { + t.Fatalf("%s must not register show_artifact", name) + } + } +} + func (s stubTool) Info(_ context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{Name: s.name}, nil } diff --git a/internal/feature/feature_default.go b/internal/feature/feature_default.go index 6c217f82..3ad623d2 100644 --- a/internal/feature/feature_default.go +++ b/internal/feature/feature_default.go @@ -4,4 +4,7 @@ package feature // BLE is compiled OFF for non-desktop builds (plain `jcode web`, CLI). The // browser web server has no need for a Bluetooth status channel. -const BLE = false +const ( + BLE = false + Desktop = false +) diff --git a/internal/feature/feature_desktop.go b/internal/feature/feature_desktop.go index 453ba1d3..4653be6a 100644 --- a/internal/feature/feature_desktop.go +++ b/internal/feature/feature_desktop.go @@ -3,4 +3,7 @@ package feature // BLE is compiled ON for desktop builds (`-tags desktop`). -const BLE = true +const ( + BLE = true + Desktop = true +) diff --git a/internal/handler/web.go b/internal/handler/web.go index 8723e5ad..797e14d1 100644 --- a/internal/handler/web.go +++ b/internal/handler/web.go @@ -138,6 +138,15 @@ func extractToolDisplayInfo(name, argsJSON string) *ToolDisplayInfo { if len(info.Subtitle) > 60 { info.Subtitle = info.Subtitle[:60] + "…" } + case "show_artifact": + info.Title = "Artifact" + info.Icon = "file" + info.Category = "context" + info.Kind = "read" + info.Subtitle = getString("title") + if info.Subtitle == "" { + info.Subtitle = shortenPath(getString("path")) + } case "load_skill": info.Title = "Load Skill" info.Icon = "skill" diff --git a/internal/handler/web_display_test.go b/internal/handler/web_display_test.go index 1d611e3e..24a1f5b1 100644 --- a/internal/handler/web_display_test.go +++ b/internal/handler/web_display_test.go @@ -83,3 +83,10 @@ func TestTodoWriteSubtitle(t *testing.T) { t.Errorf("empty-args subtitle = %q, want empty", info.Subtitle) } } + +func TestExtractToolDisplayInfoArtifact(t *testing.T) { + info := extractToolDisplayInfo("show_artifact", `{"path":"reports/result.html","title":"Benchmark report"}`) + if info.Title != "Artifact" || info.Subtitle != "Benchmark report" || info.Icon != "file" || info.Category != "context" { + t.Fatalf("show_artifact display info = %#v", info) + } +} diff --git a/internal/runner/approval.go b/internal/runner/approval.go index 0831b28d..2241856f 100644 --- a/internal/runner/approval.go +++ b/internal/runner/approval.go @@ -236,6 +236,7 @@ var noApprovalNeeded = map[string]bool{ "ask_user": true, "webfetch": true, "check_background": true, + "show_artifact": true, "team_create": true, "team_send_message": true, "team_list": true, diff --git a/internal/runner/approval_test.go b/internal/runner/approval_test.go index f25581c1..b0a3c6ab 100644 --- a/internal/runner/approval_test.go +++ b/internal/runner/approval_test.go @@ -294,6 +294,13 @@ func TestRequestApprovalDeferredMutationStillPrompts(t *testing.T) { } } +func TestShowArtifactIsAutoApprovedAsMetadataOnlyDelivery(t *testing.T) { + s := NewApprovalState("/tmp/workdir", false) + if got := s.decide("show_artifact", `{"path":"report.html"}`); got != decisionAutoApprove { + t.Fatalf("show_artifact decision=%v want auto approve", got) + } +} + func TestSubagentDelegatedWriteGrantDecision(t *testing.T) { if noApprovalNeeded["subagent"] { t.Fatal("subagent must be decided from agent_type, not globally auto-approved") diff --git a/internal/session/artifact_test.go b/internal/session/artifact_test.go new file mode 100644 index 00000000..4b47f8fb --- /dev/null +++ b/internal/session/artifact_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cnjack/jcode/internal/artifact" +) + +func TestRecorderPersistsArtifactMetadataAndMaterializesUnseenSummary(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + workspace := t.TempDir() + recorder, err := NewRecorder(workspace, "kimi", "kimi-for-coding") + if err != nil { + t.Fatal(err) + } + record := artifact.Record{ + ID: "opaque-id", SessionID: recorder.UUID(), RelativePath: "reports/result.html", + Title: "Result", Kind: artifact.KindHTML, MediaType: "text/html", Size: 42, + Revision: 1, UpdatedAt: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), + Status: artifact.StatusAvailable, Focus: true, + } + if err := recorder.RecordArtifact(record); err != nil { + t.Fatal(err) + } + + entries, err := LoadSession(recorder.UUID()) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 || entries[1].Type != EntryArtifact || entries[1].ArtifactID != record.ID || entries[1].ArtifactPath != record.RelativePath { + t.Fatalf("entries=%+v", entries) + } + raw, err := os.ReadFile(filepath.Join(home, ".jcode", "sessions", recorder.UUID()+".json")) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"", "file_content", "absolute_path"} { + if strings.Contains(string(raw), forbidden) { + t.Fatalf("session entry leaked %q: %s", forbidden, raw) + } + } + + metas, err := ListSessions(workspace) + if err != nil { + t.Fatal(err) + } + if len(metas) != 1 || metas[0].ArtifactCount != 1 || !metas[0].ArtifactUnseen || metas[0].ArtifactUpdatedAt == "" { + t.Fatalf("metas=%+v", metas) + } +} + +func TestLoadArtifactRecordsIgnoresOlderDuplicateRevision(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".jcode", "sessions") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + lines := []Entry{ + {Type: EntrySessionStart, UUID: "history", Project: "/work", Timestamp: "2026-08-01T00:00:00Z"}, + {Type: EntryArtifact, ArtifactID: "same", ArtifactPath: "a.md", ArtifactTitle: "new", ArtifactKind: "markdown", ArtifactMediaType: "text/markdown", ArtifactSize: 3, ArtifactRevision: 2, Timestamp: "2026-08-01T02:00:00Z"}, + {Type: EntryArtifact, ArtifactID: "same", ArtifactPath: "a.md", ArtifactTitle: "old", ArtifactKind: "markdown", ArtifactMediaType: "text/markdown", ArtifactSize: 2, ArtifactRevision: 1, Timestamp: "2026-08-01T01:00:00Z"}, + } + var body strings.Builder + for _, entry := range lines { + encoded, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + body.Write(encoded) + body.WriteByte('\n') + } + if err := os.WriteFile(filepath.Join(dir, "history.json"), []byte(body.String()), 0o600); err != nil { + t.Fatal(err) + } + + records, err := LoadArtifactRecords("history") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].Revision != 2 || records[0].Title != "new" { + t.Fatalf("records=%+v", records) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 73686367..cf4c30f1 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -5,12 +5,14 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" "time" "github.com/google/uuid" + "github.com/cnjack/jcode/internal/artifact" "github.com/cnjack/jcode/internal/config" ) @@ -42,6 +44,7 @@ const ( EntrySystemPrompt EntryType = "system_prompt" EntryGoalUpdate EntryType = "goal_update" EntryToolObservation EntryType = "tool_observation" + EntryArtifact EntryType = "artifact" ) // ToolObservation stores metadata-only evidence about progressive tool @@ -156,6 +159,17 @@ type Entry struct { // tool_observation fields ToolObservation *ToolObservation `json:"tool_observation,omitempty"` + + // artifact fields. Only safe, workspace-relative metadata is persisted; + // content and absolute paths remain in the workspace. + ArtifactID string `json:"artifact_id,omitempty"` + ArtifactPath string `json:"artifact_path,omitempty"` + ArtifactTitle string `json:"artifact_title,omitempty"` + ArtifactKind string `json:"artifact_kind,omitempty"` + ArtifactMediaType string `json:"artifact_media_type,omitempty"` + ArtifactSize int64 `json:"artifact_size,omitempty"` + ArtifactRevision int `json:"artifact_revision,omitempty"` + ArtifactFocus bool `json:"artifact_focus,omitempty"` } // SessionMeta is stored in the index for fast listing. @@ -185,6 +199,11 @@ type SessionMeta struct { TerminalStatus string `json:"terminal_status,omitempty"` EndTime string `json:"end_time,omitempty"` ErrorReason string `json:"error_reason,omitempty"` + // Artifact summary is a repairable materialized view over artifact entries. + ArtifactCount int `json:"artifact_count,omitempty"` + ArtifactUnseen bool `json:"artifact_unseen,omitempty"` + ArtifactUpdatedAt string `json:"artifact_updated_at,omitempty"` + ArtifactViewedAt string `json:"artifact_viewed_at,omitempty"` } // ProjectMeta is project-level metadata kept in its own file (projects.json, @@ -579,6 +598,45 @@ func (r *Recorder) RecordToolObservation(observation ToolObservation) { _ = r.writeEntry(Entry{Type: EntryToolObservation, ToolObservation: &observation}) } +// RecordArtifact durably appends one metadata-only Artifact revision. Unlike +// the historical best-effort recorder helpers, it returns append failures so +// the show_artifact tool cannot report a revision that was never persisted. +func (r *Recorder) RecordArtifact(record artifact.Record) error { + if err := r.writeEntry(Entry{ + Type: EntryArtifact, ArtifactID: record.ID, ArtifactPath: record.RelativePath, + ArtifactTitle: record.Title, ArtifactKind: string(record.Kind), ArtifactMediaType: record.MediaType, + ArtifactSize: record.Size, ArtifactRevision: record.Revision, ArtifactFocus: record.Focus, + }); err != nil { + return err + } + if err := reconcileArtifactSummary(r.UUID()); err != nil { + config.Logger().Printf("[artifact] reconcile session summary %s: %v", r.UUID(), err) + } + return nil +} + +func reconcileArtifactSummary(sessionID string) error { + records, err := LoadArtifactRecords(sessionID) + if err != nil { + return err + } + latest := time.Time{} + for i := range records { + if records[i].UpdatedAt.After(latest) { + latest = records[i].UpdatedAt + } + } + _, err = UpdateSessionMeta(sessionID, func(meta *SessionMeta) { + meta.ArtifactCount = len(records) + if !latest.IsZero() { + meta.ArtifactUpdatedAt = latest.Format(time.RFC3339Nano) + } + viewedAt, _ := time.Parse(time.RFC3339Nano, meta.ArtifactViewedAt) + meta.ArtifactUnseen = latest.After(viewedAt) + }) + return err +} + // RecordPlanUpdate appends a plan state change entry. func (r *Recorder) RecordPlanUpdate(status, title, content, feedback string) { _ = r.writeEntry(Entry{ @@ -1205,3 +1263,42 @@ func LoadSession(id string) ([]Entry, error) { } return entries, nil } + +// LoadArtifactRecords rebuilds the latest metadata revision for every Artifact +// in a session. Entry order is not trusted: the greatest revision wins, with a +// later timestamp breaking ties for defensive recovery from duplicated lines. +func LoadArtifactRecords(id string) ([]artifact.Record, error) { + entries, err := LoadSession(id) + if err != nil { + return nil, err + } + latest := make(map[string]artifact.Record) + for _, entry := range entries { + if entry.Type != EntryArtifact || entry.ArtifactID == "" || entry.ArtifactRevision <= 0 { + continue + } + updatedAt, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + record := artifact.Record{ + ID: entry.ArtifactID, SessionID: id, RelativePath: entry.ArtifactPath, + Title: entry.ArtifactTitle, Kind: artifact.Kind(entry.ArtifactKind), MediaType: entry.ArtifactMediaType, + Size: entry.ArtifactSize, Revision: entry.ArtifactRevision, UpdatedAt: updatedAt, + Status: artifact.StatusAvailable, Focus: entry.ArtifactFocus, + } + current, exists := latest[record.ID] + if !exists || record.Revision > current.Revision || + (record.Revision == current.Revision && record.UpdatedAt.After(current.UpdatedAt)) { + latest[record.ID] = record + } + } + records := make([]artifact.Record, 0, len(latest)) + for _, record := range latest { + records = append(records, record) + } + sort.Slice(records, func(i, j int) bool { + if records[i].UpdatedAt.Equal(records[j].UpdatedAt) { + return records[i].ID < records[j].ID + } + return records[i].UpdatedAt.After(records[j].UpdatedAt) + }) + return records, nil +} diff --git a/internal/tools/artifact.go b/internal/tools/artifact.go new file mode 100644 index 00000000..bf58d2cd --- /dev/null +++ b/internal/tools/artifact.go @@ -0,0 +1,101 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" + + "github.com/cnjack/jcode/internal/artifact" +) + +type ShowArtifactDeps struct { + SessionID func() string + Recorder artifact.Recorder + Service *artifact.Service + Emit func(event string, data any) + ForceNoFocus bool +} + +type ShowArtifactInput struct { + Path string `json:"path"` + Title string `json:"title,omitempty"` + Kind artifact.Kind `json:"kind,omitempty"` + Focus *bool `json:"focus,omitempty"` +} + +// NewShowArtifactTool creates the Web-only delivery tool. Transport scoping is +// enforced by command registration and the command tool catalog; this method +// contains no global enable switch. +func (e *Env) NewShowArtifactTool(deps *ShowArtifactDeps) tool.InvokableTool { + return &showArtifactTool{env: e, deps: deps, info: &schema.ToolInfo{ + Name: "show_artifact", + Desc: `Register a finished, user-consumable workspace file in the Web/Desktop Artifacts viewer. + +Call this only after writing and validating the final report, visualization, image, PDF, or data file. Do not register routine source edits, logs, temporary files, or build output. The path must be relative to the current local workspace. Re-register a path after a meaningful update. This tool records local metadata and does not upload or share anything with Cloud.`, + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "path": { + Type: schema.String, Required: true, + Desc: "Slash-separated path to an existing file, relative to the current workspace.", + }, + "title": {Type: schema.String, Desc: "Optional human-readable title (maximum 200 characters)."}, + "kind": { + Type: schema.String, Desc: "Optional renderer hint; auto detects from content and extension.", + Enum: []string{"auto", "text", "markdown", "code", "html", "image", "pdf", "csv", "binary"}, + }, + "focus": {Type: schema.Boolean, Desc: "Open the viewer for the active task. Defaults to true."}, + }), + }} +} + +type showArtifactTool struct { + env *Env + deps *ShowArtifactDeps + info *schema.ToolInfo +} + +func (t *showArtifactTool) Info(_ context.Context) (*schema.ToolInfo, error) { return t.info, nil } + +func (t *showArtifactTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) { + if t.env.IsRemote() { + return "", fmt.Errorf("artifact preview is not available for remote workspaces yet") + } + if t.deps == nil || t.deps.Service == nil || t.deps.Recorder == nil || t.deps.SessionID == nil { + return "", fmt.Errorf("artifact preview is not available in this context") + } + var input ShowArtifactInput + decoder := json.NewDecoder(strings.NewReader(argumentsInJSON)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return "", fmt.Errorf("invalid show_artifact arguments: %w", err) + } + focus := true + if input.Focus != nil { + focus = *input.Focus + } + if t.deps.ForceNoFocus { + focus = false + } + record, err := t.deps.Service.Register(ctx, artifact.RegisterRequest{ + SessionID: t.deps.SessionID(), Workspace: t.env.Pwd(), RelativePath: input.Path, + Title: input.Title, Kind: input.Kind, Focus: focus, + }, t.deps.Recorder) + if err != nil { + return "", err + } + if t.deps.Emit != nil { + t.deps.Emit("artifact_upserted", record) + } + output, err := json.Marshal(map[string]any{ + "artifact_id": record.ID, "path": record.RelativePath, "title": record.Title, + "kind": record.Kind, "revision": record.Revision, + "message": "Artifact is available in the Artifacts panel.", + }) + if err != nil { + return "", err + } + return string(output), nil +} diff --git a/internal/tools/artifact_test.go b/internal/tools/artifact_test.go new file mode 100644 index 00000000..193966b1 --- /dev/null +++ b/internal/tools/artifact_test.go @@ -0,0 +1,94 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cnjack/jcode/internal/artifact" +) + +type artifactRecorderFake struct{ records []artifact.Record } + +func (f *artifactRecorderFake) RecordArtifact(record artifact.Record) error { + f.records = append(f.records, record) + return nil +} + +func TestShowArtifactRegistersBeforeEmittingWebEvent(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "report.html"), []byte("

Ready

"), 0o600); err != nil { + t.Fatal(err) + } + recorder := &artifactRecorderFake{} + service := artifact.NewService(nil, func() time.Time { + return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + }) + var eventName string + var eventRecord artifact.Record + env := NewEnv(workspace, "darwin") + tool := env.NewShowArtifactTool(&ShowArtifactDeps{ + SessionID: func() string { return "task-1" }, + Recorder: recorder, + Service: service, + Emit: func(event string, data any) { + if len(recorder.records) != 1 { + t.Fatal("event emitted before durable record") + } + eventName = event + eventRecord = data.(artifact.Record) + }, + }) + + output, err := tool.InvokableRun(context.Background(), `{"path":"report.html","title":"Demo","kind":"auto","focus":true}`) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal([]byte(output), &response); err != nil { + t.Fatalf("output is not JSON: %v: %s", err, output) + } + if response["artifact_id"] == "" || response["revision"] != float64(1) || eventName != "artifact_upserted" || eventRecord.Title != "Demo" { + t.Fatalf("response=%v event=%q record=%+v", response, eventName, eventRecord) + } +} + +func TestShowArtifactSchemaExplainsWebDeliveryAndNoCloudUpload(t *testing.T) { + env := NewEnv(t.TempDir(), "darwin") + tool := env.NewShowArtifactTool(&ShowArtifactDeps{}) + info, err := tool.Info(context.Background()) + if err != nil { + t.Fatal(err) + } + if info.Name != "show_artifact" || !strings.Contains(info.Desc, "Web/Desktop") || !strings.Contains(info.Desc, "does not upload") { + t.Fatalf("tool info=%+v", info) + } +} + +func TestShowArtifactCanForceNoFocusForAutomation(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "report.md"), []byte("done"), 0o600); err != nil { + t.Fatal(err) + } + recorder := &artifactRecorderFake{} + var emitted artifact.Record + tool := NewEnv(workspace, "darwin").NewShowArtifactTool(&ShowArtifactDeps{ + SessionID: func() string { return "automation-task" }, + Recorder: recorder, + Service: artifact.NewService(nil, time.Now), + ForceNoFocus: true, + Emit: func(_ string, data any) { + emitted = data.(artifact.Record) + }, + }) + if _, err := tool.InvokableRun(context.Background(), `{"path":"report.md","focus":true}`); err != nil { + t.Fatal(err) + } + if emitted.Focus { + t.Fatal("automation artifact events must never request foreground focus") + } +} diff --git a/internal/web/artifacts.go b/internal/web/artifacts.go new file mode 100644 index 00000000..a89cbd19 --- /dev/null +++ b/internal/web/artifacts.go @@ -0,0 +1,366 @@ +package web + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cnjack/jcode/internal/artifact" + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/session" +) + +var errArtifactChanged = errors.New("artifact changed while creating snapshot") + +type artifactSnapshotFile interface { + io.ReadSeeker + Stat() (os.FileInfo, error) +} + +func (s *Server) artifactWorkspace(r *http.Request) (string, string, error) { + sessionID := r.PathValue("id") + if err := session.ValidateSessionID(sessionID); err != nil { + return "", "", err + } + if eng := s.resolveEngine(sessionID); eng != nil && eng.env != nil && eng.env.IsRemote() { + return "", "", fmt.Errorf("remote artifacts are not supported") + } + workspace, err := s.workspacePwdForTask(sessionID) + if err != nil { + return "", "", err + } + if workspace == "" { + return "", "", os.ErrNotExist + } + return sessionID, workspace, nil +} + +func (s *Server) handleListArtifacts(w http.ResponseWriter, r *http.Request) { + if s.artifacts == nil { + writeJSON(w, http.StatusOK, []artifact.Record{}) + return + } + sessionID, workspace, err := s.artifactWorkspace(r) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task artifacts not found"}) + return + } + records, err := s.artifacts.List(r.Context(), sessionID, workspace) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load task artifacts"}) + return + } + if records == nil { + records = []artifact.Record{} + } + writeJSON(w, http.StatusOK, records) +} + +func artifactInlineLimit(kind artifact.Kind) int64 { + switch kind { + case artifact.KindText, artifact.KindMarkdown, artifact.KindCode, artifact.KindHTML, artifact.KindCSV: + return artifact.MaxInlineTextSize + default: + return artifact.MaxInlineBinarySize + } +} + +func setArtifactContentHeaders(w http.ResponseWriter, record artifact.Record) { + w.Header().Set("Content-Type", record.MediaType) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox") +} + +func (s *Server) handleArtifactContent(w http.ResponseWriter, r *http.Request) { + s.serveArtifactFile(w, r, false) +} + +func (s *Server) handleArtifactDownload(w http.ResponseWriter, r *http.Request) { + s.serveArtifactFile(w, r, true) +} + +func (s *Server) serveArtifactFile(w http.ResponseWriter, r *http.Request, download bool) { + if s.artifacts == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + sessionID, workspace, err := s.artifactWorkspace(r) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + record, file, err := s.artifacts.Open(r.Context(), sessionID, workspace, r.PathValue("artifactID")) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + defer func() { _ = file.Close() }() + limit := artifactInlineLimit(record.Kind) + if download { + limit = artifact.MaxDownloadSize + } + if record.Size > limit { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "artifact_too_large"}) + return + } + setArtifactContentHeaders(w, record) + name := artifactDownloadName(record.RelativePath) + if download { + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) + } + http.ServeContent(w, r, name, record.UpdatedAt, file) +} + +func artifactDownloadName(relativePath string) string { + base := filepath.Base(relativePath) + var safe strings.Builder + for _, char := range base { + if char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' { + safe.WriteByte('_') + continue + } + safe.WriteRune(char) + } + name := strings.TrimSpace(safe.String()) + if name == "" || name == "." { + return "artifact" + } + return name +} + +func (s *Server) handleArtifactsViewed(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if err := session.ValidateSessionID(sessionID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid task id"}) + return + } + meta, err := session.UpdateSessionMeta(sessionID, func(meta *session.SessionMeta) { + meta.ArtifactViewedAt = meta.ArtifactUpdatedAt + if meta.ArtifactViewedAt == "" { + meta.ArtifactViewedAt = time.Now().UTC().Format(time.RFC3339Nano) + } + meta.ArtifactUnseen = false + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not update artifact view state"}) + return + } + if meta == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func readArtifactSnapshot(file artifactSnapshotFile, limit int64) ([]byte, error) { + before, err := file.Stat() + if err != nil { + return nil, err + } + if before.Size() > limit { + return nil, artifact.ErrTooLarge + } + content, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > limit { + return nil, artifact.ErrTooLarge + } + after, err := file.Stat() + if err != nil { + return nil, err + } + if before.Size() != after.Size() || !before.ModTime().Equal(after.ModTime()) { + return nil, errArtifactChanged + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return nil, err + } + verification, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, err + } + if !bytes.Equal(content, verification) { + return nil, errArtifactChanged + } + return content, nil +} + +func (s *Server) cloudArtifactCredentials(w http.ResponseWriter) (*cloud.Credentials, bool) { + if s.loadCloudCredentials == nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "cloud login is required"}) + return nil, false + } + creds, err := s.loadCloudCredentials() + if err != nil { + config.Logger().Printf("[artifact-share] load credentials: %v", err) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "cloud credentials are unavailable"}) + return nil, false + } + if creds == nil || strings.TrimSpace(creds.DeviceToken) == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "cloud login is required"}) + return nil, false + } + return creds, true +} + +func decodeArtifactShareRequest(r *http.Request) (time.Duration, error) { + var req struct { + ExpiresInSeconds int64 `json:"expires_in_seconds,omitempty"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, 4097)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + return 0, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return 0, fmt.Errorf("request body must contain one JSON object") + } + if req.ExpiresInSeconds == 0 { + req.ExpiresInSeconds = int64((7 * 24 * time.Hour) / time.Second) + } + if req.ExpiresInSeconds < int64(time.Hour/time.Second) || req.ExpiresInSeconds > int64((30*24*time.Hour)/time.Second) { + return 0, fmt.Errorf("expiry must be between 1 hour and 30 days") + } + return time.Duration(req.ExpiresInSeconds) * time.Second, nil +} + +func (s *Server) handleCreateArtifactShare(w http.ResponseWriter, r *http.Request) { + if s.artifacts == nil || s.artifactShares == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact sharing is unavailable"}) + return + } + creds, ok := s.cloudArtifactCredentials(w) + if !ok { + return + } + expiresIn, err := decodeArtifactShareRequest(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid artifact share request"}) + return + } + sessionID, workspace, err := s.artifactWorkspace(r) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + record, file, err := s.artifacts.Open(r.Context(), sessionID, workspace, r.PathValue("artifactID")) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + defer func() { _ = file.Close() }() + content, err := readArtifactSnapshot(file, artifact.MaxShareSize) + if errors.Is(err, artifact.ErrTooLarge) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "artifact_too_large"}) + return + } + if errors.Is(err, errArtifactChanged) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "artifact_changed"}) + return + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not snapshot artifact"}) + return + } + result, err := s.artifactShares.Publish(r.Context(), creds, cloud.ArtifactShareInput{ + ArtifactID: record.ID, Revision: record.Revision, Title: record.Title, + RelativePath: record.RelativePath, MediaType: record.MediaType, Kind: string(record.Kind), + Content: content, ExpiresIn: expiresIn, + }) + if err != nil { + config.Logger().Printf("[artifact-share] publish %s r%d: %v", record.ID, record.Revision, err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not share artifact"}) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + writeJSON(w, http.StatusCreated, result) +} + +func (s *Server) artifactRecord(r *http.Request) (artifact.Record, error) { + if s.artifacts == nil { + return artifact.Record{}, os.ErrNotExist + } + sessionID, workspace, err := s.artifactWorkspace(r) + if err != nil { + return artifact.Record{}, err + } + records, err := s.artifacts.List(r.Context(), sessionID, workspace) + if err != nil { + return artifact.Record{}, err + } + for _, record := range records { + if record.ID == r.PathValue("artifactID") { + return record, nil + } + } + return artifact.Record{}, os.ErrNotExist +} + +func (s *Server) handleListArtifactShares(w http.ResponseWriter, r *http.Request) { + record, err := s.artifactRecord(r) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + creds, ok := s.cloudArtifactCredentials(w) + if !ok { + return + } + shares, err := s.artifactShares.List(r.Context(), creds, record.ID) + if err != nil { + config.Logger().Printf("[artifact-share] list %s: %v", record.ID, err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not load artifact shares"}) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, shares) +} + +func (s *Server) handleRevokeArtifactShare(w http.ResponseWriter, r *http.Request) { + record, err := s.artifactRecord(r) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + creds, ok := s.cloudArtifactCredentials(w) + if !ok { + return + } + shares, err := s.artifactShares.List(r.Context(), creds, record.ID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not verify artifact share"}) + return + } + shareID := r.PathValue("shareID") + owned := false + for _, share := range shares { + if share.ShareID == shareID && share.ArtifactID == record.ID { + owned = true + break + } + } + if !owned { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact share not found"}) + return + } + if err := s.artifactShares.Revoke(r.Context(), creds, shareID); err != nil { + config.Logger().Printf("[artifact-share] revoke %s: %v", shareID, err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not revoke artifact share"}) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/web/artifacts_desktop.go b/internal/web/artifacts_desktop.go new file mode 100644 index 00000000..6f9780dd --- /dev/null +++ b/internal/web/artifacts_desktop.go @@ -0,0 +1,108 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/cnjack/jcode/internal/artifact" + "github.com/cnjack/jcode/internal/config" +) + +func (s *Server) handleOpenArtifact(w http.ResponseWriter, r *http.Request) { + s.handleArtifactDesktopAction(w, r, false) +} + +func (s *Server) handleRevealArtifact(w http.ResponseWriter, r *http.Request) { + s.handleArtifactDesktopAction(w, r, true) +} + +func (s *Server) handleArtifactDesktopAction(w http.ResponseWriter, r *http.Request, reveal bool) { + if s.artifacts == nil || s.openArtifact == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + sessionID, workspace, err := s.artifactWorkspace(r) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + record, absolutePath, err := s.artifacts.Resolve(r.Context(), sessionID, workspace, r.PathValue("artifactID")) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + if !reveal && !artifactHostOpenAllowed(record) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "active or executable artifacts can only be previewed in the sandboxed viewer"}) + return + } + if err := s.openArtifact(r.Context(), absolutePath, reveal); err != nil { + config.Logger().Printf("[artifact] desktop action reveal=%v path=%s: %v", reveal, absolutePath, err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "desktop artifact action failed"}) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +var blockedArtifactHostOpenExtensions = map[string]struct{}{ + ".app": {}, ".application": {}, ".bat": {}, ".cmd": {}, ".com": {}, ".command": {}, + ".cpl": {}, ".desktop": {}, ".exe": {}, ".gadget": {}, ".hta": {}, ".htm": {}, ".html": {}, + ".inf": {}, ".ins": {}, ".isp": {}, ".jar": {}, ".js": {}, ".jse": {}, ".lnk": {}, ".msc": {}, + ".msi": {}, ".msp": {}, ".mst": {}, ".pif": {}, ".ps1": {}, ".reg": {}, ".scr": {}, ".sh": {}, + ".svg": {}, ".svgz": {}, ".url": {}, ".vb": {}, ".vbe": {}, ".vbs": {}, ".workflow": {}, + ".ws": {}, ".wsf": {}, ".wsh": {}, ".xhtml": {}, +} + +func artifactHostOpenAllowed(record artifact.Record) bool { + if record.Kind == artifact.KindHTML { + return false + } + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(record.MediaType, ";")[0])) + if mediaType == "text/html" || mediaType == "application/xhtml+xml" || mediaType == "image/svg+xml" { + return false + } + _, blocked := blockedArtifactHostOpenExtensions[strings.ToLower(filepath.Ext(record.RelativePath))] + return !blocked +} + +func openArtifactOnDesktop(ctx context.Context, path string, reveal bool) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect desktop artifact: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("desktop artifact must remain a regular non-symlink file") + } + var command *exec.Cmd + switch runtime.GOOS { + case "darwin": + args := []string{path} + if reveal { + args = []string{"-R", path} + } + command = exec.CommandContext(ctx, "open", args...) + case "windows": + if reveal { + command = exec.CommandContext(ctx, "explorer.exe", "/select,"+path) + } else { + command = exec.CommandContext(ctx, "rundll32.exe", "url.dll,FileProtocolHandler", path) + } + default: + target := path + if reveal { + target = filepath.Dir(path) + } + command = exec.CommandContext(ctx, "xdg-open", target) + } + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %w (%s)", command.Path, err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/internal/web/artifacts_test.go b/internal/web/artifacts_test.go new file mode 100644 index 00000000..4177b380 --- /dev/null +++ b/internal/web/artifacts_test.go @@ -0,0 +1,406 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cnjack/jcode/internal/artifact" + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/session" +) + +func artifactWebFixture(t *testing.T, name string, content []byte) (*Server, artifact.Record, string) { + return artifactWebFixtureWithKind(t, name, content, artifact.KindAuto) +} + +func artifactWebFixtureWithKind(t *testing.T, name string, content []byte, kind artifact.Kind) (*Server, artifact.Record, string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, name), content, 0o600); err != nil { + t.Fatal(err) + } + recorder, err := session.NewRecorder(workspace, "kimi", "kimi-for-coding") + if err != nil { + t.Fatal(err) + } + service := artifact.NewService(session.LoadArtifactRecords, time.Now) + record, err := service.Register(context.Background(), artifact.RegisterRequest{ + SessionID: recorder.UUID(), Workspace: workspace, RelativePath: name, Kind: kind, Focus: true, + }, recorder) + if err != nil { + t.Fatal(err) + } + eng := &Engine{taskID: recorder.UUID(), pwd: workspace, recorder: recorder} + return &Server{Engine: eng, tasks: map[string]*Engine{eng.taskID: eng}, artifacts: service}, record, workspace +} + +type fakeArtifactSharePublisher struct { + publishInput cloud.ArtifactShareInput + publishCalls int + list []cloud.ArtifactShareSummary + revoked string +} + +func (f *fakeArtifactSharePublisher) Publish(_ context.Context, _ *cloud.Credentials, input cloud.ArtifactShareInput) (*cloud.ArtifactShareResult, error) { + f.publishCalls++ + f.publishInput = input + return &cloud.ArtifactShareResult{ShareID: "share-1", URL: "https://share.example/s/share-1#k=v1.secret", ExpiresAt: time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC)}, nil +} + +func (f *fakeArtifactSharePublisher) List(_ context.Context, _ *cloud.Credentials, _ string) ([]cloud.ArtifactShareSummary, error) { + return f.list, nil +} + +func (f *fakeArtifactSharePublisher) Revoke(_ context.Context, _ *cloud.Credentials, shareID string) error { + f.revoked = shareID + return nil +} + +func TestArtifactListAndContentAreTaskScopedAndSecurityHardened(t *testing.T) { + content := []byte("

isolated

") + srv, record, _ := artifactWebFixture(t, "report.html", content) + + listW := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/tasks/"+record.SessionID+"/artifacts", nil) + listReq.SetPathValue("id", record.SessionID) + srv.handleListArtifacts(listW, listReq) + if listW.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", listW.Code, listW.Body.String()) + } + var records []artifact.Record + if err := json.Unmarshal(listW.Body.Bytes(), &records); err != nil || len(records) != 1 || records[0].ID != record.ID { + t.Fatalf("records=%+v err=%v", records, err) + } + + contentW := httptest.NewRecorder() + contentReq := httptest.NewRequest(http.MethodGet, "/api/tasks/"+record.SessionID+"/artifacts/"+record.ID+"/content", nil) + contentReq.SetPathValue("id", record.SessionID) + contentReq.SetPathValue("artifactID", record.ID) + srv.handleArtifactContent(contentW, contentReq) + if contentW.Code != http.StatusOK || !bytes.Equal(contentW.Body.Bytes(), content) { + t.Fatalf("content status=%d body=%q", contentW.Code, contentW.Body.String()) + } + if got := contentW.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("nosniff=%q", got) + } + if csp := contentW.Header().Get("Content-Security-Policy"); csp == "" || !bytes.Contains([]byte(csp), []byte("connect-src 'none'")) { + t.Fatalf("csp=%q", csp) + } + + forgedW := httptest.NewRecorder() + forgedReq := httptest.NewRequest(http.MethodGet, "/api/tasks/"+record.SessionID+"/artifacts/forged/content", nil) + forgedReq.SetPathValue("id", record.SessionID) + forgedReq.SetPathValue("artifactID", "forged") + srv.handleArtifactContent(forgedW, forgedReq) + if forgedW.Code != http.StatusNotFound { + t.Fatalf("forged status=%d body=%s", forgedW.Code, forgedW.Body.String()) + } +} + +func TestArtifactContentRejectsSymlinkSwapAfterRegistration(t *testing.T) { + srv, record, workspace := artifactWebFixture(t, "report.txt", []byte("safe")) + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("SECRET"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "report.txt")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(workspace, "report.txt")); err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/content", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleArtifactContent(w, req) + if w.Code != http.StatusNotFound || bytes.Contains(w.Body.Bytes(), []byte("SECRET")) { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestArtifactContentRejectsSensitiveInWorkspaceSymlinkSwap(t *testing.T) { + srv, record, workspace := artifactWebFixture(t, "report.txt", []byte("safe")) + secret := filepath.Join(workspace, ".env") + if err := os.WriteFile(secret, []byte("TOKEN=SECRET"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "report.txt")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(".env", filepath.Join(workspace, "report.txt")); err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/content", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleArtifactContent(w, req) + if w.Code != http.StatusNotFound || bytes.Contains(w.Body.Bytes(), []byte("SECRET")) { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestArtifactDownloadUsesSafeContentDisposition(t *testing.T) { + srv, record, _ := artifactWebFixture(t, "report\r\nX-Injected.txt", []byte("safe")) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/download", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleArtifactDownload(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } + disposition := w.Header().Get("Content-Disposition") + if strings.ContainsAny(disposition, "\r\n") || w.Header().Get("X-Injected") != "" { + t.Fatalf("unsafe Content-Disposition: %q", disposition) + } +} + +func TestMarkArtifactsViewedClearsDurableUnseenState(t *testing.T) { + srv, record, _ := artifactWebFixture(t, "report.md", []byte("# done")) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+record.SessionID+"/artifacts/viewed", nil) + req.SetPathValue("id", record.SessionID) + srv.handleArtifactsViewed(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + metas, err := session.ListSessions(srv.pwd) + if err != nil || len(metas) != 1 || metas[0].ArtifactUnseen || metas[0].ArtifactViewedAt == "" { + t.Fatalf("metas=%+v err=%v", metas, err) + } +} + +func TestArtifactShareRequiresLoginWithoutCallingCloud(t *testing.T) { + srv, record, _ := artifactWebFixture(t, "report.md", []byte("# private")) + publisher := &fakeArtifactSharePublisher{} + srv.artifactShares = publisher + srv.loadCloudCredentials = func() (*cloud.Credentials, error) { return nil, nil } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/share", bytes.NewBufferString(`{}`)) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleCreateArtifactShare(w, req) + if w.Code != http.StatusUnauthorized || publisher.publishCalls != 0 { + t.Fatalf("status=%d calls=%d body=%s", w.Code, publisher.publishCalls, w.Body.String()) + } +} + +func TestArtifactSharePublishesAnImmutableTaskScopedSnapshot(t *testing.T) { + content := []byte("# final result") + srv, record, _ := artifactWebFixture(t, "report.md", content) + publisher := &fakeArtifactSharePublisher{} + srv.artifactShares = publisher + srv.loadCloudCredentials = func() (*cloud.Credentials, error) { + return &cloud.Credentials{CloudURL: "https://cloud.example", DeviceToken: "token"}, nil + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/share", bytes.NewBufferString(`{"expires_in_seconds":3600}`)) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleCreateArtifactShare(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if publisher.publishCalls != 1 || !bytes.Equal(publisher.publishInput.Content, content) || + publisher.publishInput.ArtifactID != record.ID || publisher.publishInput.Revision != record.Revision || + publisher.publishInput.RelativePath != record.RelativePath || publisher.publishInput.ExpiresIn != time.Hour { + t.Fatalf("publish input = %#v", publisher.publishInput) + } + if w.Header().Get("Cache-Control") != "no-store" || !strings.Contains(w.Body.String(), "#k=v1.secret") { + t.Fatalf("headers=%v body=%s", w.Header(), w.Body.String()) + } +} + +func TestArtifactShareRevokeIsScopedToTheSelectedArtifact(t *testing.T) { + srv, record, _ := artifactWebFixture(t, "report.md", []byte("done")) + publisher := &fakeArtifactSharePublisher{list: []cloud.ArtifactShareSummary{{ShareID: "different-share", ArtifactID: record.ID}}} + srv.artifactShares = publisher + srv.loadCloudCredentials = func() (*cloud.Credentials, error) { + return &cloud.Credentials{CloudURL: "https://cloud.example", DeviceToken: "token"}, nil + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/share", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + req.SetPathValue("shareID", "forged-share") + srv.handleRevokeArtifactShare(w, req) + if w.Code != http.StatusNotFound || publisher.revoked != "" { + t.Fatalf("status=%d revoked=%q body=%s", w.Code, publisher.revoked, w.Body.String()) + } +} + +type changingSnapshotFile struct { + reader *bytes.Reader + stats int +} + +func (f *changingSnapshotFile) Read(p []byte) (int, error) { return f.reader.Read(p) } +func (f *changingSnapshotFile) Seek(offset int64, whence int) (int64, error) { + return f.reader.Seek(offset, whence) +} +func (f *changingSnapshotFile) Stat() (os.FileInfo, error) { + f.stats++ + return snapshotFileInfo{size: int64(f.reader.Len()), mod: time.Unix(int64(f.stats), 0)}, nil +} + +type snapshotFileInfo struct { + size int64 + mod time.Time +} + +type sameStatChangingSnapshotFile struct { + reader *bytes.Reader + first []byte + second []byte +} + +func (f *sameStatChangingSnapshotFile) Read(p []byte) (int, error) { return f.reader.Read(p) } +func (f *sameStatChangingSnapshotFile) Seek(offset int64, whence int) (int64, error) { + if offset == 0 && whence == io.SeekStart { + f.reader = bytes.NewReader(f.second) + } + return f.reader.Seek(offset, whence) +} +func (f *sameStatChangingSnapshotFile) Stat() (os.FileInfo, error) { + return snapshotFileInfo{size: int64(len(f.first)), mod: time.Unix(1, 0)}, nil +} + +func (f snapshotFileInfo) Name() string { return "artifact" } +func (f snapshotFileInfo) Size() int64 { return f.size } +func (f snapshotFileInfo) Mode() os.FileMode { return 0o600 } +func (f snapshotFileInfo) ModTime() time.Time { return f.mod } +func (f snapshotFileInfo) IsDir() bool { return false } +func (f snapshotFileInfo) Sys() any { return nil } + +func TestReadArtifactSnapshotRejectsAFileChangedDuringRead(t *testing.T) { + file := &changingSnapshotFile{reader: bytes.NewReader([]byte("changing"))} + _, err := readArtifactSnapshot(file, artifact.MaxShareSize) + if !errors.Is(err, errArtifactChanged) { + t.Fatalf("readArtifactSnapshot error = %v", err) + } +} + +func TestReadArtifactSnapshotRejectsSameSizeAndTimestampRewrite(t *testing.T) { + file := &sameStatChangingSnapshotFile{ + first: []byte("version-one"), second: []byte("version-two"), reader: bytes.NewReader([]byte("version-one")), + } + _, err := readArtifactSnapshot(file, artifact.MaxShareSize) + if !errors.Is(err, errArtifactChanged) { + t.Fatalf("readArtifactSnapshot error = %v", err) + } +} + +func TestDesktopArtifactActionsResolveOnlyRegisteredTaskArtifacts(t *testing.T) { + srv, record, workspace := artifactWebFixture(t, "report.txt", []byte("done")) + var openedPath string + var revealed bool + srv.openArtifact = func(_ context.Context, path string, reveal bool) error { + openedPath, revealed = path, reveal + return nil + } + wantPath, err := filepath.EvalSymlinks(filepath.Join(workspace, "report.txt")) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/open", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleOpenArtifact(w, req) + if w.Code != http.StatusNoContent || openedPath != wantPath || revealed { + t.Fatalf("status=%d path=%q reveal=%v", w.Code, openedPath, revealed) + } + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/reveal", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleRevealArtifact(w, req) + if w.Code != http.StatusNoContent || !revealed { + t.Fatalf("status=%d reveal=%v", w.Code, revealed) + } + + openedPath = "" + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/open", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", "forged") + srv.handleOpenArtifact(w, req) + if w.Code != http.StatusNotFound || openedPath != "" { + t.Fatalf("forged status=%d path=%q", w.Code, openedPath) + } +} + +func TestDesktopArtifactOpenRejectsActiveHTMLButRevealRemainsAvailable(t *testing.T) { + srv, record, _ := artifactWebFixture(t, "report.html", []byte("")) + called := false + srv.openArtifact = func(_ context.Context, _ string, _ bool) error { + called = true + return nil + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/open", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleOpenArtifact(w, req) + if w.Code != http.StatusForbidden || called { + t.Fatalf("open status=%d called=%v", w.Code, called) + } + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/reveal", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleRevealArtifact(w, req) + if w.Code != http.StatusNoContent || !called { + t.Fatalf("reveal status=%d called=%v", w.Code, called) + } +} + +func TestDesktopArtifactOpenRejectsSpoofedActiveAndExecutableFiles(t *testing.T) { + tests := []struct { + name string + file string + kind artifact.Kind + }{ + {name: "html kind downgrade", file: "report.html", kind: artifact.KindText}, + {name: "svg active document", file: "diagram.svg", kind: artifact.KindImage}, + {name: "mac command", file: "report.command", kind: artifact.KindText}, + {name: "windows script host", file: "report.js", kind: artifact.KindCode}, + {name: "windows batch", file: "report.bat", kind: artifact.KindCode}, + {name: "linux launcher", file: "report.desktop", kind: artifact.KindText}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, record, _ := artifactWebFixtureWithKind(t, tt.file, []byte("active"), tt.kind) + called := false + srv.openArtifact = func(_ context.Context, _ string, _ bool) error { + called = true + return nil + } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/open", nil) + req.SetPathValue("id", record.SessionID) + req.SetPathValue("artifactID", record.ID) + srv.handleOpenArtifact(w, req) + if w.Code != http.StatusForbidden || called { + t.Fatalf("file=%q kind=%q status=%d called=%v", tt.file, tt.kind, w.Code, called) + } + }) + } +} diff --git a/internal/web/artifacts_web_test.go b/internal/web/artifacts_web_test.go new file mode 100644 index 00000000..995f3507 --- /dev/null +++ b/internal/web/artifacts_web_test.go @@ -0,0 +1,12 @@ +//go:build !desktop + +package web + +import "testing" + +func TestPlainWebBuildDoesNotConfigureDesktopArtifactOpener(t *testing.T) { + srv := NewServer(&ServerConfig{}) + if srv.openArtifact != nil { + t.Fatal("plain web builds must not expose host open/reveal actions") + } +} diff --git a/internal/web/automation_api.go b/internal/web/automation_api.go index b9b5ca34..5973f30b 100644 --- a/internal/web/automation_api.go +++ b/internal/web/automation_api.go @@ -236,6 +236,8 @@ type automationRun struct { TerminalStatus string `json:"terminal_status,omitempty"` Status string `json:"status,omitempty"` ErrorReason string `json:"error_reason,omitempty"` + ArtifactCount int `json:"artifact_count,omitempty"` + ArtifactUnseen bool `json:"artifact_unseen,omitempty"` } func (s *Server) handleListAutomationRuns(w http.ResponseWriter, r *http.Request) { @@ -276,6 +278,8 @@ func (s *Server) handleListAutomationRuns(w http.ResponseWriter, r *http.Request TerminalStatus: m.TerminalStatus, Status: m.Status, ErrorReason: m.ErrorReason, + ArtifactCount: m.ArtifactCount, + ArtifactUnseen: m.ArtifactUnseen, }) } } diff --git a/internal/web/automation_api_test.go b/internal/web/automation_api_test.go index f9484f80..52b5a6a3 100644 --- a/internal/web/automation_api_test.go +++ b/internal/web/automation_api_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/cnjack/jcode/internal/automation" + "github.com/cnjack/jcode/internal/session" ) func newAutomationTestServer(t *testing.T) *Server { @@ -19,6 +20,34 @@ func newAutomationTestServer(t *testing.T) *Server { return &Server{automations: store} } +func TestAutomationRunsExposeArtifactSummary(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + project := t.TempDir() + recorder, err := session.NewRecorder(project, "openai", "gpt-5") + if err != nil { + t.Fatal(err) + } + recorder.RecordUser("run") + if _, err := session.UpdateSessionMeta(recorder.UUID(), func(meta *session.SessionMeta) { + meta.AutomationID = "automation-1" + meta.TriggerKind = "manual" + meta.ArtifactCount = 2 + meta.ArtifactUnseen = true + }); err != nil { + t.Fatal(err) + } + s := &Server{} + w := httptest.NewRecorder() + s.handleListAutomationRuns(w, httptest.NewRequest(http.MethodGet, "/api/automations/runs", nil)) + var runs []automationRun + if err := json.Unmarshal(w.Body.Bytes(), &runs); err != nil { + t.Fatal(err) + } + if len(runs) != 1 || runs[0].ArtifactCount != 2 || !runs[0].ArtifactUnseen { + t.Fatalf("runs = %#v", runs) + } +} + func TestAutomationAPI_CRUD(t *testing.T) { s := newAutomationTestServer(t) proj := t.TempDir() diff --git a/internal/web/models_test.go b/internal/web/models_test.go index db5ac650..65922d6d 100644 --- a/internal/web/models_test.go +++ b/internal/web/models_test.go @@ -27,6 +27,11 @@ func TestWebSwitchModelSameValueIsNoOp(t *testing.T) { } func TestProviderCatalogUsesPersistedModelVisibility(t *testing.T) { + const ( + providerID = "kimi-for-coding" + enabledModelID = "kimi-for-coding-highspeed" + disabledModelID = "k3" + ) home := t.TempDir() t.Setenv("HOME", home) configDir := filepath.Join(home, ".jcode") @@ -35,19 +40,19 @@ func TestProviderCatalogUsesPersistedModelVisibility(t *testing.T) { } if err := os.WriteFile( filepath.Join(configDir, "config.json"), - []byte(`{"providers":{"zhipuai-coding-plan":{"api_key":"test"}}}`), + []byte(`{"providers":{"kimi-for-coding":{"api_key":"test"}}}`), 0o600, ); err != nil { t.Fatal(err) } state := &config.ModelState{ EnabledModels: []config.ModelRef{{ - Provider: "zhipuai-coding-plan", - Model: "glm-4.5-air", + Provider: providerID, + Model: enabledModelID, }}, DisabledModels: []config.ModelRef{{ - Provider: "zhipuai-coding-plan", - Model: "glm-5.2", + Provider: providerID, + Model: disabledModelID, }}, } if err := config.SaveModelState(state); err != nil { @@ -55,9 +60,16 @@ func TestProviderCatalogUsesPersistedModelVisibility(t *testing.T) { } registry := model.NewModelRegistry() + _, enabledModel, ok := registry.LookupModel(providerID, enabledModelID) + if !ok { + t.Fatalf("static model %s/%s is missing", providerID, enabledModelID) + } + // Make the enabled override observable instead of relying on the static + // provider's current default visibility. + enabledModel.DefaultEnabled = false s := &Server{registry: registry} - req := httptest.NewRequest(http.MethodGet, "/api/providers/zhipuai-coding-plan/models", nil) - req.SetPathValue("id", "zhipuai-coding-plan") + req := httptest.NewRequest(http.MethodGet, "/api/providers/"+providerID+"/models", nil) + req.SetPathValue("id", providerID) rec := httptest.NewRecorder() s.handleProviderCatalog(rec, req) if rec.Code != http.StatusOK { @@ -75,10 +87,10 @@ func TestProviderCatalogUsesPersistedModelVisibility(t *testing.T) { for _, item := range got { addedByID[item.ID] = item.Added } - if !addedByID["glm-4.5-air"] { - t.Error("explicitly enabled glm-4.5-air was reported disabled") + if !addedByID[enabledModelID] { + t.Errorf("explicitly enabled %s was reported disabled", enabledModelID) } - if addedByID["glm-5.2"] { - t.Error("explicitly disabled default glm-5.2 was reported enabled") + if addedByID[disabledModelID] { + t.Errorf("explicitly disabled default %s was reported enabled", disabledModelID) } } diff --git a/internal/web/server.go b/internal/web/server.go index 0900f4dc..620c62f9 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -14,12 +14,14 @@ import ( "sync/atomic" "github.com/cloudwego/eino/adk" + "github.com/cnjack/jcode/internal/artifact" "github.com/cnjack/jcode/internal/automation" "github.com/cnjack/jcode/internal/browser" "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/cloud" "github.com/cnjack/jcode/internal/computer" "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/feature" "github.com/cnjack/jcode/internal/flow" "github.com/cnjack/jcode/internal/handler" "github.com/cnjack/jcode/internal/mode" @@ -179,6 +181,24 @@ type Server struct { cloudSyncMu sync.Mutex cloudSyncStore *cloud.SyncStore cloudSyncErr error // sticky load failure + + // artifacts is the process-wide metadata registry shared by local Web and + // Desktop task engines. nil only in focused server tests. + artifacts *artifact.Service + artifactShares ArtifactSharePublisher + loadCloudCredentials func() (*cloud.Credentials, error) + openArtifact ArtifactOpener +} + +type ArtifactOpener func(context.Context, string, bool) error + +// ArtifactSharePublisher is consumed by the local Web API and implemented by +// cloud.ArtifactSharePublisher. Keeping the interface here makes task scoping +// and login behavior independently testable without a Cloud deployment. +type ArtifactSharePublisher interface { + Publish(context.Context, *cloud.Credentials, cloud.ArtifactShareInput) (*cloud.ArtifactShareResult, error) + List(context.Context, *cloud.Credentials, string) ([]cloud.ArtifactShareSummary, error) + Revoke(context.Context, *cloud.Credentials, string) error } // BLEController lets the settings endpoint start/stop the BLE status channel at @@ -259,6 +279,10 @@ type ServerConfig struct { MemoryStart func(context.Context, string) (<-chan error, error) // optional: acquire and start one manual local-project memory distillation BLEController BLEController // optional: live BLE status-channel toggle (desktop builds) CloudSupervisor CloudSupervisor // optional: cloud relay status + live auto_connect toggle + ArtifactService *artifact.Service // optional: session Artifact registry + ArtifactShares ArtifactSharePublisher // optional: encrypted Cloud artifact publisher + CloudCredentials func() (*cloud.Credentials, error) // optional: injectable credential loader + OpenArtifact ArtifactOpener // optional: Desktop open/reveal adapter } // NewServer creates a new web server. @@ -299,40 +323,56 @@ func NewServer(cfg *ServerConfig) *Server { if boot.taskID == "" && boot.recorder != nil { boot.taskID = boot.recorder.UUID() } + artifactShares := cfg.ArtifactShares + if artifactShares == nil { + artifactShares = cloud.NewArtifactSharePublisher(nil) + } + loadCloudCredentials := cfg.CloudCredentials + if loadCloudCredentials == nil { + loadCloudCredentials = cloud.LoadCredentials + } + openArtifact := cfg.OpenArtifact + if openArtifact == nil && feature.Desktop { + openArtifact = openArtifactOnDesktop + } s := &Server{ - Engine: boot, - tasks: make(map[string]*Engine), - port: cfg.Port, - host: cfg.Host, - openBrowser: cfg.OpenBrowser, - version: cfg.Version, - wsBroker: NewWSBroker(), - newEngine: cfg.NewEngine, - newRemoteEngine: cfg.NewRemoteEngine, - newAutomationEngine: cfg.NewAutomationEngine, - remoteConns: newRemoteConnRegistry(), - tracer: cfg.Tracer, - cfg: cfg.Config, - registry: cfg.Registry, - ptyMgr: newPTYManager(), - skillLoader: cfg.SkillLoader, - flowLoader: cfg.FlowLoader, - reloadMCP: cfg.ReloadMCP, - mcpStatuses: make(map[string]tools.MCPStatus), - mcpLogins: make(map[string]*mcpLoginState), - wechatClient: cfg.WechatClient, - needsSetup: cfg.NeedsSetup, - automations: cfg.Automations, - autoRunInflight: make(map[string]bool), - authToken: cfg.AuthToken, - requireAuth: cfg.RequireAuth, - browserMgr: cfg.BrowserManager, - computerMgr: cfg.ComputerManager, - memoryStart: cfg.MemoryStart, - memoryRuns: make(map[string]bool), - memoryWarnings: make(map[string]string), - bleController: cfg.BLEController, - cloudSupervisor: cfg.CloudSupervisor, + Engine: boot, + tasks: make(map[string]*Engine), + port: cfg.Port, + host: cfg.Host, + openBrowser: cfg.OpenBrowser, + version: cfg.Version, + wsBroker: NewWSBroker(), + newEngine: cfg.NewEngine, + newRemoteEngine: cfg.NewRemoteEngine, + newAutomationEngine: cfg.NewAutomationEngine, + remoteConns: newRemoteConnRegistry(), + tracer: cfg.Tracer, + cfg: cfg.Config, + registry: cfg.Registry, + ptyMgr: newPTYManager(), + skillLoader: cfg.SkillLoader, + flowLoader: cfg.FlowLoader, + reloadMCP: cfg.ReloadMCP, + mcpStatuses: make(map[string]tools.MCPStatus), + mcpLogins: make(map[string]*mcpLoginState), + wechatClient: cfg.WechatClient, + needsSetup: cfg.NeedsSetup, + automations: cfg.Automations, + autoRunInflight: make(map[string]bool), + authToken: cfg.AuthToken, + requireAuth: cfg.RequireAuth, + browserMgr: cfg.BrowserManager, + computerMgr: cfg.ComputerManager, + memoryStart: cfg.MemoryStart, + memoryRuns: make(map[string]bool), + memoryWarnings: make(map[string]string), + bleController: cfg.BLEController, + cloudSupervisor: cfg.CloudSupervisor, + artifacts: cfg.ArtifactService, + artifactShares: artifactShares, + loadCloudCredentials: loadCloudCredentials, + openArtifact: openArtifact, } // The bootstrap engine is registered (and its pump started) in Start, once // the root context exists. @@ -388,6 +428,17 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/ask/pending", s.handlePendingAskUser) mux.HandleFunc("GET /api/files", s.handleListFiles) mux.HandleFunc("GET /api/files/content", s.handleReadFile) + mux.HandleFunc("GET /api/tasks/{id}/artifacts", s.handleListArtifacts) + mux.HandleFunc("GET /api/tasks/{id}/artifacts/{artifactID}/content", s.handleArtifactContent) + mux.HandleFunc("GET /api/tasks/{id}/artifacts/{artifactID}/download", s.handleArtifactDownload) + mux.HandleFunc("PATCH /api/tasks/{id}/artifacts/viewed", s.handleArtifactsViewed) + mux.HandleFunc("POST /api/tasks/{id}/artifacts/{artifactID}/shares", s.handleCreateArtifactShare) + mux.HandleFunc("GET /api/tasks/{id}/artifacts/{artifactID}/shares", s.handleListArtifactShares) + mux.HandleFunc("DELETE /api/tasks/{id}/artifacts/{artifactID}/shares/{shareID}", s.handleRevokeArtifactShare) + if s.openArtifact != nil { + mux.HandleFunc("POST /api/tasks/{id}/artifacts/{artifactID}/open", s.handleOpenArtifact) + mux.HandleFunc("POST /api/tasks/{id}/artifacts/{artifactID}/reveal", s.handleRevealArtifact) + } mux.HandleFunc("GET /api/status", s.handleStatus) mux.HandleFunc("GET /api/workspace", s.handleWorkspace) mux.HandleFunc("GET /api/git/branches", s.handleGitBranches) diff --git a/internal/web/sessions.go b/internal/web/sessions.go index dbb81aee..1e1d24cb 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -19,36 +19,40 @@ import ( // field drifting (start_time vs created_at) that would blank created_at and // scramble the recency sort. type taskItem struct { - UUID string `json:"uuid"` - Project string `json:"project"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at,omitempty"` - Provider string `json:"provider"` - Model string `json:"model"` - Agent string `json:"agent,omitempty"` - Title string `json:"title,omitempty"` - Pinned bool `json:"pinned"` - Archived bool `json:"archived"` - Unread bool `json:"unread"` - Status string `json:"status,omitempty"` - Running bool `json:"running"` + UUID string `json:"uuid"` + Project string `json:"project"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Agent string `json:"agent,omitempty"` + Title string `json:"title,omitempty"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + Unread bool `json:"unread"` + Status string `json:"status,omitempty"` + Running bool `json:"running"` + ArtifactCount int `json:"artifact_count,omitempty"` + ArtifactUnseen bool `json:"artifact_unseen,omitempty"` } func newTaskItem(m *session.SessionMeta, project string, running bool) taskItem { return taskItem{ - UUID: m.UUID, - Project: project, - CreatedAt: m.StartTime, - UpdatedAt: m.UpdatedAt, - Provider: m.Provider, - Model: m.Model, - Agent: m.Agent, - Title: m.Title, - Pinned: m.Pinned, - Archived: m.Archived, - Unread: m.Unread, - Status: m.Status, - Running: running, + UUID: m.UUID, + Project: project, + CreatedAt: m.StartTime, + UpdatedAt: m.UpdatedAt, + Provider: m.Provider, + Model: m.Model, + Agent: m.Agent, + Title: m.Title, + Pinned: m.Pinned, + Archived: m.Archived, + Unread: m.Unread, + Status: m.Status, + Running: running, + ArtifactCount: m.ArtifactCount, + ArtifactUnseen: m.ArtifactUnseen, } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 5ea7d008..b9e92444 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next' import { ArrowLeftIcon, CheckCircleIcon, + DocumentDuplicateIcon, ExclamationCircleIcon, PlayIcon, StopIcon, @@ -173,7 +174,7 @@ function store_getState() { return store.getState() } -type PanelType = 'terminal' | 'files' | 'changes' | 'plan' +type PanelType = 'terminal' | 'files' | 'changes' | 'plan' | 'artifacts' function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mobile' | 'automation-run' | 'settings' }) { const dispatch = useAppDispatch() @@ -183,10 +184,10 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob const isRunning = useAppSelector((s) => s.chat.isRunning) const wsConnected = useAppSelector((s) => s.session.wsConnected) - // Panel state — mirrors Vue App.vue: a right panel (files/changes/plan) and a + // Panel state — a right panel (files/changes/plan/artifacts) and a // bottom panel (terminal) that can be open simultaneously. const [rightPanelOpen, setRightPanelOpen] = useState(false) - const [rightPanelTab, setRightPanelTab] = useState<'files' | 'changes' | 'plan'>('files') + const [rightPanelTab, setRightPanelTab] = useState<'files' | 'changes' | 'plan' | 'artifacts'>('files') const [bottomPanel, setBottomPanel] = useState<'none' | 'terminal'>('none') const [bottomPanelHeight, setBottomPanelHeight] = useState(260) const [activeRun, setActiveRun] = useState(null) @@ -219,7 +220,8 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob }) }, [rightPanelOpen]) - // Panel keyboard shortcuts: ⇧⌘P (plan), ⇧⌘E (files), ⇧⌘G (changes), ⌘` / ⌘J (terminal). + // Panel keyboard shortcuts: ⇧⌘P (plan), ⇧⌘E (files), ⇧⌘G (changes), + // ⇧⌘A (artifacts), ⌘` / ⌘J (terminal). useEffect(() => { function onKey(e: KeyboardEvent) { const meta = e.metaKey || e.ctrlKey @@ -229,6 +231,8 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob e.preventDefault(); togglePanel('files') } else if (meta && e.shiftKey && e.key.toLowerCase() === 'g') { e.preventDefault(); togglePanel('changes') + } else if (meta && e.shiftKey && e.key.toLowerCase() === 'a') { + e.preventDefault(); togglePanel('artifacts') } else if (meta && !e.shiftKey && (e.key === '`' || e.key.toLowerCase() === 'j')) { // ⌘` never reaches the page on macOS (OS window cycling), so ⌘J is the // alias shown in the UI. `!e.shiftKey` keeps ⇧⌘J (DevTools) intact. @@ -239,6 +243,15 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob return () => window.removeEventListener('keydown', onKey) }, [togglePanel]) + useEffect(() => { + function onArtifactUpserted() { + setRightPanelTab('artifacts') + setRightPanelOpen(true) + } + window.addEventListener('jcode:artifact-upserted', onArtifactUpserted) + return () => window.removeEventListener('jcode:artifact-upserted', onArtifactUpserted) + }, []) + useEffect(() => { function onOpenRemote(e: Event) { const detail = (e as CustomEvent).detail @@ -316,7 +329,7 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob {activeView === 'automations' && } {activeView === 'cloud-mobile' && } {activeView === 'automation-run' && ( - + togglePanel('artifacts')} /> )} {/* M18: settings is a first-class view, not an overlay dialog. */} {activeView === 'settings' && } @@ -335,7 +348,7 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob )} - {/* Right panel (files/changes/plan) — sibling of main, like Vue. */} + {/* Right panel (files/changes/plan/artifacts) — sibling of main. */} {rightPanelOpen && ( void }) { +function AutomationRunReplay({ run, onBack, onOpenArtifacts }: { run: AutomationRun | null; onBack: () => void; onOpenArtifacts: () => void }) { const { t } = useTranslation() const isRunning = run ? (run.terminal_status || run.status) === 'running' || (!run.terminal_status && run.status === 'running') : false const status = run ? statusKind(run) : 'running' @@ -401,6 +414,13 @@ function AutomationRunReplay({ run, onBack }: { run: AutomationRun | null; onBac {statusLabel} + {!!run?.artifact_count && ( + + )} {isRunning && ( + ))} + + +
+ {selected ? ( +
+
+
+

{selected.title}

+

{selected.relative_path} · {formatBytes(selected.size)}

+
+
+ {cloudLoggedIn && } + {isTauri && canOpenArtifactOnDesktop(selected) && } + {isTauri && } + + +
+
+ {actionError &&
{actionError}
} +
{viewer}
+
+ ) : !loading &&
{t('artifacts.select')}
} +
+ + + {fullscreen && selected && ( +
+
+

{selected.title}

+ +
+
+
+ )} + {shareOpen && selected && ( + setShareOpen(false)} /> + )} + + ) +} + +function ArtifactShareDialog({ taskId, record, onClose }: { taskId: string; record: ArtifactRecord; onClose: () => void }) { + const { t } = useTranslation() + const [expiresIn, setExpiresIn] = useState(7 * 24 * 60 * 60) + const [sharing, setSharing] = useState(false) + const [result, setResult] = useState(null) + const [shares, setShares] = useState([]) + const [error, setError] = useState('') + + const loadShares = useCallback(async () => { + try { + setShares(await api.artifactShares(taskId, record.id)) + } catch { + setShares([]) + } + }, [record.id, taskId]) + + useEffect(() => { void loadShares() }, [loadShares]) + + async function createShare() { + if (sharing) return + setSharing(true) + setError('') + try { + const next = await api.createArtifactShare(taskId, record.id, expiresIn) + setResult(next) + await loadShares() + } catch { + setError(t('artifacts.shareDialog.createError')) + } finally { + setSharing(false) + } + } + + async function revoke(shareID: string) { + setError('') + try { + await api.revokeArtifactShare(taskId, record.id, shareID) + setShares((current) => current.filter((share) => share.share_id !== shareID)) + } catch { + setError(t('artifacts.shareDialog.revokeError')) + } + } + + async function copyLink() { + if (!result) return + try { + await navigator.clipboard.writeText(result.url) + } catch { + const input = document.querySelector('[data-artifact-share-url]') + input?.select() + document.execCommand('copy') + } + } + + return ( +
+
+
+ +

{t('artifacts.shareDialog.title')}

+ +
+
+

{t('artifacts.shareDialog.privacy')}

+ {!result ? ( +
+ + +
+ ) : ( +
+

{t('artifacts.shareDialog.ready')}

+ +
+ + {t('artifacts.shareDialog.open')} +
+

{t('artifacts.shareDialog.keyWarning')}

+
+ )} + {error &&

{error}

} + {shares.filter((share) => !share.revoked_at && share.state !== 'revoked').length > 0 && ( +
+

{t('artifacts.shareDialog.active')}

+
+ {shares.filter((share) => !share.revoked_at && share.state !== 'revoked').map((share) => ( +
+ r{share.revision} · {new Date(share.expires_at).toLocaleString()} + +
+ ))} +
+
+ )} +
+
+
+ ) +} + +function ArtifactViewer({ taskId, record }: { taskId: string; record: ArtifactRecord }) { + const { t } = useTranslation() + const [blob, setBlob] = useState(null) + const [error, setError] = useState('') + useEffect(() => { + let active = true + setBlob(null) + setError('') + if (record.status !== 'available') return () => { active = false } + void api.artifactContent(taskId, record.id).then((next) => { if (active) setBlob(next) }).catch(() => { if (active) setError(t('artifacts.contentError')) }) + return () => { active = false } + }, [record.id, record.status, taskId, t]) + + const [text, setText] = useState('') + const [textLoaded, setTextLoaded] = useState(false) + useEffect(() => { + let active = true + setText('') + setTextLoaded(false) + if (!blob || !['text', 'markdown', 'code', 'html', 'csv'].includes(record.kind)) return + void readBlobText(blob).then((value) => { + if (active) { + setText(value) + setTextLoaded(true) + } + }).catch(() => { if (active) setError(t('artifacts.contentError')) }) + return () => { active = false } + }, [blob, record.kind, t]) + + const objectURL = useMemo(() => blob && ['image', 'pdf', 'binary'].includes(record.kind) ? URL.createObjectURL(blob) : '', [blob, record.kind]) + useEffect(() => () => { if (objectURL) URL.revokeObjectURL(objectURL) }, [objectURL]) + + if (record.status !== 'available') return + if (error) return window.dispatchEvent(new Event('jcode:artifact-upserted'))} /> + if (!blob || (['text', 'markdown', 'code', 'html', 'csv'].includes(record.kind) && !textLoaded)) return + if (record.kind === 'html') return