diff --git a/AGENTS.md b/AGENTS.md index 79843c05..bbb6227b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,35 @@ Checklist before publish: - **Don't skip `env.ResolvePath()`.** Raw path concatenation can escape the working directory without warning. - **Don't import `internal/tui` from non-TUI packages.** The handler interface is the decoupling boundary. - **Don't depend on `jcode-ui` / `jcode-ui-core` via `file:` or `workspace:*`.** Consumers and `packages/jcode-ui`→core must use registry version ranges so publish and local installs match. +- **Don't let tests read the real HOME.** The pre-push hook runs the full suite; tests must `t.Setenv("HOME", t.TempDir())` whenever code under test resolves `config.ConfigDir()` (a real `~/.jcode/AGENTS.md` breaks `internal/prompts`). + +--- + +## Cloud relay (`internal/cloud/`) + +jcode can log into jcloud (device code) and be remote-controlled via an +outbound-only relay; design contract lives in the cloud repo +(`docs/17-jcode-device-relay.md`). Durable rules: + +- **Layout:** `credentials.go` (~/.jcode/cloud.json, 0600), `client.go` + (transport, 32MB response cap — attachment commands are large), + `connector.go` + `supervisor.go` (lifecycle, runtime auto_connect toggle), + `events.go` (WS event pump: durable vs ephemeral classification + + `agent_message` synthesis), `sessions.go` (index + capabilities mirror), + `crypto.go` (CEK, AES-256-GCM envelopes, P-256 ECIES, BIP39), + `pairing_inbox.go` (pairing approvals; QR offers auto-approve). +- **E2EE is the default.** All relay payloads are sealed once a CEK exists; + gray plaintext is only for pre-CEK or `cloud.e2ee:false`. Never add an + uplink path that bypasses `sealUplink`/`openDownlink`. +- **The connector is a client of the local control plane** (`/api/*` on + loopback) — it must never change engine behavior or block `jcode web`; + failures log and back off (≤60s). +- **Contract changes are cross-repo:** bump the cloud orchestrator first + (strict decode rejects unknown fields), keep `docs/17` in sync, and extend + the matching e2e journey in the cloud repo (`e2e/j7`–`j11`). +- **Cross-implementation crypto vectors** live outside the repos at + `jcode-cloud-relay/shared/test-vectors.json`; `TestSharedVectorsFile` skips + cleanly when the sibling dir is absent. --- @@ -232,3 +261,4 @@ Checklist before publish: - **Colors:** every color must come from a CSS custom property (jcode-ui tokens / `tokens.generated.css`). Never hardcode hex/rgb/`#fff`/`white` in components. - **Themes:** edit `internal/theme/palette.go` and run `make generate` — never edit `tokens.generated.css` or `themes.generated.ts` by hand. - **Reusable chat UI:** import from the `jcode-ui` package (registry); implement/fix library code under `packages/jcode-ui` and publish. +- **Product composer:** the desktop input experience (`ChatInput` + `WorkspacePicker` / `BranchPicker` / `GoalBanner`) lives in `packages/jcode-ui/src/product/` and is consumed as `jcode-ui/product`. The components are Redux/fetch/Tauri/i18next-free — hosts inject a `ProductComposerHost` (state + actions + strings + icons); the web adapter is `web/src/app/composerHost.ts`. Package tests: `cd packages/jcode-ui && pnpm test` (vitest). diff --git a/cmd/jcode/main.go b/cmd/jcode/main.go index 78c94202..7a60a92a 100644 --- a/cmd/jcode/main.go +++ b/cmd/jcode/main.go @@ -51,6 +51,9 @@ func main() { command.NewSessionsCmd(), command.NewUpdateCmd(), command.NewMemoryCmd(), + command.NewLoginCmd(), + command.NewLogoutCmd(), + command.NewCloudCmd(), ) if err := rootCmd.Execute(); err != nil { diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 5fd2bcf3..615c630e 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -126,7 +126,11 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { "127.0.0.1", "--open=false", ]) - .current_dir(workdir); + .current_dir(workdir) + // Marks the process as desktop-launched: the sidecar reports + // platform="desktop" at cloud device register (JCODE_DESKTOP detection + // in internal/cloud/connector.go). + .env("JCODE_DESKTOP", "1"); // GUI launches hand us launchd's minimal environment — no Homebrew, no // profile PATH — so the sidecar can't find `rg`, `git`, node, etc. Overlay diff --git a/docs/cloud.md b/docs/cloud.md new file mode 100644 index 00000000..195a48e0 --- /dev/null +++ b/docs/cloud.md @@ -0,0 +1,114 @@ +# jcode 云端(jcloud)用户指南 + +把本地 jcode(CLI 或桌面版)登录到 jcode Cloud 后,这台机器就成为一台可被远程查看和控制的"设备":你可以从 cloud 控制台(浏览器)或手机 app 随时给它派活、看会话、处理审批。本文面向终端用户,覆盖登录、远程使用、配对加密和密钥恢复。 + +> 命令速查:`jcode cloud guide` 会打印本文的精简版。 + +## 1. 什么是"设备" + +- 一台运行 jcode 并通过 `jcode login` 登录到 jcode Cloud 的机器 = 一台设备。登录信息保存在本机 `~/.jcode/cloud.json`。 +- 会话始终在你的机器上运行和保存;云端只做中转(relay):你的机器主动向云端发起连接,无需开放入站端口、无需 VPN。 +- 从控制台或手机发起的会话就是你本机的会话,会出现在本地 sessions 列表里。 + +## 2. 快速上手 + +在你要控制的机器上运行: + +```bash +jcode login +``` + +1. CLI 打印一个验证码(user code)和一个确认链接,并尝试自动打开浏览器。 +2. 在浏览器里确认这次登录(确认页地址为云端控制台的 `/device` 路由)。 +3. 确认后 CLI 自动完成登录,设备随即上线:打开控制台的设备列表(`/devices`)就能看到它。 + +常用变体: + +```bash +jcode login --cloud https://your-cloud.example.com # self-host 云端(必须 https;仅 localhost/127.0.0.1 开发场景允许 http) +jcode login --name "我的工作站" # 自定义设备名(默认用主机名) +jcode login --status # 查看当前登录状态 +``` + +默认云端地址是 `https://cloud.j-code.net`。 + +登录后,设备随 `jcode web` 启动自动保持连接(可在 `~/.jcode/config.json` 的 `cloud` 块中用 `auto_connect: false` 关闭)。 + +## 3. 远程使用 + +打开控制台 **设备列表**(`/devices`)或手机 app 首页: + +- 点进一台设备:发起新会话(可带 `plan` / `full_access` 等模式)、浏览历史会话。 +- 点进一个会话:实时跟进输出、发消息追加指令、**停止**运行、处理**权限审批**——和在本机上操作一致。 +- **设备离线时**:仍可翻看会话历史,但发消息、停止、审批都会被禁用,直到设备重新上线。 + +## 4. 配对与端到端加密 + +会话内容**端到端加密**(AES-256-GCM):云端只存密文和路由元数据,**云端看不到会话内容**。因此每个新客户端(控制台的一个新浏览器、一部手机)必须先与设备"配对"拿到密钥,否则只能看到配对引导卡片。 + +配对流程: + +1. 在新客户端上点击"配对"发起请求(请求 10 分钟有效)。 +2. 在设备上查看并批准: + +```bash +jcode cloud pairings # 列出待批准的配对请求(含客户端备注名) +jcode cloud approve # 批准:把加密后的密钥交给该客户端 +jcode cloud deny # 拒绝 +``` + +批准后客户端自动完成配对,之后即可正常读写加密会话。换浏览器/换手机需要重新配对。 + +## 5. 密钥与恢复 + +加密密钥(CEK)在你的第一台设备上生成,永不明文离开你的设备。 + +- **备份**(强烈建议第一时间做): + + ```bash + jcode cloud key show-phrase # 二次确认后显示 24 词恢复短语 + ``` + + 恢复短语能解密账号下全部同步内容——请抄写下来离线妥善保存,不要分享给任何人。它不会保存在其他任何地方。 + +- **恢复**:所有设备都丢失后,在新登录的设备上输入短语重建密钥: + + ```bash + jcode cloud key recover + ``` + +- **换钥**:生成新一代密钥(key_gen+1);已配对的客户端需重新配对才能读取新内容: + + ```bash + jcode cloud rotate-key + ``` + +## 6. 常用命令 + +| 命令 | 说明 | +|------|------| +| `jcode login` | 登录 jcloud(设备码流程)。`--cloud ` 指定云端(默认 `https://cloud.j-code.net`,self-host 必须 https),`--name` 指定设备名 | +| `jcode login --status` | 显示当前登录状态并退出 | +| `jcode logout` | 退出登录:吊销设备令牌并清除本地凭据 | +| `jcode cloud status` | 显示云端地址、设备 id、密钥代数和连通性(心跳探测) | +| `jcode cloud pairings` | 列出待批准的配对请求 | +| `jcode cloud approve ` | 批准配对(为该客户端包裹密钥) | +| `jcode cloud deny ` | 拒绝配对 | +| `jcode cloud key show-phrase` | 显示 CEK 的 24 词恢复短语 | +| `jcode cloud key recover` | 用恢复短语重建 CEK | +| `jcode cloud rotate-key` | 生成新 CEK(key_gen+1);已配对客户端需重新配对 | +| `jcode cloud guide` | 打印本指南的精简版 | + +**E2EE 开关**:端到端加密默认开启。需要临时关闭(明文上行,用于灰度回滚或排查)时,在 `~/.jcode/config.json` 中设置: + +```json +{ "cloud": { "e2ee": false } } +``` + +然后重启 `jcode web` 生效。 + +## 7. 常见问题 + +- **控制台设备列表是空的?** 先在机器上运行 `jcode login` 并完成浏览器确认;控制台须用用户会话登录(服务令牌看不到设备)。 +- **新浏览器里会话全是"未配对"提示?** 这是 E2EE 的正常状态——按第 4 节完成配对即可。 +- **设备显示离线?** 确认机器上 `jcode web` 在运行且能访问云端;`jcode cloud status` 会做心跳探测并显示连通性。 diff --git a/go.mod b/go.mod index 1d3b3c11..8fdc2028 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,8 @@ require ( tinygo.org/x/bluetooth v0.15.0 ) +require github.com/tyler-smith/go-bip39 v1.1.0 + require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect @@ -60,7 +62,7 @@ require ( github.com/dop251/goja v0.0.0-20260701091749-b07b74453ea9 github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 github.com/dustin/go-humanize v1.0.1 // indirect - github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/eino-contrib/jsonschema v1.0.3 github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -104,7 +106,7 @@ require ( github.com/tinygo-org/cbgo v0.0.4 // indirect github.com/tinygo-org/pio v0.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yargevad/filepathx v1.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 01f5fa69..b231364f 100644 --- a/go.sum +++ b/go.sum @@ -287,6 +287,8 @@ github.com/tinygo-org/pio v0.3.0 h1:opEnOtw58KGB4RJD3/n/Rd0/djYGX3DeJiXLI6y/yDI= github.com/tinygo-org/pio v0.3.0/go.mod h1:wf6c6lKZp+pQOzKKcpzchmRuhiMc27ABRuo7KVnaMFU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= @@ -330,11 +332,14 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -342,6 +347,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cloud/auth.go b/internal/cloud/auth.go new file mode 100644 index 00000000..1638bcd7 --- /dev/null +++ b/internal/cloud/auth.go @@ -0,0 +1,75 @@ +// auth.go holds the shared login/logout helpers used by both the CLI +// (`jcode login` / `jcode logout`) and the web API (POST /api/cloud/login, +// /api/cloud/logout) so the two entry points can never drift apart. +package cloud + +import ( + "context" + "encoding/json" + "os" + + "github.com/cnjack/jcode/internal/config" +) + +// Logout signs the device out of jcloud: the device token is revoked remotely +// (best effort — a network failure or a missing server endpoint must not trap +// the local credentials), the credentials file and the in-process CEK cache +// are cleared, and config.cloud is updated (enabled=false, URL and user +// preferences preserved). warnf receives non-fatal warnings (revoke/config +// failures); it may be nil. A missing credentials file is a no-op. +func Logout(ctx context.Context, warnf func(format string, args ...any)) error { + warn := func(format string, args ...any) { + if warnf != nil { + warnf(format, args...) + } + } + creds, err := LoadCredentials() + if err != nil { + return err + } + if creds == nil { + return nil // not logged in + } + + client := NewClient(creds.CloudURL) + if err := client.RevokeDevice(ctx, creds.DeviceToken); err != nil { + warn("failed to revoke device token on %s: %v — clearing local credentials anyway", creds.CloudURL, err) + } + + if err := DeleteCredentials(); err != nil { + return err + } + ResetCEKCache() + + if err := UpdateConfigCloud("", false); err != nil { + warn("failed to update %s: %v", config.ConfigPath(), err) + } + return nil +} + +// UpdateConfigCloud sets config.cloud while preserving the stored url (when +// the url argument is empty, i.e. logout) and the user's auto_connect/e2ee +// preferences. Login/logout must not require a fully configured provider set, +// so a LoadConfig failure falls back to a best-effort raw read of the file +// (unknown fields may be dropped in that case). +func UpdateConfigCloud(url string, enabled bool) error { + cfg, err := config.LoadConfig() + if err != nil { + cfg = &config.Config{} + if data, readErr := os.ReadFile(config.ConfigPath()); readErr == nil { + _ = json.Unmarshal(data, cfg) + } + } + current := cfg.CloudSettings() + if url == "" { + url = current.URL + } + cfg.SetCloud(&config.CloudConfig{ + Enabled: enabled, + URL: url, + AutoConnect: current.AutoConnect, + E2EE: current.E2EE, + SyncDefault: current.SyncDefault, + }) + return config.SaveConfig(cfg) +} diff --git a/internal/cloud/backoff.go b/internal/cloud/backoff.go new file mode 100644 index 00000000..2cd154b4 --- /dev/null +++ b/internal/cloud/backoff.go @@ -0,0 +1,59 @@ +package cloud + +import ( + "context" + "sync" + "time" +) + +// Backoff is a thread-safe exponential backoff with a cap, used by every +// connector retry loop (register, poll, WS reconnect). It is injectable so +// tests can shrink the delays. +type Backoff struct { + Min time.Duration + Max time.Duration + + mu sync.Mutex + cur time.Duration +} + +// NewBackoff returns a Backoff starting at min and capped at max. +func NewBackoff(min, max time.Duration) *Backoff { + return &Backoff{Min: min, Max: max} +} + +// Next returns the current delay and doubles it (capped at Max). +func (b *Backoff) Next() time.Duration { + b.mu.Lock() + defer b.mu.Unlock() + if b.cur <= 0 { + b.cur = b.Min + } else { + b.cur *= 2 + if b.cur > b.Max { + b.cur = b.Max + } + } + return b.cur +} + +// Reset clears the backoff after a success. +func (b *Backoff) Reset() { + b.mu.Lock() + b.cur = 0 + b.mu.Unlock() +} + +// Wait sleeps for the next backoff delay or until ctx is cancelled (in which +// case it returns ctx.Err()). +func (b *Backoff) Wait(ctx context.Context) error { + d := b.Next() + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/cloud/backoff_test.go b/internal/cloud/backoff_test.go new file mode 100644 index 00000000..a09ebb4e --- /dev/null +++ b/internal/cloud/backoff_test.go @@ -0,0 +1,26 @@ +package cloud + +import ( + "testing" + "time" +) + +func TestBackoffProgressionAndCap(t *testing.T) { + b := NewBackoff(1*time.Second, 60*time.Second) + want := []time.Duration{1, 2, 4, 8, 16, 32, 60, 60, 60} + for i, w := range want { + if got := b.Next(); got != w*time.Second { + t.Fatalf("Next()[%d] = %v, want %v", i, got, w*time.Second) + } + } +} + +func TestBackoffReset(t *testing.T) { + b := NewBackoff(1*time.Second, 60*time.Second) + b.Next() + b.Next() + b.Reset() + if got := b.Next(); got != 1*time.Second { + t.Fatalf("Next() after Reset = %v, want 1s", got) + } +} diff --git a/internal/cloud/capabilities.go b/internal/cloud/capabilities.go new file mode 100644 index 00000000..9c460629 --- /dev/null +++ b/internal/cloud/capabilities.go @@ -0,0 +1,182 @@ +// capabilities.go builds the device-capabilities mirror (M12): the compose +// facets the local control plane can offer a remotely-started session — +// projects (from the session index), models (from config + the model +// registry, mirroring GET /api/models), the supported reasoning-effort +// levels, and the available slash commands (from GET /api/slash-commands). +// The connector reports it as the top-level `capabilities` field of +// every sessions upsert; the orchestrator stores it in devices.capabilities. +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "sort" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/model" + "github.com/cnjack/jcode/internal/session" +) + +// DeviceCapabilities is the capabilities payload stored by the orchestrator +// and consumed by the console/mobile compose UI. +type DeviceCapabilities struct { + Projects []CapabilityProject `json:"projects"` + Models []CapabilityModel `json:"models"` + Efforts []string `json:"efforts"` + SlashCommands []CapabilitySlashCommand `json:"slash_commands"` +} + +// CapabilityProject is one known project directory. +type CapabilityProject struct { + Path string `json:"path"` + Name string `json:"name"` +} + +// CapabilityModel is one selectable model of a configured provider. +type CapabilityModel struct { + Provider string `json:"provider"` + ID string `json:"id"` + Label string `json:"label"` +} + +// CapabilitySlashCommand mirrors one item of the web control plane's +// GET /api/slash-commands response (skill- and workflow-provided commands). +type CapabilitySlashCommand struct { + Slash string `json:"slash"` + Description string `json:"description"` + Type string `json:"type"` // "skill" | "flow" +} + +// standardEfforts mirrors the registry's standard effort option set +// (internal/model standardEffortOptions), used when no configured model +// advertises reasoning options. +var standardEfforts = []string{"minimal", "low", "medium", "high"} + +// collectCapabilities builds the capabilities mirror. It is best-effort per +// source: a failure in one facet is logged and that facet reports empty — +// capabilities must never break the session upsert they ride along with. +func (c *Connector) collectCapabilities(ctx context.Context) *DeviceCapabilities { + caps := &DeviceCapabilities{ + Projects: []CapabilityProject{}, + Models: []CapabilityModel{}, + Efforts: []string{}, + SlashCommands: []CapabilitySlashCommand{}, + } + + // Projects: the session index is keyed by project path — the same source + // the sessions upsert uses (there is no separate projects endpoint). + listFn := c.cfg.ListSessionsFn + if listFn == nil { + listFn = session.ListAllSessions + } + if all, err := listFn(); err != nil { + c.logf("capabilities: session index unavailable: %v", err) + } else { + for path := range all { + if path == "" { + continue + } + caps.Projects = append(caps.Projects, CapabilityProject{Path: path, Name: filepath.Base(path)}) + } + sort.Slice(caps.Projects, func(i, j int) bool { return caps.Projects[i].Path < caps.Projects[j].Path }) + } + + modelsFn := c.cfg.ModelCapabilitiesFn + if modelsFn == nil { + modelsFn = collectModelCapabilities + } + models, efforts, err := modelsFn() + if err != nil { + c.logf("capabilities: model list unavailable: %v", err) + } else { + caps.Models = models + caps.Efforts = efforts + } + + // Slash commands: unlike projects/models (config + index), these live in + // the running web server (skill + workflow loaders), so they come from + // the local control plane itself. + slashFn := c.cfg.SlashCommandsFn + if slashFn == nil { + slashFn = func() ([]CapabilitySlashCommand, error) { return c.collectSlashCommands(ctx) } + } + if cmds, err := slashFn(); err != nil { + c.logf("capabilities: slash commands unavailable: %v", err) + } else { + caps.SlashCommands = cmds + } + return caps +} + +// collectSlashCommands fetches GET /api/slash-commands from the local control +// plane. The response is a bare JSON array [{slash, description, type}]. +func (c *Connector) collectSlashCommands(ctx context.Context) ([]CapabilitySlashCommand, error) { + status, body, err := c.local.getJSON(ctx, "/api/slash-commands") + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, errUnexpectedStatus("/api/slash-commands", status, string(body)) + } + var items []CapabilitySlashCommand + if err := json.Unmarshal(body, &items); err != nil { + return nil, fmt.Errorf("/api/slash-commands: invalid response: %w", err) + } + if items == nil { + items = []CapabilitySlashCommand{} + } + return items, nil +} + +// collectModelCapabilities lists the selectable models of the configured +// providers, mirroring the web control plane's GET /api/models: registry +// models of configured providers, filtered by the user's enabled/disabled +// model state. Efforts are the union of the listed models' reasoning-effort +// options (falling back to the standard set when no model advertises any). +func collectModelCapabilities() ([]CapabilityModel, []string, error) { + cfg, err := config.LoadConfig() + if err != nil { + return nil, nil, err + } + registry := model.NewModelRegistryWithConfig(cfg) + modelState, _ := config.LoadModelState() + configured := cfg.GetProviders() + + models := []CapabilityModel{} + efforts := []string{} + effortSeen := map[string]bool{} + for _, rp := range registry.ListProviders() { + if _, ok := configured[rp.ID]; !ok { + continue + } + for _, m := range registry.ListProviderModels(rp.ID, true) { + ref := config.ModelRef{Provider: rp.ID, Model: m.ID} + if !modelState.IsModelEnabled(ref, m.DefaultEnabled) { + continue + } + label := m.Name + if label == "" { + label = m.ID + } + models = append(models, CapabilityModel{Provider: rp.ID, ID: m.ID, Label: label}) + for _, ro := range m.ReasoningOptions { + if ro.Type != "effort" { + continue + } + for _, v := range ro.Values { + if !effortSeen[v] { + effortSeen[v] = true + efforts = append(efforts, v) + } + } + } + } + } + if len(efforts) == 0 { + efforts = standardEfforts + } + return models, efforts, nil +} diff --git a/internal/cloud/client.go b/internal/cloud/client.go new file mode 100644 index 00000000..ebb844c1 --- /dev/null +++ b/internal/cloud/client.go @@ -0,0 +1,355 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// DefaultCloudURL is the public jcloud orchestrator address. +const DefaultCloudURL = "https://cloud.j-code.net" + +// Polling defaults per RFC 8628 when the server omits them. +const ( + defaultPollInterval = 5 * time.Second + defaultPollExpiry = 10 * time.Minute +) + +// maxResponseBytes bounds any single API response body. Poll responses carry +// chat.send commands whose attachments can be 5×2MB decoded (~14MB base64 +// inside the encrypted envelope), so the cap must clear that with headroom. +const maxResponseBytes = 32 << 20 + +// Sentinel errors for terminal device-token polling outcomes. +var ( + // ErrAuthorizationDenied is returned when the user denies the user_code on + // the verification page. + ErrAuthorizationDenied = errors.New("authorization denied by user") + // ErrDeviceCodeExpired is returned when the device_code expires before the + // user authorizes, or when the overall expiry deadline passes while polling. + ErrDeviceCodeExpired = errors.New("device code expired") +) + +// APIError is the orchestrator's error envelope {error:{code,message}}. +type APIError struct { + StatusCode int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + if e.Code != "" { + return fmt.Sprintf("%s: %s (HTTP %d)", e.Code, e.Message, e.StatusCode) + } + return fmt.Sprintf("%s (HTTP %d)", e.Message, e.StatusCode) +} + +// ValidateCloudURL normalizes a --cloud flag value and enforces the scheme +// policy from docs/17 §3.3: https everywhere, except that +// localhost / 127.0.0.1 / [::1] (any port) may use http for development. +// The returned URL has no trailing slash. +func ValidateCloudURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("cloud URL must not be empty") + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid cloud URL %q: %w", raw, err) + } + if u.Host == "" { + return "", fmt.Errorf("invalid cloud URL %q: missing scheme or host (want e.g. %s)", raw, DefaultCloudURL) + } + switch u.Scheme { + case "https": + case "http": + switch u.Hostname() { + case "localhost", "127.0.0.1", "::1": + default: + return "", fmt.Errorf("cloud URL %q: plain http is only allowed for localhost/127.0.0.1/[::1] (use https)", raw) + } + default: + return "", fmt.Errorf("invalid cloud URL %q: unsupported scheme %q (use https)", raw, u.Scheme) + } + return strings.TrimRight(u.String(), "/"), nil +} + +// Client talks to the jcloud orchestrator's device-auth and internal device +// endpoints. +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +// NewClient returns a Client for baseURL (already validated). A nil +// HTTPClient means a default one with a 30s timeout. +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +// DeviceCodeResponse is the answer of POST /auth/device/code. +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` // seconds + Interval int `json:"interval"` // seconds +} + +// DeviceTokenResponse is the success answer of POST /auth/device/token. +type DeviceTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + DeviceID string `json:"device_id"` + // Deduped is true when the orchestrator recognized the machine fingerprint + // and reused the existing devices row (M16) instead of minting a new one. + Deduped bool `json:"deduped"` +} + +// RegisterDeviceRequest is the body of POST /internal/v1/device/register. +type RegisterDeviceRequest struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + JcodeVersion string `json:"jcode_version"` + PubKey string `json:"pubkey"` // X25519 public key, base64 + // Platform is how this jcode instance was launched ("desktop" | "cli"); + // see detectPlatform in connector.go. + Platform string `json:"platform,omitempty"` + // E2EE reports the connector's ACTUAL encryption state (M13): true only + // when the CEK cipher is active and cloud.e2ee did not disable it. The + // orchestrator gates plaintext downlink on it (docs/17 §6.7). + E2EE bool `json:"e2ee,omitempty"` + // Fingerprint is the sha256 of the machine fingerprint (M16): it backfills + // rows minted without one so a later login dedups onto this device. + Fingerprint string `json:"fingerprint,omitempty"` +} + +// post issues one JSON POST request and decodes the response envelope. token, +// when non-empty, is sent as a Bearer credential. out may be nil for responses +// whose body is irrelevant. Non-2xx responses become *APIError. +// +// E2E (M5): payload sealing happens above this transport layer — callers +// (Connector.sealUplink/openDownlink) pass already-sealed envelope JSON. +func (c *Client) post(ctx context.Context, path, token string, body, out any) error { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, reader) + if err != nil { + return fmt.Errorf("build request POST %s: %w", path, err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("POST %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("POST %s: read response: %w", path, err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + apiErr := &APIError{StatusCode: resp.StatusCode, Message: http.StatusText(resp.StatusCode)} + var envelope struct { + Error *APIError `json:"error"` + } + if json.Unmarshal(data, &envelope) == nil && envelope.Error != nil { + apiErr = envelope.Error + apiErr.StatusCode = resp.StatusCode + } else if trimmed := strings.TrimSpace(string(data)); trimmed != "" { + apiErr.Message = trimmed + } + return apiErr + } + + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("POST %s: decode response: %w", path, err) + } + } + return nil +} + +// get issues one GET request and returns the HTTP status code, decoding a +// 2xx JSON body into out (out may be nil). Non-2xx responses (except 204, +// which is returned as-is with no error) become *APIError. +func (c *Client) get(ctx context.Context, path, token string, out any) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return 0, fmt.Errorf("build request GET %s: %w", path, err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return 0, fmt.Errorf("GET %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNoContent { + return resp.StatusCode, nil + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return resp.StatusCode, fmt.Errorf("GET %s: read response: %w", path, err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + apiErr := &APIError{StatusCode: resp.StatusCode, Message: http.StatusText(resp.StatusCode)} + var envelope struct { + Error *APIError `json:"error"` + } + if json.Unmarshal(data, &envelope) == nil && envelope.Error != nil { + apiErr = envelope.Error + apiErr.StatusCode = resp.StatusCode + } else if trimmed := strings.TrimSpace(string(data)); trimmed != "" { + apiErr.Message = trimmed + } + return resp.StatusCode, apiErr + } + + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return resp.StatusCode, fmt.Errorf("GET %s: decode response: %w", path, err) + } + } + return resp.StatusCode, nil +} + +// RequestDeviceCode starts the device authorization flow (RFC 8628 §3.1): +// POST {url}/auth/device/code. +func (c *Client) RequestDeviceCode(ctx context.Context, clientName string) (*DeviceCodeResponse, error) { + var out DeviceCodeResponse + if err := c.post(ctx, "/auth/device/code", "", map[string]string{"client_name": clientName}, &out); err != nil { + return nil, err + } + if out.DeviceCode == "" || out.UserCode == "" || out.VerificationURI == "" { + return nil, fmt.Errorf("incomplete device code response from %s", c.BaseURL) + } + return &out, nil +} + +// PollDeviceToken makes a single token poll attempt: POST /auth/device/token. +// fingerprint is the sha256 machine-fingerprint hash (M16; "" for none) — the +// orchestrator uses it to dedup a re-login onto the existing devices row. +// Pending authorization surfaces as an *APIError with Code +// "authorization_pending" (or "slow_down"); terminal failures use +// "access_denied" / "expired_token". +func (c *Client) PollDeviceToken(ctx context.Context, deviceCode, fingerprint string) (*DeviceTokenResponse, error) { + var out DeviceTokenResponse + body := map[string]string{"device_code": deviceCode} + if fingerprint != "" { + body["fingerprint"] = fingerprint + } + if err := c.post(ctx, "/auth/device/token", "", body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// PollForToken polls POST /auth/device/token every interval until the user +// authorizes, the deadline (now+expiresIn) passes, or ctx is cancelled. Zero +// interval/expiresIn fall back to RFC 8628 defaults. fingerprint rides every +// poll (M16, see PollDeviceToken). +func (c *Client) PollForToken(ctx context.Context, deviceCode, fingerprint string, interval, expiresIn time.Duration) (*DeviceTokenResponse, error) { + if interval <= 0 { + interval = defaultPollInterval + } + if expiresIn <= 0 { + expiresIn = defaultPollExpiry + } + deadline := time.Now().Add(expiresIn) + + for { + tok, err := c.PollDeviceToken(ctx, deviceCode, fingerprint) + if err == nil { + return tok, nil + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + return nil, err + } + switch apiErr.Code { + case "authorization_pending": + // Keep polling at the current interval. + case "slow_down": + interval += 5 * time.Second + case "access_denied": + return nil, ErrAuthorizationDenied + case "expired_token": + return nil, ErrDeviceCodeExpired + default: + return nil, err + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, ErrDeviceCodeExpired + } + wait := interval + if wait > remaining { + wait = remaining + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + if time.Now().After(deadline) { + return nil, ErrDeviceCodeExpired + } + } +} + +// RegisterDevice registers (or refreshes) this device with the orchestrator: +// POST /internal/v1/device/register (Bearer device token). +func (c *Client) RegisterDevice(ctx context.Context, token string, req RegisterDeviceRequest) error { + return c.post(ctx, "/internal/v1/device/register", token, req, nil) +} + +// RevokeDevice asks the orchestrator to revoke this device's token: +// POST /internal/v1/device/revoke. A 404 (endpoint not deployed yet on the +// server side) is treated as success — local cleanup proceeds regardless. +func (c *Client) RevokeDevice(ctx context.Context, token string) error { + err := c.post(ctx, "/internal/v1/device/revoke", token, map[string]string{}, nil) + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return nil + } + return err +} diff --git a/internal/cloud/client_test.go b/internal/cloud/client_test.go new file mode 100644 index 00000000..a528343c --- /dev/null +++ b/internal/cloud/client_test.go @@ -0,0 +1,321 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestValidateCloudURL(t *testing.T) { + tests := []struct { + name string + raw string + want string // normalized URL; empty means expect an error + wantErr bool + }{ + {name: "https default", raw: "https://cloud.j-code.net", want: "https://cloud.j-code.net"}, + {name: "https trailing slash trimmed", raw: "https://cloud.j-code.net/", want: "https://cloud.j-code.net"}, + {name: "https self-host with port", raw: "https://cloud.example.com:8443", want: "https://cloud.example.com:8443"}, + {name: "http remote rejected", raw: "http://cloud.example.com", wantErr: true}, + {name: "http localhost", raw: "http://localhost", want: "http://localhost"}, + {name: "http localhost with port", raw: "http://localhost:8080", want: "http://localhost:8080"}, + {name: "http 127.0.0.1 with port", raw: "http://127.0.0.1:3000", want: "http://127.0.0.1:3000"}, + {name: "http ipv6 loopback with port", raw: "http://[::1]:9000", want: "http://[::1]:9000"}, + {name: "http other loopback rejected", raw: "http://127.0.0.2:3000", wantErr: true}, + {name: "unsupported scheme", raw: "ftp://cloud.example.com", wantErr: true}, + {name: "missing scheme", raw: "cloud.example.com", wantErr: true}, + {name: "empty", raw: "", wantErr: true}, + {name: "whitespace trimmed", raw: " https://cloud.j-code.net ", want: "https://cloud.j-code.net"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateCloudURL(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("ValidateCloudURL(%q) = %q, want error", tt.raw, got) + } + return + } + if err != nil { + t.Fatalf("ValidateCloudURL(%q) error = %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("ValidateCloudURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +// deviceAuthServer builds an httptest server speaking the device-auth +// contract. tokenHandler decides what POST /auth/device/token returns. +func deviceAuthServer(t *testing.T, tokenHandler http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/device/code", func(w http.ResponseWriter, r *http.Request) { + var body struct { + ClientName string `json:"client_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode /auth/device/code body: %v", err) + } + if body.ClientName == "" { + t.Errorf("client_name is empty") + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "device_code": "dev-code-123", + "user_code": "ABCD-EFGH", + "verification_uri": "https://cloud.example.com/auth/device", + "expires_in": 600, + "interval": 1, + }) + }) + mux.Handle("POST /auth/device/token", tokenHandler) + return httptest.NewServer(mux) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Errorf("encode response: %v", err) + } +} + +// writeError emits the orchestrator's {error:{code,message}} envelope with a +// 400 status, as the device-token endpoint does for pending/denied/expired. +func writeError(t *testing.T, w http.ResponseWriter, code, message string) { + t.Helper() + writeJSON(t, w, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": code, "message": message}}) +} + +func TestRequestDeviceCode(t *testing.T) { + srv := deviceAuthServer(t, http.NotFoundHandler().ServeHTTP) + defer srv.Close() + + dc, err := NewClient(srv.URL).RequestDeviceCode(context.Background(), "jcode CLI test") + if err != nil { + t.Fatalf("RequestDeviceCode() error = %v", err) + } + if dc.DeviceCode != "dev-code-123" || dc.UserCode != "ABCD-EFGH" { + t.Fatalf("RequestDeviceCode() = %+v", dc) + } + if dc.ExpiresIn != 600 || dc.Interval != 1 { + t.Fatalf("RequestDeviceCode() expiry/interval = %d/%d, want 600/1", dc.ExpiresIn, dc.Interval) + } +} + +func TestPollForTokenPendingThenSuccess(t *testing.T) { + var calls int32 + srv := deviceAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + var body struct { + DeviceCode string `json:"device_code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode token body: %v", err) + } + if body.DeviceCode != "dev-code-123" { + t.Errorf("device_code = %q, want dev-code-123", body.DeviceCode) + } + if n < 3 { + writeError(t, w, "authorization_pending", "user has not authorized yet") + return + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "access_token": "dev-token-abc", + "token_type": "device", + "device_id": "device-42", + }) + }) + defer srv.Close() + + tok, err := NewClient(srv.URL).PollForToken(context.Background(), "dev-code-123", "", time.Millisecond, 10*time.Second) + if err != nil { + t.Fatalf("PollForToken() error = %v", err) + } + if tok.AccessToken != "dev-token-abc" || tok.DeviceID != "device-42" { + t.Fatalf("PollForToken() = %+v", tok) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("token endpoint called %d times, want 3", got) + } +} + +func TestPollForTokenDenied(t *testing.T) { + srv := deviceAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + writeError(t, w, "access_denied", "user denied the request") + }) + defer srv.Close() + + _, err := NewClient(srv.URL).PollForToken(context.Background(), "dev-code-123", "", time.Millisecond, 10*time.Second) + if !errors.Is(err, ErrAuthorizationDenied) { + t.Fatalf("PollForToken() error = %v, want ErrAuthorizationDenied", err) + } +} + +func TestPollForTokenExpired(t *testing.T) { + srv := deviceAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + writeError(t, w, "expired_token", "device code expired") + }) + defer srv.Close() + + _, err := NewClient(srv.URL).PollForToken(context.Background(), "dev-code-123", "", time.Millisecond, 10*time.Second) + if !errors.Is(err, ErrDeviceCodeExpired) { + t.Fatalf("PollForToken() error = %v, want ErrDeviceCodeExpired", err) + } +} + +func TestPollForTokenOverallDeadline(t *testing.T) { + srv := deviceAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + writeError(t, w, "authorization_pending", "still waiting") + }) + defer srv.Close() + + start := time.Now() + _, err := NewClient(srv.URL).PollForToken(context.Background(), "dev-code-123", "", 5*time.Millisecond, 20*time.Millisecond) + if !errors.Is(err, ErrDeviceCodeExpired) { + t.Fatalf("PollForToken() error = %v, want ErrDeviceCodeExpired", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("PollForToken() took %v, want bounded by expires_in", elapsed) + } +} + +func TestPollForTokenContextCancel(t *testing.T) { + srv := deviceAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + writeError(t, w, "authorization_pending", "still waiting") + }) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := NewClient(srv.URL).PollForToken(ctx, "dev-code-123", "", time.Second, time.Minute) + if !errors.Is(err, context.Canceled) { + t.Fatalf("PollForToken() error = %v, want context.Canceled", err) + } +} + +func TestRegisterDevice(t *testing.T) { + var gotAuth, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v1/device/register" || r.Method != http.MethodPost { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + var req RegisterDeviceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode register body: %v", err) + } + data, _ := json.Marshal(req) + gotBody = string(data) + writeJSON(t, w, http.StatusOK, map[string]any{"server_time": "2026-07-20T00:00:00Z"}) + })) + defer srv.Close() + + err := NewClient(srv.URL).RegisterDevice(context.Background(), "dev-token-abc", RegisterDeviceRequest{ + Name: "jack-macbook", + Hostname: "jack-macbook.local", + JcodeVersion: "1.2.3", + PubKey: "cHVia2V5", + }) + if err != nil { + t.Fatalf("RegisterDevice() error = %v", err) + } + if gotAuth != "Bearer dev-token-abc" { + t.Fatalf("Authorization header = %q, want Bearer token", gotAuth) + } + for _, field := range []string{`"name":"jack-macbook"`, `"hostname":"jack-macbook.local"`, `"jcode_version":"1.2.3"`, `"pubkey":"cHVia2V5"`} { + if !strings.Contains(gotBody, field) { + t.Fatalf("register body %s missing %s", gotBody, field) + } + } +} + +func TestRevokeDeviceIgnores404(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + if err := NewClient(srv.URL).RevokeDevice(context.Background(), "tok"); err != nil { + t.Fatalf("RevokeDevice() on 404 error = %v, want nil", err) + } +} + +func TestErrorEnvelopeParsing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeError(t, w, "some_code", "something went wrong") + })) + defer srv.Close() + + err := NewClient(srv.URL).RegisterDevice(context.Background(), "tok", RegisterDeviceRequest{}) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("RegisterDevice() error = %v, want *APIError", err) + } + if apiErr.Code != "some_code" || apiErr.Message != "something went wrong" || apiErr.StatusCode != http.StatusBadRequest { + t.Fatalf("APIError = %+v", apiErr) + } +} + +// TestPollForTokenSendsFingerprint covers the M16 contract: the sha256 +// fingerprint hash rides every token poll, and a deduped=true response +// decodes. +func TestPollForTokenSendsFingerprint(t *testing.T) { + const fp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + srv := deviceAuthServer(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + DeviceCode string `json:"device_code"` + Fingerprint string `json:"fingerprint"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode token body: %v", err) + } + if body.Fingerprint != fp { + t.Errorf("fingerprint = %q, want %q", body.Fingerprint, fp) + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "access_token": "dev-token-abc", + "token_type": "device", + "device_id": "device-42", + "deduped": true, + }) + }) + defer srv.Close() + + tok, err := NewClient(srv.URL).PollForToken(context.Background(), "dev-code-123", fp, time.Millisecond, 10*time.Second) + if err != nil { + t.Fatalf("PollForToken() error = %v", err) + } + if !tok.Deduped { + t.Fatalf("PollForToken() Deduped = false, want true (%+v)", tok) + } +} + +// TestRegisterDeviceSendsFingerprint: the register body carries the hash for +// the server-side backfill (M16). +func TestRegisterDeviceSendsFingerprint(t *testing.T) { + const fp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + gotBody = string(data) + writeJSON(t, w, http.StatusOK, map[string]any{"server_time": "2026-07-20T00:00:00Z"}) + })) + defer srv.Close() + + err := NewClient(srv.URL).RegisterDevice(context.Background(), "tok", RegisterDeviceRequest{ + Name: "x", PubKey: "cHVia2V5", Fingerprint: fp, + }) + if err != nil { + t.Fatalf("RegisterDevice() error = %v", err) + } + if !strings.Contains(gotBody, `"fingerprint":"`+fp+`"`) { + t.Fatalf("register body %s missing fingerprint", gotBody) + } +} diff --git a/internal/cloud/compose_test.go b/internal/cloud/compose_test.go new file mode 100644 index 00000000..f53bee28 --- /dev/null +++ b/internal/cloud/compose_test.go @@ -0,0 +1,711 @@ +// compose_test.go covers the M12 compose facets: attachment sanitize/limits/ +// landing, the ordered five-facet dispatch against a mock local control plane, +// and the capabilities mirror collection/reporting. +package cloud + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +// --- attachment unit tests --- + +func TestSanitizeInboxName(t *testing.T) { + cases := map[string]string{ + "report.pdf": "report.pdf", + "../etc/passwd": "passwd", + "..\\..\\win.ini": "win.ini", + "a/b/c.txt": "c.txt", + ".env": "env", + "..": "attachment", + ".": "attachment", + "": "attachment", + " ": "attachment", + "a\x00b\x1fc.pdf": "abc.pdf", + " spaced name.txt ": "spaced name.txt", + ".../.../...hidden.md": "hidden.md", + "中文 附件.pdf": "中文 附件.pdf", + } + for in, want := range cases { + if got := sanitizeInboxName(in); got != want { + t.Errorf("sanitizeInboxName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestDecodeAttachmentsLimits(t *testing.T) { + b64 := base64.StdEncoding.EncodeToString([]byte("hi")) + + // Count limit: 6 files breach the 5-file cap. + tooMany := make([]chatAttachment, maxAttachmentCount+1) + for i := range tooMany { + tooMany[i] = chatAttachment{Name: fmt.Sprintf("f%d.txt", i), DataB64: b64} + } + if _, err := decodeAttachments(tooMany); err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("6 attachments: err = %v, want count-limit error", err) + } + + // Size limit: 2MB+1 decoded bytes breach the per-file cap. + big := make([]byte, maxAttachmentBytes+1) + _, err := decodeAttachments([]chatAttachment{{Name: "big.bin", DataB64: base64.StdEncoding.EncodeToString(big)}}) + if err == nil || !strings.Contains(err.Error(), "2MB") { + t.Fatalf("oversize attachment: err = %v, want size-limit error", err) + } + + // Invalid base64 is rejected. + if _, err := decodeAttachments([]chatAttachment{{Name: "x", DataB64: "!!!not-b64!!!"}}); err == nil { + t.Fatal("invalid base64: want error") + } + + // Exactly at the limits passes: 5 files, one exactly 2MB. + ok := []chatAttachment{ + {Name: "a", DataB64: base64.StdEncoding.EncodeToString(make([]byte, maxAttachmentBytes))}, + {Name: "b", DataB64: b64}, {Name: "c", DataB64: b64}, {Name: "d", DataB64: b64}, {Name: "e", DataB64: b64}, + } + decoded, err := decodeAttachments(ok) + if err != nil { + t.Fatalf("at-limit attachments: %v", err) + } + if len(decoded) != 5 || len(decoded[0]) != maxAttachmentBytes { + t.Fatalf("decoded = %d files (%d bytes), want 5 (%d)", len(decoded), len(decoded[0]), maxAttachmentBytes) + } +} + +func TestWriteInboxAttachments(t *testing.T) { + root := t.TempDir() + atts := []chatAttachment{ + {Name: "../../evil.txt"}, + {Name: "report.pdf"}, + {Name: "report.pdf"}, // collision → numeric suffix + } + decoded := [][]byte{[]byte("one"), []byte("two"), []byte("three")} + + refs, err := writeInboxAttachments(root, "sess-1", atts, decoded) + if err != nil { + t.Fatalf("writeInboxAttachments: %v", err) + } + if len(refs) != 3 { + t.Fatalf("refs = %v, want 3", refs) + } + + // Traversal is flattened into the session dir; collision got "-2". + wantNames := []string{"evil.txt", "report.pdf", "report-2.pdf"} + for i, r := range refs { + if r.Name != wantNames[i] { + t.Errorf("refs[%d].Name = %q, want %q", i, r.Name, wantNames[i]) + } + wantPath := filepath.Join(root, "sess-1", wantNames[i]) + if r.Path != wantPath { + t.Errorf("refs[%d].Path = %q, want %q", i, r.Path, wantPath) + } + data, err := os.ReadFile(r.Path) + if err != nil || string(data) != string(decoded[i]) { + t.Errorf("refs[%d] content = %q, %v", i, data, err) + } + fi, err := os.Stat(r.Path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != inboxFileMode { + t.Errorf("file perm = %o, want %o", perm, inboxFileMode) + } + } + di, err := os.Stat(filepath.Join(root, "sess-1")) + if err != nil { + t.Fatal(err) + } + if perm := di.Mode().Perm(); perm != inboxDirMode { + t.Errorf("dir perm = %o, want %o", perm, inboxDirMode) + } + + // The session id crosses the trust boundary too: sanitize it. + if _, err := writeInboxAttachments(root, "../escape", atts[:1], decoded[:1]); err != nil { + t.Fatalf("hostile session id: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "escape", "evil.txt")); err != nil { + t.Errorf("hostile session id did not land inside the inbox root: %v", err) + } +} + +// --- compose dispatch tests --- + +// fakeComposeLocal is a mock local control plane recording every call (in +// order) for the compose endpoints. +type fakeComposeLocal struct { + mu sync.Mutex + calls []string + bodies map[string][]map[string]any + failPaths map[string]int // path → status to answer with + + sessionID string + healthProvider string + healthModel string +} + +func newFakeComposeLocal(t *testing.T) (*fakeComposeLocal, *httptest.Server) { + t.Helper() + f := &fakeComposeLocal{ + bodies: map[string][]map[string]any{}, + failPaths: map[string]int{}, + sessionID: "sess-compose-1", + healthProvider: "prov-cur", + healthModel: "mod-cur", + } + record := func(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.calls = append(f.calls, path) + f.bodies[path] = append(f.bodies[path], body) + fail := f.failPaths[path] + f.mu.Unlock() + if fail != 0 { + http.Error(w, "boom", fail) + return + } + switch path { + case "/api/sessions": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "session_id": f.sessionID}) + case "/api/chat": + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "processing", "session_id": f.sessionID}) + default: + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + } + } + } + mux := http.NewServeMux() + for _, p := range []string{"/api/sessions", "/api/chat", "/api/model", "/api/mode", "/api/model-state/effort", "/api/goal"} { + mux.HandleFunc("POST "+p, record(p)) + } + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.calls = append(f.calls, "GET /api/health") + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]string{"provider": f.healthProvider, "model": f.healthModel}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return f, srv +} + +func (f *fakeComposeLocal) snapshot() ([]string, map[string][]map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + calls := append([]string(nil), f.calls...) + bodies := map[string][]map[string]any{} + for k, v := range f.bodies { + bodies[k] = append([]map[string]any(nil), v...) + } + return calls, bodies +} + +func TestChatSendComposeOrder(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + conn.cfg.InboxDir = t.TempDir() + + cmd := DeviceCommand{ + ID: "cmd-compose", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "text": "请阅读附件并总结", + "channel": "console", + "mode": "plan", + "project_path": "/tmp/proj-a", + "model": map[string]string{"provider": "anthropic", "id": "claude-x"}, + "effort": "high", + "goal": "交付 M12", + "attachments": []map[string]string{ + {"name": "spec.pdf", "mime": "application/pdf", "data_b64": base64.StdEncoding.EncodeToString([]byte("pdf-bytes"))}, + }, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + res, _ := result.(map[string]string) + if res["session_id"] != local.sessionID { + t.Fatalf("ack session_id = %v, want %q", result, local.sessionID) + } + + calls, bodies := local.snapshot() + wantOrder := []string{"/api/sessions", "/api/model", "/api/model-state/effort", "/api/mode", "/api/goal", "/api/chat"} + if strings.Join(calls, ",") != strings.Join(wantOrder, ",") { + t.Fatalf("call order = %v, want %v", calls, wantOrder) + } + + // Session create carries the project path. + if bodies["/api/sessions"][0]["pwd"] != "/tmp/proj-a" { + t.Errorf("sessions body = %v, want pwd /tmp/proj-a", bodies["/api/sessions"][0]) + } + // Model + effort (effort keyed by the command's model). + if bodies["/api/model"][0]["provider"] != "anthropic" || bodies["/api/model"][0]["model"] != "claude-x" { + t.Errorf("model body = %v", bodies["/api/model"][0]) + } + eff := bodies["/api/model-state/effort"][0] + if eff["provider"] != "anthropic" || eff["model"] != "claude-x" || eff["effort"] != "high" { + t.Errorf("effort body = %v", eff) + } + // Goal with start=false (the chat message kicks the run off). + goal := bodies["/api/goal"][0] + if goal["objective"] != "交付 M12" || goal["start"] != false { + t.Errorf("goal body = %v, want objective + start=false", goal) + } + // Chat: session id, channel passthrough, attachment reference appended. + chat := bodies["/api/chat"][0] + if chat["session_id"] != local.sessionID || chat["source"] != "console" { + t.Errorf("chat body = %v", chat) + } + msg, _ := chat["message"].(string) + wantRef := "[附件] spec.pdf → " + filepath.Join(conn.cfg.InboxDir, local.sessionID, "spec.pdf") + if !strings.HasPrefix(msg, "请阅读附件并总结") || !strings.Contains(msg, wantRef) { + t.Errorf("message = %q, want text + %q", msg, wantRef) + } + + // The attachment landed on disk with the right content and permissions. + data, err := os.ReadFile(filepath.Join(conn.cfg.InboxDir, local.sessionID, "spec.pdf")) + if err != nil || string(data) != "pdf-bytes" { + t.Errorf("inbox file = %q, %v", data, err) + } +} + +func TestChatSendComposeExistingSessionAndEffortFromHealth(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ + ID: "cmd-effort", + Kind: "chat.send", + SessionID: "sess-77", + Payload: mustPayload(t, map[string]any{ + "text": "think harder", + "effort": "low", + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + + calls, bodies := local.snapshot() + wantOrder := []string{"/api/sessions", "GET /api/health", "/api/model-state/effort", "/api/chat"} + if strings.Join(calls, ",") != strings.Join(wantOrder, ",") { + t.Fatalf("call order = %v, want %v", calls, wantOrder) + } + // Focus passes the existing session id through. + if bodies["/api/sessions"][0]["session_id"] != "sess-77" { + t.Errorf("sessions body = %v, want session_id sess-77", bodies["/api/sessions"][0]) + } + // Effort without an explicit model resolves the current one via /api/health. + eff := bodies["/api/model-state/effort"][0] + if eff["provider"] != "prov-cur" || eff["model"] != "mod-cur" || eff["effort"] != "low" { + t.Errorf("effort body = %v, want current model from /api/health", eff) + } +} + +func TestChatSendComposeAttachmentsOnly(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + conn.cfg.InboxDir = t.TempDir() + + cmd := DeviceCommand{ + ID: "cmd-atts-only", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "attachments": []map[string]string{ + {"name": "notes.txt", "data_b64": base64.StdEncoding.EncodeToString([]byte("n"))}, + }, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v (attachments-only must be a valid message)", status, result) + } + _, bodies := local.snapshot() + msg, _ := bodies["/api/chat"][0]["message"].(string) + if !strings.Contains(msg, "[附件] notes.txt → ") { + t.Errorf("message = %q, want the reference list as the text", msg) + } +} + +func TestChatSendAttachmentLimitBreachHasNoSideEffects(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + conn.cfg.InboxDir = filepath.Join(t.TempDir(), "inbox") + + atts := make([]map[string]string, maxAttachmentCount+1) + for i := range atts { + atts[i] = map[string]string{"name": fmt.Sprintf("f%d", i), "data_b64": "aGk="} + } + cmd := DeviceCommand{ + ID: "cmd-too-many", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "text": "hi", "goal": "g", "attachments": atts, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "error" { + t.Fatalf("status = %q, want error", status) + } + if !strings.Contains(fmt.Sprint(result), "limit") { + t.Fatalf("result = %v, want limit error", result) + } + calls, _ := local.snapshot() + if len(calls) != 0 { + t.Fatalf("local calls = %v, want none (no side effects on limit breach)", calls) + } + if _, err := os.Stat(conn.cfg.InboxDir); !os.IsNotExist(err) { + t.Fatalf("inbox dir exists after rejected command") + } +} + +func TestChatSendComposeFacetErrorNamesField(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + local.failPaths["/api/model"] = http.StatusInternalServerError + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ + ID: "cmd-bad-model", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "text": "hi", "model": map[string]string{"provider": "nope", "id": "nope"}, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "error" { + t.Fatalf("status = %q, want error", status) + } + if !strings.Contains(fmt.Sprint(result), "model:") { + t.Fatalf("result = %v, want the failing facet named", result) + } + // The goal/chat steps must not run after a model failure. + calls, _ := local.snapshot() + for _, c := range calls { + if c == "/api/goal" || c == "/api/chat" { + t.Fatalf("calls = %v, want pipeline aborted after the model failure", calls) + } + } +} + +// --- capabilities tests --- + +func TestSyncSessionsReportsCapabilities(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "/proj-b": {{UUID: "s2", Status: "idle"}}, + "/proj-a": {{UUID: "s1", Status: "idle"}}, + }, nil + } + conn.cfg.ModelCapabilitiesFn = func() ([]CapabilityModel, []string, error) { + return []CapabilityModel{{Provider: "anthropic", ID: "claude-x", Label: "Claude X"}}, []string{"low", "high"}, nil + } + + if err := conn.syncSessions(context.Background()); err != nil { + t.Fatalf("syncSessions: %v", err) + } + + cloud.mu.Lock() + caps := cloud.capsReqs + cloud.mu.Unlock() + if len(caps) != 1 || len(caps[0]) == 0 { + t.Fatalf("capabilities payloads = %v, want one non-empty", caps) + } + var got DeviceCapabilities + if err := json.Unmarshal(caps[0], &got); err != nil { + t.Fatalf("capabilities JSON: %v", err) + } + // Projects come from the session index, sorted by path. + if len(got.Projects) != 2 || got.Projects[0].Path != "/proj-a" || got.Projects[1].Path != "/proj-b" { + t.Fatalf("projects = %+v, want /proj-a,/proj-b", got.Projects) + } + if got.Projects[0].Name != "proj-a" { + t.Errorf("project name = %q, want proj-a (basename)", got.Projects[0].Name) + } + if len(got.Models) != 1 || got.Models[0].ID != "claude-x" || got.Models[0].Label != "Claude X" { + t.Errorf("models = %+v", got.Models) + } + if strings.Join(got.Efforts, ",") != "low,high" { + t.Errorf("efforts = %v", got.Efforts) + } +} + +func TestCollectModelCapabilities(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfgJSON := `{ + "providers": { + "my-gw": { + "api_key": "k", + "base_url": "http://gw", + "custom_models": [ + {"id": "m-plain", "name": "Plain", "tool_call": true}, + {"id": "m-think", "name": "Thinker", "tool_call": true, "reasoning": true, "effort_tiers": ["low", "max"]} + ] + } + } + }` + if err := os.MkdirAll(filepath.Join(home, ".jcode"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".jcode", "config.json"), []byte(cfgJSON), 0o600); err != nil { + t.Fatal(err) + } + + models, efforts, err := collectModelCapabilities() + if err != nil { + t.Fatalf("collectModelCapabilities: %v", err) + } + if len(models) != 2 { + t.Fatalf("models = %+v, want the two custom models", models) + } + byID := map[string]CapabilityModel{} + for _, m := range models { + if m.Provider != "my-gw" { + t.Errorf("model provider = %q, want my-gw", m.Provider) + } + byID[m.ID] = m + } + if byID["m-think"].Label != "Thinker" { + t.Errorf("m-think label = %q, want Thinker", byID["m-think"].Label) + } + // Efforts are the union of the models' reasoning options; only m-think + // advertises any. + if strings.Join(efforts, ",") != "low,max" { + t.Errorf("efforts = %v, want [low max]", efforts) + } +} + +func TestCollectModelCapabilitiesFallbackEfforts(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfgJSON := `{ + "providers": { + "my-gw": { + "api_key": "k", + "custom_models": [{"id": "m-plain", "tool_call": true}] + } + } + }` + if err := os.MkdirAll(filepath.Join(home, ".jcode"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".jcode", "config.json"), []byte(cfgJSON), 0o600); err != nil { + t.Fatal(err) + } + + _, efforts, err := collectModelCapabilities() + if err != nil { + t.Fatalf("collectModelCapabilities: %v", err) + } + if strings.Join(efforts, ",") != "minimal,low,medium,high" { + t.Errorf("efforts = %v, want the standard fallback set", efforts) + } +} + +// --- M14: slash_commands capability, goal_armed, images passthrough --- + +func TestSyncSessionsReportsSlashCommands(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{"/proj": {{UUID: "s1", Status: "idle"}}}, nil + } + conn.cfg.ModelCapabilitiesFn = func() ([]CapabilityModel, []string, error) { + return nil, nil, fmt.Errorf("models down") // best-effort: must not break the upsert + } + conn.cfg.SlashCommandsFn = func() ([]CapabilitySlashCommand, error) { + return []CapabilitySlashCommand{ + {Slash: "/review", Description: "评审改动", Type: "skill"}, + {Slash: "/deploy", Description: "部署", Type: "flow"}, + }, nil + } + + if err := conn.syncSessions(context.Background()); err != nil { + t.Fatalf("syncSessions: %v", err) + } + + cloud.mu.Lock() + caps := cloud.capsReqs + cloud.mu.Unlock() + if len(caps) != 1 { + t.Fatalf("capabilities payloads = %d, want 1", len(caps)) + } + var got DeviceCapabilities + if err := json.Unmarshal(caps[0], &got); err != nil { + t.Fatalf("capabilities JSON: %v", err) + } + if len(got.SlashCommands) != 2 { + t.Fatalf("slash_commands = %+v, want 2 entries", got.SlashCommands) + } + if got.SlashCommands[0].Slash != "/review" || got.SlashCommands[0].Type != "skill" || + got.SlashCommands[1].Slash != "/deploy" || got.SlashCommands[1].Type != "flow" { + t.Errorf("slash_commands = %+v", got.SlashCommands) + } + // The failed model facet reported empty but did not fail the sync. + if len(got.Models) != 0 || len(got.Efforts) != 0 { + t.Errorf("models/efforts = %+v/%v, want empty on facet failure", got.Models, got.Efforts) + } +} + +// TestCollectSlashCommandsDefault exercises the default facet path against a +// fake local control plane: GET /api/slash-commands answers a bare JSON +// array; an unreachable server degrades to an empty list (never an error). +func TestCollectSlashCommandsDefault(t *testing.T) { + localSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/slash-commands" || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"slash": "/commit", "description": "提交", "type": "skill"}, + }) + })) + t.Cleanup(localSrv.Close) + + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + cmds, err := conn.collectSlashCommands(context.Background()) + if err != nil { + t.Fatalf("collectSlashCommands: %v", err) + } + if len(cmds) != 1 || cmds[0].Slash != "/commit" || cmds[0].Type != "skill" { + t.Fatalf("slash commands = %+v", cmds) + } + + // Unreachable control plane → error (the caller logs and reports empty). + connDown := newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1") + if _, err := connDown.collectSlashCommands(context.Background()); err == nil { + t.Fatal("unreachable control plane: want error") + } +} + +func TestChatSendGoalArmed(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ + ID: "cmd-goal-armed", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "text": "交付 M14", + "goal_armed": true, + // Compose facets are ignored when goal_armed is set. + "mode": "plan", + "goal": "ignored", + "images": []map[string]string{{"data": "aGk=", "media_type": "image/png"}}, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + + calls, bodies := local.snapshot() + if strings.Join(calls, ",") != "/api/goal" { + t.Fatalf("local calls = %v, want only /api/goal (no /api/chat, no compose steps)", calls) + } + goal := bodies["/api/goal"][0] + if goal["objective"] != "交付 M14" || goal["start"] != true { + t.Errorf("goal body = %v, want {objective, start:true}", goal) + } + + // goal_armed with an empty objective is rejected without side effects. + local2, localSrv2 := newFakeComposeLocal(t) + conn2 := newTestConnector(t, "http://127.0.0.1:1", localSrv2.URL) + empty := DeviceCommand{ + ID: "cmd-goal-empty", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{"text": " ", "goal_armed": true}), + } + if status, result := conn2.executeCommand(context.Background(), empty); status != "error" { + t.Fatalf("empty objective: status = %q, result = %v; want error", status, result) + } + if calls, _ := local2.snapshot(); len(calls) != 0 { + t.Fatalf("empty objective: local calls = %v, want none", calls) + } +} + +func TestChatSendImagesForwarded(t *testing.T) { + // Legacy path: images (incl. the optional name) reach /api/chat as-is. + local, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ + ID: "cmd-imgs", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "text": "看图", + "images": []map[string]string{ + {"data": "aGk=", "media_type": "image/png", "name": "shot.png"}, + {"data": "aGky", "media_type": "image/jpeg"}, + }, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + imgs, ok := local.chatBody()["images"].([]any) + if !ok || len(imgs) != 2 { + t.Fatalf("images = %v, want 2 entries", local.chatBody()["images"]) + } + first, _ := imgs[0].(map[string]any) + if first["data"] != "aGk=" || first["media_type"] != "image/png" || first["name"] != "shot.png" { + t.Errorf("images[0] = %v, want data/media_type/name preserved", first) + } + second, _ := imgs[1].(map[string]any) + if second["media_type"] != "image/jpeg" { + t.Errorf("images[1] = %v", second) + } + if _, has := second["name"]; has { + t.Errorf("images[1] = %v, want name omitted when empty", second) + } + + // Compose path: images ride the same /api/chat call. + compLocal, compSrv := newFakeComposeLocal(t) + conn2 := newTestConnector(t, "http://127.0.0.1:1", compSrv.URL) + cmd2 := DeviceCommand{ + ID: "cmd-imgs-compose", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{ + "text": "看图", + "project_path": "/tmp/proj", + "images": []map[string]string{{"data": "aGk=", "media_type": "image/png", "name": "shot.png"}}, + }), + } + status, result = conn2.executeCommand(context.Background(), cmd2) + if status != "ok" { + t.Fatalf("compose: status = %q, result = %v", status, result) + } + _, bodies := compLocal.snapshot() + cimgs, ok := bodies["/api/chat"][0]["images"].([]any) + if !ok || len(cimgs) != 1 { + t.Fatalf("compose chat images = %v, want 1 entry", bodies["/api/chat"][0]) + } + cimg, _ := cimgs[0].(map[string]any) + if cimg["name"] != "shot.png" || cimg["media_type"] != "image/png" { + t.Errorf("compose images[0] = %v", cimg) + } +} diff --git a/internal/cloud/connector.go b/internal/cloud/connector.go new file mode 100644 index 00000000..7a2b6dcb --- /dev/null +++ b/internal/cloud/connector.go @@ -0,0 +1,911 @@ +// connector.go implements the jcloud relay connector: the local jcode poses +// as an always-on runner toward the cloud, over purely outbound connections +// (long poll + POSTs). It forwards downlink commands to the local web control +// plane (127.0.0.1) and pumps local WS events uplink. Every failure is logged +// as a warning — the connector must never affect the local web server. +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/session" +) + +// ConnectorConfig carries everything the connector needs. The zero durations +// fall back to production defaults; tests inject short ones. +type ConnectorConfig struct { + CloudURL string // orchestrator base URL (config.cloud.url, else credentials) + Credentials *Credentials // device identity from ~/.jcode/cloud.json + LocalBase string // local web control plane, e.g. http://127.0.0.1:8080 + LocalToken string // web auth token (empty when the server doesn't require auth) + Version string // jcode version reported at register + + // Optional knobs (tests inject these; zero values use defaults). + HTTPClient *http.Client + Hostname string + HeartbeatInterval time.Duration + PollWait time.Duration + IndexPollInterval time.Duration + BatchWindow time.Duration + BatchMax int + Backoff *Backoff + // IndexPathFn / ListSessionsFn default to the real session index; tests + // override them to point at a temp dir. + IndexPathFn func() (string, error) + ListSessionsFn func() (map[string][]session.SessionMeta, error) + + // InboxDir is the root under which chat attachments land + // (//). Empty → ~/.jcode/inbox. + InboxDir string + // ModelCapabilitiesFn overrides the model/effort facet of the capabilities + // mirror (default: config + model registry); tests inject a fixed list. + ModelCapabilitiesFn func() ([]CapabilityModel, []string, error) + // SlashCommandsFn overrides the slash-commands facet of the capabilities + // mirror (default: GET /api/slash-commands on the local control plane); + // tests inject a fixed list. + SlashCommandsFn func() ([]CapabilitySlashCommand, error) + + // Cipher, when non-nil, seals uplink payloads (events/ephemeral payload, + // sessions meta, ack result) and opens downlink command payloads. Nil + // means plaintext uplink (pre-CEK grey period; see crypto.go). Run lazily + // initializes it from ~/.jcode/cloud.json when left nil; tests inject one + // explicitly (or set CipherDisabled to force the plaintext path). + Cipher *EnvelopeCipher + CipherDisabled bool + + // SyncStore is the M19 per-session cloud-sync gate (~/.jcode/cloud-sessions.json): + // only sessions with an explicit opt-in are upserted / have their events + // uploaded. Nil lazily loads the default path on first use; a load failure + // fails CLOSED (nothing syncs). Tests inject a temp-file store. + SyncStore *SyncStore +} + +const ( + defaultHeartbeatInterval = 30 * time.Second + defaultPollWait = 25 * time.Second + defaultIndexPollInterval = 2 * time.Second + defaultBatchWindow = 200 * time.Millisecond + defaultBatchMax = 20 +) + +// Connector states surfaced via Status (supervisor → GET /api/cloud/status). +const ( + StateOffline = "offline" + StateConnecting = "connecting" + StateOnline = "online" + StateError = "error" +) + +// Connector is the cloud relay client. Construct with NewConnector, then Run. +type Connector struct { + cfg ConnectorConfig + client *Client + local *localControlPlane + seq *seqAllocator + text *agentTextBuffers + token string + cipher *EnvelopeCipher // nil = plaintext uplink (grey period) + + // statusMu guards state/lastError, the live connection snapshot exposed + // via Status. Written by Run's loops, read by the web status endpoint. + statusMu sync.Mutex + state string + lastErr string + + // pairMu guards the pairing inbox (pending approvals + the last-paired + // notification), written by pairing.request commands and read/mutated by + // the web pairing endpoints (via the Supervisor). + pairMu sync.Mutex + pending []PendingPairing + lastPaired *PairedInfo + + // syncStoreMu guards the lazily loaded M19 per-session sync gate. + syncStoreMu sync.Mutex + syncStore *SyncStore + syncStoreTried bool // load attempted (failure is sticky: fail closed) +} + +// NewConnector builds a Connector from cfg. +func NewConnector(cfg ConnectorConfig) *Connector { + hc := cfg.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 30 * time.Second} + } + client := NewClient(cfg.CloudURL) + client.HTTPClient = hc + token := "" + if cfg.Credentials != nil { + token = cfg.Credentials.DeviceToken + } + return &Connector{ + cfg: cfg, + client: client, + local: &localControlPlane{base: strings.TrimRight(cfg.LocalBase, "/"), token: cfg.LocalToken, http: hc}, + seq: newSeqAllocator(), + text: newAgentTextBuffers(), + token: token, + cipher: cfg.Cipher, + state: StateOffline, + // An injected store is used as-is; nil lazy-loads the default path on + // first gate check (see syncGate). + syncStore: cfg.SyncStore, + } +} + +// setState publishes a connection-state transition (see the State* constants). +func (c *Connector) setState(state, lastErr string) { + c.statusMu.Lock() + c.state = state + c.lastErr = lastErr + c.statusMu.Unlock() +} + +// clearError returns the state to online after a failed loop recovers. It is +// a no-op unless the current state is StateError, so it never masks a fresh +// transition made by another loop. +func (c *Connector) clearError() { + c.statusMu.Lock() + if c.state == StateError { + c.state = StateOnline + c.lastErr = "" + } + c.statusMu.Unlock() +} + +// Status returns the current connection state and the last error message +// (empty when none). Safe to call from any goroutine, including before Run. +func (c *Connector) Status() (state string, lastErr string) { + c.statusMu.Lock() + defer c.statusMu.Unlock() + if c.state == "" { + return StateOffline, "" + } + return c.state, c.lastErr +} + +func (c *Connector) heartbeatInterval() time.Duration { + if c.cfg.HeartbeatInterval > 0 { + return c.cfg.HeartbeatInterval + } + return defaultHeartbeatInterval +} + +func (c *Connector) pollWait() time.Duration { + if c.cfg.PollWait > 0 { + return c.cfg.PollWait + } + return defaultPollWait +} + +func (c *Connector) indexPollInterval() time.Duration { + if c.cfg.IndexPollInterval > 0 { + return c.cfg.IndexPollInterval + } + return defaultIndexPollInterval +} + +func (c *Connector) batchWindow() time.Duration { + if c.cfg.BatchWindow > 0 { + return c.cfg.BatchWindow + } + return defaultBatchWindow +} + +func (c *Connector) batchMax() int { + if c.cfg.BatchMax > 0 { + return c.cfg.BatchMax + } + return defaultBatchMax +} + +func (c *Connector) backoff() *Backoff { + if c.cfg.Backoff != nil { + return c.cfg.Backoff + } + return NewBackoff(1*time.Second, 60*time.Second) +} + +func (c *Connector) logf(format string, args ...any) { + config.Logger().Printf("[cloud] "+format, args...) +} + +// syncGate returns the M19 per-session sync store, lazily loading the default +// path on first use. A load failure is sticky and fails CLOSED (nil): with no +// readable opt-in state the connector must not upload anything. The store +// itself transparently re-reads its file on change, so API toggles take +// effect without restarting the connector. +func (c *Connector) syncGate() *SyncStore { + c.syncStoreMu.Lock() + defer c.syncStoreMu.Unlock() + if c.syncStore != nil || c.syncStoreTried { + return c.syncStore + } + c.syncStoreTried = true + path, err := SyncStorePath() + if err != nil { + c.logf("sync store path unavailable, no sessions will sync: %v", err) + return nil + } + store, err := LoadSyncStore(path) + if err != nil { + c.logf("sync store unreadable, no sessions will sync: %v", err) + return nil + } + c.syncStore = store + return store +} + +// syncEnabled reports whether the session's metadata/events may be uploaded: +// only sessions with an explicit opt-in entry sync (M19 default OFF). +func (c *Connector) syncEnabled(sessionID string) bool { + store := c.syncGate() + return store != nil && store.Enabled(sessionID) +} + +// sealUplink encrypts one uplink payload field when the CEK cipher is active; +// on the plaintext grey path (or on seal failure, which must never block the +// relay) it returns the input unchanged. +func (c *Connector) sealUplink(plaintext json.RawMessage) json.RawMessage { + if c.cipher == nil || len(plaintext) == 0 { + return plaintext + } + sealed, err := c.cipher.Seal(plaintext) + if err != nil { + c.logf("seal failed, sending plaintext: %v", err) + return plaintext + } + return sealed +} + +// openDownlink decrypts a downlink command payload when it is an envelope; +// plaintext payloads pass through unchanged (grey rule). An envelope that +// fails to decrypt is a hard error — the command must not run. +func (c *Connector) openDownlink(payload json.RawMessage) (json.RawMessage, error) { + if len(payload) == 0 { + return payload, nil + } + if c.cipher == nil { + if IsEnvelope(payload) { + return nil, fmt.Errorf("received encrypted command but no CEK is initialized on this device") + } + return payload, nil + } + plain, _, err := c.cipher.OpenMaybe(payload) + if err != nil { + return nil, fmt.Errorf("decrypt downlink payload: %w", err) + } + return plain, nil +} + +// Run starts the connector and blocks until ctx is cancelled (web server +// shutdown). It never returns an error — all failures are logged as warnings. +func (c *Connector) Run(ctx context.Context) { + if c.cfg.Credentials == nil || c.token == "" { + c.logf("connector not started: no device credentials") + return + } + // Stopping (ctx cancel) always lands back on offline, whatever the loops + // were doing. + defer c.setState(StateOffline, "") + // E2E (M5): lazily initialize the CEK cipher. Before the CEK exists the + // connector stays on the plaintext grey path; once generated, everything + // uplink is sealed. A failure here must never stop the connector — fall + // back to plaintext with a warning. + if c.cipher == nil && !c.cfg.CipherDisabled { + if cipher, err := EnsureCEK(); err != nil { + c.logf("CEK initialization failed, staying on plaintext uplink: %v", err) + } else { + c.cipher = cipher + c.logf("E2E encryption active (key_gen=%d)", cipher.KeyGen()) + } + } + // The long poll holds a request open for pollWait; make sure the HTTP + // client's own timeout gives the server room to answer. + if c.client.HTTPClient != nil && c.client.HTTPClient.Timeout > 0 && + c.client.HTTPClient.Timeout < c.pollWait()+10*time.Second { + pollClient := *c.client.HTTPClient + pollClient.Timeout = c.pollWait() + 10*time.Second + c.client.HTTPClient = &pollClient + } + + // Register first, retrying with backoff: the orchestrator may briefly be + // unreachable while the local web server is already up. + if err := c.registerLoop(ctx); err != nil { + return // ctx cancelled while registering + } + c.logf("connected to %s as device %s", c.cfg.CloudURL, c.cfg.Credentials.DeviceID) + + // Seed seq numbers from the server's per-session high-water marks BEFORE + // the event pump starts assigning seqs (续号). Best-effort: a failure just + // means the next successful upsert seeds instead. + if err := c.syncSessions(ctx); err != nil { + c.logf("initial session upsert failed (will retry): %v", err) + } + + var wg sync.WaitGroup + for name, fn := range map[string]func(context.Context){ + "heartbeat": c.heartbeatLoop, + "poll": c.pollLoop, + "event_pump": c.eventPumpLoop, + "session_sync": c.sessionSyncLoop, + } { + wg.Add(1) + go func(name string, fn func(context.Context)) { + defer wg.Done() + fn(ctx) + c.logf("%s stopped", name) + }(name, fn) + } + wg.Wait() + c.logf("disconnected") +} + +// registerLoop attempts device registration until it succeeds or ctx ends. +func (c *Connector) registerLoop(ctx context.Context) error { + bo := c.backoff() + hostname := c.cfg.Hostname + if hostname == "" { + hostname, _ = os.Hostname() + } + name := c.cfg.Credentials.DeviceName + if name == "" { + name = hostname + } + for { + c.setState(StateConnecting, "") + err := c.client.RegisterDevice(ctx, c.token, RegisterDeviceRequest{ + Name: name, + Hostname: hostname, + JcodeVersion: c.cfg.Version, + PubKey: c.cfg.Credentials.PublicKey, + Platform: detectPlatform(), + E2EE: c.e2eeActive(), + Fingerprint: fingerprintHashForCreds(c.cfg.Credentials), + }) + if err == nil { + c.setState(StateOnline, "") + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + c.setState(StateError, err.Error()) + c.logf("device register failed: %v", err) + if werr := bo.Wait(ctx); werr != nil { + return werr + } + } +} + +// e2eeActive reports the ACTUAL uplink encryption state sent as `e2ee` at +// register (M13): true only when the CEK cipher initialized AND cloud.e2ee did +// not disable it (CipherDisabled). Run initializes the cipher before the +// register loop, so this reflects the live grey/encrypted path, not the raw +// config flag. +func (c *Connector) e2eeActive() bool { + return c.cipher != nil && !c.cfg.CipherDisabled +} + +// detectPlatform reports how this jcode instance was launched, sent as the// `platform` field at device register. The desktop app spawns `jcode web` as +// a Tauri sidecar with JCODE_DESKTOP=1 in its environment (set in +// desktop/src-tauri/src/sidecar.rs); every other launch is the CLI. +func detectPlatform() string { + if os.Getenv("JCODE_DESKTOP") == "1" { + return "desktop" + } + return "cli" +} + +// heartbeatLoop POSTs a heartbeat every heartbeatInterval. +func (c *Connector) heartbeatLoop(ctx context.Context) { + ticker := time.NewTicker(c.heartbeatInterval()) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := c.client.Heartbeat(ctx, c.token) + if err != nil && ctx.Err() == nil { + c.setState(StateError, err.Error()) + c.logf("heartbeat failed: %v", err) + } + if err == nil { + c.clearError() // heartbeat recovery: back to online + } + } + } +} + +// pollLoop long-polls the downlink command queue. A 204 polls again +// immediately; network/HTTP errors back off exponentially. +func (c *Connector) pollLoop(ctx context.Context) { + bo := c.backoff() + for { + cmds, ok, err := c.client.PollCommands(ctx, c.token, c.pollWait()) + if err != nil { + if ctx.Err() != nil { + return + } + c.setState(StateError, err.Error()) + c.logf("poll failed: %v", err) + if werr := bo.Wait(ctx); werr != nil { + return + } + continue + } + bo.Reset() + c.clearError() // poll recovery: back to online + if !ok { + continue // 204: no commands, poll again right away + } + for _, cmd := range cmds { + c.executeAndAck(ctx, cmd) + } + } +} + +// executeAndAck runs one downlink command against the local control plane and +// posts the ack. The ack itself is best-effort (warned on failure). Downlink +// payloads are decrypted (envelope) before dispatch; the ack result is sealed +// when the CEK cipher is active. +func (c *Connector) executeAndAck(ctx context.Context, cmd DeviceCommand) { + payload, err := c.openDownlink(cmd.Payload) + if err != nil { + c.logf("command %s (%s) rejected: %v", cmd.ID, cmd.Kind, err) + c.ack(ctx, cmd.ID, "error", map[string]string{"error": err.Error()}) + return + } + cmd.Payload = payload + status, result := c.executeCommand(ctx, cmd) + if status == "error" { + c.logf("command %s (%s) failed: %v", cmd.ID, cmd.Kind, result) + } else { + c.logf("command %s (%s) executed", cmd.ID, cmd.Kind) + } + c.ack(ctx, cmd.ID, status, result) +} + +// ack posts one command ack, sealing the result when encryption is active. +func (c *Connector) ack(ctx context.Context, id, status string, result any) { + if c.cipher != nil && result != nil { + if plain, err := json.Marshal(result); err == nil { + result = c.sealUplink(plain) + } + } + if err := c.client.AckCommand(ctx, c.token, id, CommandAck{Status: status, Result: result}); err != nil && ctx.Err() == nil { + c.logf("ack for command %s failed: %v", id, err) + } +} + +// --- command execution against the local web control plane --- + +// localControlPlane is a minimal client for the local jcode web REST API. +type localControlPlane struct { + base string + token string + http *http.Client +} + +// postJSON POSTs body to the local control plane and returns the status and +// raw response body. +func (l *localControlPlane) postJSON(ctx context.Context, path string, body any) (int, []byte, error) { + data, err := json.Marshal(body) + if err != nil { + return 0, nil, fmt.Errorf("marshal local request: %w", err) + } + return l.doJSON(ctx, http.MethodPost, path, bytes.NewReader(data)) +} + +// getJSON GETs the local control plane and returns the status and raw body. +func (l *localControlPlane) getJSON(ctx context.Context, path string) (int, []byte, error) { + return l.doJSON(ctx, http.MethodGet, path, nil) +} + +func (l *localControlPlane) doJSON(ctx context.Context, method, path string, body io.Reader) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, method, l.base+path, body) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + if l.token != "" { + req.Header.Set("Authorization", "Bearer "+l.token) + } + resp, err := l.http.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("%s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("%s %s: read response: %w", method, path, err) + } + return resp.StatusCode, respBody, nil +} + +// chatSendPayload is the (plaintext) payload of a chat.send command. Beyond +// the original text/images/mode/channel it carries the M12 compose facets — +// project_path, model, effort, goal, attachments — all optional. goal_armed +// (M14) flips the meaning of text: it is a goal objective and the command +// only arms the goal (POST /api/goal with start=true), skipping /api/chat and +// every other compose step. +type chatSendPayload struct { + Text string `json:"text"` + Images []chatImage `json:"images,omitempty"` + Mode string `json:"mode,omitempty"` + Channel string `json:"channel,omitempty"` // "console" | "mobile" + ProjectPath string `json:"project_path,omitempty"` + Model *chatModelRef `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + Goal string `json:"goal,omitempty"` + GoalArmed bool `json:"goal_armed,omitempty"` + Attachments []chatAttachment `json:"attachments,omitempty"` +} + +// chatModelRef is the model facet of a compose chat.send. +type chatModelRef struct { + Provider string `json:"provider"` + ID string `json:"id"` +} + +// needsCompose reports whether the payload carries any M12 compose facet and +// therefore goes through the ordered compose pipeline instead of the plain +// one-shot /api/chat call. +func (p *chatSendPayload) needsCompose() bool { + return p.ProjectPath != "" || p.Model != nil || p.Effort != "" || p.Goal != "" || len(p.Attachments) > 0 +} + +// cloudForbiddenMode reports whether a cloud chat.send asks for unrestricted +// execution. Cloud-originated sessions are capped at auto (M20): full_access — +// under any alias — is refused at the protocol layer (ack error +// mode_not_allowed_for_cloud) instead of relying on the client to self-censor. +func cloudForbiddenMode(m string) bool { + switch strings.ToLower(strings.TrimSpace(m)) { + case "full_access", "full-access", "fullaccess", "bypass", "bypass_permissions", "bypasspermissions": + return true + } + return false +} + +// chatImage mirrors web.chatImage (base64 image in a chat request); name is +// the optional original filename (file picker / paste), passed through +// losslessly — the local /api/chat handler ignores it. +type chatImage struct { + Data string `json:"data"` + MimeType string `json:"media_type"` + Name string `json:"name,omitempty"` +} + +// approvalRespondPayload is the payload of an approval.respond command. +type approvalRespondPayload struct { + ApprovalID string `json:"approval_id"` + Decision string `json:"decision"` // "approve" | "approve_all" | "deny" +} + +// executeCommand dispatches one command to the local control plane and +// returns the ack status ("ok"|"error") plus the ack result payload. +func (c *Connector) executeCommand(ctx context.Context, cmd DeviceCommand) (string, any) { + switch cmd.Kind { + case "chat.send": + return c.execChatSend(ctx, cmd) + case "chat.stop": + return c.execChatStop(ctx, cmd) + case "approval.respond": + return c.execApprovalRespond(ctx, cmd) + case "pairing.request": + return c.execPairingRequest(ctx, cmd) + default: + return "error", map[string]string{"error": fmt.Sprintf("unknown command kind %q", cmd.Kind)} + } +} + +func (c *Connector) execChatSend(ctx context.Context, cmd DeviceCommand) (string, any) { + var p chatSendPayload + if err := json.Unmarshal(cmd.Payload, &p); err != nil { + return "error", map[string]string{"error": fmt.Sprintf("invalid chat.send payload: %v", err)} + } + // M20 mode ceiling: a cloud-originated session may not run full_access + // (bypass). Rejected before any side effect — even a goal_armed payload + // that declares the intent is refused. + if cloudForbiddenMode(p.Mode) { + return "error", map[string]string{"error": fmt.Sprintf("mode_not_allowed_for_cloud: %q (cloud sessions are capped at auto)", p.Mode)} + } + // goal_armed wins over everything: text is the goal objective and the + // command only arms the goal — /api/chat and all compose facets + // (mode/images/session/attachments/…) are ignored. + if p.GoalArmed { + return c.execChatSendGoalArmed(ctx, &p) + } + // Attachments alone are a valid message (their reference list becomes the + // text); truly empty input is not. + if strings.TrimSpace(p.Text) == "" && len(p.Attachments) == 0 { + return "error", map[string]string{"error": "chat.send: empty text"} + } + if p.needsCompose() { + return c.execChatSendCompose(ctx, cmd, &p) + } + return c.execChatSendLegacy(ctx, cmd, &p) +} + +// execChatSendGoalArmed arms the session goal on the active engine via +// POST /api/goal with start=true (mirroring the web UI's setGoal default), +// which kicks off the agent run itself — no /api/chat call follows. +func (c *Connector) execChatSendGoalArmed(ctx context.Context, p *chatSendPayload) (string, any) { + objective := strings.TrimSpace(p.Text) + if objective == "" { + return "error", map[string]string{"error": "chat.send: goal_armed with empty objective"} + } + status, body, err := c.local.postJSON(ctx, "/api/goal", map[string]any{ + "objective": objective, "start": true, + }) + if err != nil { + return "error", map[string]string{"error": err.Error()} + } + if status != http.StatusOK { + return "error", map[string]string{"error": errUnexpectedStatus("/api/goal", status, string(body)).Error()} + } + return "ok", json.RawMessage(body) +} + +// execChatSendLegacy is the pre-M12 one-shot path: a single /api/chat call, +// optionally continuing cmd.SessionID. +func (c *Connector) execChatSendLegacy(ctx context.Context, cmd DeviceCommand, p *chatSendPayload) (string, any) { + // The local /api/chat request. session_id is omitted for a NEW session + // (the server mints the UUID); source carries the relay channel through + // the same mechanism WeChat uses (submitMessage's source label). + req := map[string]any{ + "message": p.Text, + } + if len(p.Images) > 0 { + req["images"] = p.Images + } + if p.Mode != "" { + req["mode"] = p.Mode + } + if cmd.SessionID != "" { + req["session_id"] = cmd.SessionID + } + if p.Channel != "" { + req["source"] = p.Channel + } + status, body, err := c.local.postJSON(ctx, "/api/chat", req) + if err != nil { + return "error", map[string]string{"error": err.Error()} + } + if status != http.StatusAccepted && status != http.StatusOK { + return "error", map[string]string{"error": errUnexpectedStatus("/api/chat", status, string(body)).Error()} + } + var resp struct { + SessionID string `json:"session_id"` + } + _ = json.Unmarshal(body, &resp) + return "ok", map[string]string{"session_id": resp.SessionID} +} + +// execChatSendCompose runs the M12 ordered compose pipeline against the local +// control plane: create/focus the session (project_path) → land attachments → +// model → effort → mode → goal → send the message with the attachment +// reference list appended. Every step failure acks error naming the facet — +// unsupported facets are never silently ignored. +func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, p *chatSendPayload) (string, any) { + errResult := func(err error) (string, any) { + return "error", map[string]string{"error": err.Error()} + } + + // 0. Validate and decode attachments BEFORE any side effect, so a limit + // breach leaves no trace. + decoded, err := decodeAttachments(p.Attachments) + if err != nil { + return errResult(err) + } + + // 1. Create/focus the session. POST /api/sessions makes the task the + // active engine — the model/mode/goal endpoints all target the active + // engine — and returns its id, which the attachments dir and the chat + // call both need. + sessReq := map[string]string{} + if cmd.SessionID != "" { + sessReq["session_id"] = cmd.SessionID + } + if p.ProjectPath != "" { + sessReq["pwd"] = p.ProjectPath + } + if p.Channel != "" { + // The web layer stamps cloud-originated sessions as cloud-synced at + // creation (M19): a session created from the cloud must be visible + // there. + sessReq["source"] = p.Channel + } + status, body, err := c.local.postJSON(ctx, "/api/sessions", sessReq) + if err != nil { + return errResult(err) + } + if status != http.StatusOK { + return errResult(errUnexpectedStatus("/api/sessions", status, string(body))) + } + var sessResp struct { + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(body, &sessResp); err != nil || sessResp.SessionID == "" { + return errResult(fmt.Errorf("/api/sessions: no session_id in response: %s", body)) + } + sid := sessResp.SessionID + + // 2. Land attachments at //. + var refs []attachmentRef + if len(p.Attachments) > 0 { + refs, err = writeInboxAttachments(c.inboxRoot(), sid, p.Attachments, decoded) + if err != nil { + return errResult(err) + } + } + + // 3. Model, then effort (the effort endpoint is keyed by provider+model). + if p.Model != nil { + if p.Model.Provider == "" || p.Model.ID == "" { + return errResult(fmt.Errorf("model: provider and id are both required")) + } + if err := c.postLocalOK(ctx, "/api/model", map[string]string{ + "provider": p.Model.Provider, "model": p.Model.ID, + }); err != nil { + return errResult(fmt.Errorf("model: %w", err)) + } + } + if p.Effort != "" { + provider, modelID := "", "" + if p.Model != nil { + provider, modelID = p.Model.Provider, p.Model.ID + } else { + provider, modelID, err = c.currentModel(ctx) + if err != nil { + return errResult(fmt.Errorf("effort: cannot resolve current model: %w", err)) + } + } + if provider == "" || modelID == "" { + return errResult(fmt.Errorf("effort: no current model to apply effort %q to", p.Effort)) + } + if err := c.postLocalOK(ctx, "/api/model-state/effort", map[string]string{ + "provider": provider, "model": modelID, "effort": p.Effort, + }); err != nil { + return errResult(fmt.Errorf("effort: %w", err)) + } + } + // Mode: the chat endpoint's mode field only applies to engines IT builds; + // the compose session already exists, so switch it on the focused engine. + if p.Mode != "" { + m := p.Mode + if m == "build" { // legacy chat-mode alias for the approval mode + m = "approval" + } + if err := c.postLocalOK(ctx, "/api/mode", map[string]string{"mode": m}); err != nil { + return errResult(fmt.Errorf("mode: %w", err)) + } + } + + // 4. Goal (start=false: the message below kicks the run off itself). + if p.Goal != "" { + if err := c.postLocalOK(ctx, "/api/goal", map[string]any{ + "objective": p.Goal, "start": false, + }); err != nil { + return errResult(fmt.Errorf("goal: %w", err)) + } + } + + // 5. Send the message with the attachment reference list appended. + req := map[string]any{ + "message": p.Text + attachmentReferenceList(refs), + "session_id": sid, + } + if len(p.Images) > 0 { + req["images"] = p.Images + } + if p.Channel != "" { + req["source"] = p.Channel + } + status, body, err = c.local.postJSON(ctx, "/api/chat", req) + if err != nil { + return errResult(err) + } + if status != http.StatusAccepted && status != http.StatusOK { + return errResult(errUnexpectedStatus("/api/chat", status, string(body))) + } + return "ok", map[string]string{"session_id": sid} +} + +// postLocalOK POSTs to the local control plane expecting a 200 OK. +func (c *Connector) postLocalOK(ctx context.Context, path string, body any) error { + status, respBody, err := c.local.postJSON(ctx, path, body) + if err != nil { + return err + } + if status != http.StatusOK { + return errUnexpectedStatus(path, status, string(respBody)) + } + return nil +} + +// currentModel resolves the active engine's provider/model via GET +// /api/health (used to key an effort override when the command did not name +// a model). +func (c *Connector) currentModel(ctx context.Context) (provider, modelID string, err error) { + status, body, err := c.local.getJSON(ctx, "/api/health") + if err != nil { + return "", "", err + } + if status != http.StatusOK { + return "", "", errUnexpectedStatus("/api/health", status, string(body)) + } + var health struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + if err := json.Unmarshal(body, &health); err != nil { + return "", "", fmt.Errorf("/api/health: invalid response: %w", err) + } + return health.Provider, health.Model, nil +} + +// inboxRoot returns the attachment inbox root (default ~/.jcode/inbox). +func (c *Connector) inboxRoot() string { + if c.cfg.InboxDir != "" { + return c.cfg.InboxDir + } + return filepath.Join(config.ConfigDir(), "inbox") +} + +func (c *Connector) execChatStop(ctx context.Context, cmd DeviceCommand) (string, any) { + // The local stop endpoint keys on task_id (= session UUID). + status, body, err := c.local.postJSON(ctx, "/api/stop", map[string]string{"task_id": cmd.SessionID}) + if err != nil { + return "error", map[string]string{"error": err.Error()} + } + if status != http.StatusOK { + return "error", map[string]string{"error": errUnexpectedStatus("/api/stop", status, string(body)).Error()} + } + return "ok", json.RawMessage(body) +} + +func (c *Connector) execApprovalRespond(ctx context.Context, cmd DeviceCommand) (string, any) { + var p approvalRespondPayload + if err := json.Unmarshal(cmd.Payload, &p); err != nil { + return "error", map[string]string{"error": fmt.Sprintf("invalid approval.respond payload: %v", err)} + } + if p.ApprovalID == "" { + return "error", map[string]string{"error": "approval.respond: empty approval_id"} + } + var approved, approveAll bool + switch p.Decision { + case "approve": + approved = true + case "approve_all": + approved, approveAll = true, true + case "deny", "reject": + approved = false + default: + return "error", map[string]string{"error": fmt.Sprintf("approval.respond: unknown decision %q", p.Decision)} + } + status, body, err := c.local.postJSON(ctx, "/api/approval", map[string]any{ + "id": p.ApprovalID, + "task_id": cmd.SessionID, + "approved": approved, + "approve_all": approveAll, + }) + if err != nil { + return "error", map[string]string{"error": err.Error()} + } + if status != http.StatusOK { + return "error", map[string]string{"error": errUnexpectedStatus("/api/approval", status, string(body)).Error()} + } + return "ok", json.RawMessage(body) +} diff --git a/internal/cloud/connector_test.go b/internal/cloud/connector_test.go new file mode 100644 index 00000000..75ff95e3 --- /dev/null +++ b/internal/cloud/connector_test.go @@ -0,0 +1,787 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cnjack/jcode/internal/session" + "github.com/gorilla/websocket" +) + +// --- mock orchestrator --- + +type mockCloud struct { + mu sync.Mutex + acks map[string]CommandAck + eventBatches map[string][]EventUpload + ephemeral []ephemeralRecord + sessionReqs [][]SessionUpsert + capsReqs []json.RawMessage + lastSeq map[string]int64 + + pollScripts [][]DeviceCommand // consumed in order; afterwards 204 + pollCount atomic.Int64 + pollFail atomic.Bool + + conflictNextEvents atomic.Bool // next events upload answers all-conflicted + conflictMaxSeq int64 +} + +type ephemeralRecord struct { + sid string + kind string + payload json.RawMessage +} + +func newMockCloud() *mockCloud { + return &mockCloud{ + acks: make(map[string]CommandAck), + eventBatches: make(map[string][]EventUpload), + lastSeq: make(map[string]int64), + } +} + +func (m *mockCloud) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /internal/v1/device/register", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("POST /internal/v1/device/heartbeat", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /internal/v1/device/poll", func(w http.ResponseWriter, r *http.Request) { + m.pollCount.Add(1) + if m.pollFail.Load() { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + m.mu.Lock() + var cmds []DeviceCommand + if len(m.pollScripts) > 0 { + cmds = m.pollScripts[0] + m.pollScripts = m.pollScripts[1:] + } + m.mu.Unlock() + if cmds == nil { + w.WriteHeader(http.StatusNoContent) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"commands": cmds}) + }) + mux.HandleFunc("POST /internal/v1/device/commands/{id}/ack", func(w http.ResponseWriter, r *http.Request) { + var ack CommandAck + _ = json.NewDecoder(r.Body).Decode(&ack) + m.mu.Lock() + m.acks[r.PathValue("id")] = ack + m.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("POST /internal/v1/device/sessions", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Sessions []SessionUpsert `json:"sessions"` + Capabilities json.RawMessage `json:"capabilities"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + m.mu.Lock() + m.sessionReqs = append(m.sessionReqs, req.Sessions) + m.capsReqs = append(m.capsReqs, req.Capabilities) + resp := SessionsUpsertResponse{} + for _, s := range req.Sessions { + resp.Sessions = append(resp.Sessions, SessionSeqInfo{SessionID: s.SessionID, LastSeq: m.lastSeq[s.SessionID]}) + } + m.mu.Unlock() + _ = json.NewEncoder(w).Encode(resp) + }) + mux.HandleFunc("POST /internal/v1/device/sessions/{sid}/events", func(w http.ResponseWriter, r *http.Request) { + sid := r.PathValue("sid") + var req struct { + Events []EventUpload `json:"events"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if m.conflictNextEvents.Swap(false) { + seqs := make([]int64, 0, len(req.Events)) + for _, e := range req.Events { + seqs = append(seqs, e.Seq) + } + _ = json.NewEncoder(w).Encode(EventsUploadResponse{ + Accepted: []int64{}, + Conflicted: seqs, + MaxSeq: m.conflictMaxSeq, + }) + return + } + m.mu.Lock() + m.eventBatches[sid] = append(m.eventBatches[sid], req.Events...) + m.mu.Unlock() + seqs := make([]int64, 0, len(req.Events)) + var max int64 + for _, e := range req.Events { + seqs = append(seqs, e.Seq) + if e.Seq > max { + max = e.Seq + } + } + _ = json.NewEncoder(w).Encode(EventsUploadResponse{Accepted: seqs, MaxSeq: max}) + }) + mux.HandleFunc("POST /internal/v1/device/sessions/{sid}/ephemeral", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + m.mu.Lock() + m.ephemeral = append(m.ephemeral, ephemeralRecord{sid: r.PathValue("sid"), kind: req.Kind, payload: req.Payload}) + m.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + return mux +} + +// allEvents returns every durable event uploaded for the test session ("s1"). +func (m *mockCloud) allEvents() []EventUpload { + m.mu.Lock() + defer m.mu.Unlock() + return append([]EventUpload(nil), m.eventBatches["s1"]...) +} + +func (m *mockCloud) ephemeralCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.ephemeral) +} + +func (m *mockCloud) ackCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.acks) +} + +// --- fake local web control plane --- + +type fakeLocal struct { + mu sync.Mutex + chatBodies []map[string]any + stopBodies []map[string]any + approvalBodies []map[string]any + chatSessionID string +} + +func newFakeLocal(t *testing.T) (*fakeLocal, *httptest.Server) { + t.Helper() + f := &fakeLocal{chatSessionID: "sess-new-1"} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/chat", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.chatBodies = append(f.chatBodies, body) + f.mu.Unlock() + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "processing", "session_id": f.chatSessionID}) + }) + mux.HandleFunc("POST /api/stop", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.stopBodies = append(f.stopBodies, body) + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]string{"status": "stopped"}) + }) + mux.HandleFunc("POST /api/approval", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.approvalBodies = append(f.approvalBodies, body) + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return f, srv +} + +func (f *fakeLocal) chatBody() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.chatBodies[0] +} + +// --- helpers --- + +func newTestConnector(t *testing.T, cloudURL, localBase string) *Connector { + t.Helper() + return NewConnector(ConnectorConfig{ + CloudURL: cloudURL, + Credentials: &Credentials{DeviceID: "dev-1", DeviceToken: "tok", DeviceName: "test"}, + LocalBase: localBase, + Version: "test", + HeartbeatInterval: time.Hour, // effectively disabled in tests + PollWait: 20 * time.Millisecond, + IndexPollInterval: time.Hour, + BatchWindow: 10 * time.Millisecond, + BatchMax: 20, + Backoff: NewBackoff(5*time.Millisecond, 40*time.Millisecond), + // M19: without an explicit opt-in the connector syncs nothing. Tests + // pre-opt-in the sids they exercise ("s1"/"s2"); filter-specific tests + // build their own store via newTestSyncStore. + SyncStore: newTestSyncStore(t, "s1", "s2"), + }) +} + +// newTestSyncStore writes a temp sync-store file with the given sessions +// opted in and loads it. +func newTestSyncStore(t *testing.T, ids ...string) *SyncStore { + t.Helper() + entries := make(map[string]bool, len(ids)) + for _, id := range ids { + entries[id] = true + } + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + data, err := json.Marshal(entries) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + store, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + return store +} + +// waitFor polls cond until it holds, failing the test after a fixed deadline. +func waitFor(t *testing.T, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for: %s", msg) +} + +func mustPayload(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data +} + +// --- command execution tests --- + +func TestChatSendNewSession(t *testing.T) { + local, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ + ID: "cmd-1", + Kind: "chat.send", + // no session_id → new session: the local request must NOT carry one + Payload: mustPayload(t, map[string]any{"text": "hello", "channel": "console"}), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + res, ok := result.(map[string]string) + if !ok || res["session_id"] != local.chatSessionID { + t.Fatalf("ack result = %v, want session_id %q", result, local.chatSessionID) + } + body := local.chatBody() + if body["message"] != "hello" { + t.Errorf("message = %v, want hello", body["message"]) + } + if _, has := body["session_id"]; has { + t.Errorf("new-session chat.send must omit session_id, got %v", body["session_id"]) + } + if body["source"] != "console" { + t.Errorf("source = %v, want console (channel passthrough)", body["source"]) + } +} + +func TestChatSendExistingSession(t *testing.T) { + local, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ + ID: "cmd-2", + Kind: "chat.send", + SessionID: "sess-42", + Payload: mustPayload(t, map[string]any{ + "text": "go on", "channel": "mobile", "mode": "plan", + "images": []map[string]string{{"data": "aGk=", "media_type": "image/png"}}, + }), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + body := local.chatBody() + if body["session_id"] != "sess-42" { + t.Errorf("session_id = %v, want sess-42", body["session_id"]) + } + if body["mode"] != "plan" { + t.Errorf("mode = %v, want plan", body["mode"]) + } + if body["source"] != "mobile" { + t.Errorf("source = %v, want mobile", body["source"]) + } + imgs, ok := body["images"].([]any) + if !ok || len(imgs) != 1 { + t.Errorf("images = %v, want one image", body["images"]) + } +} + +func TestChatSendEmptyTextRejected(t *testing.T) { + _, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + cmd := DeviceCommand{ID: "cmd-3", Kind: "chat.send", Payload: mustPayload(t, map[string]any{"text": " "})} + if status, _ := conn.executeCommand(context.Background(), cmd); status != "error" { + t.Fatalf("status = %q, want error for empty text", status) + } +} + +func TestChatStop(t *testing.T) { + local, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cmd := DeviceCommand{ID: "cmd-4", Kind: "chat.stop", SessionID: "sess-7"} + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + local.mu.Lock() + defer local.mu.Unlock() + if len(local.stopBodies) != 1 || local.stopBodies[0]["task_id"] != "sess-7" { + t.Fatalf("stop bodies = %v, want one call with task_id sess-7", local.stopBodies) + } +} + +func TestApprovalRespond(t *testing.T) { + local, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + + cases := []struct { + decision string + wantApproved, wantAll bool + }{ + {"approve", true, false}, + {"approve_all", true, true}, + {"deny", false, false}, + } + for i, tc := range cases { + cmd := DeviceCommand{ + ID: fmt.Sprintf("cmd-a%d", i), + Kind: "approval.respond", + SessionID: "sess-9", + Payload: mustPayload(t, map[string]any{"approval_id": "approval_3", "decision": tc.decision}), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("decision %q: status = %q, result = %v", tc.decision, status, result) + } + local.mu.Lock() + ab := local.approvalBodies[len(local.approvalBodies)-1] + local.mu.Unlock() + if ab["id"] != "approval_3" || ab["task_id"] != "sess-9" { + t.Errorf("decision %q: body = %v", tc.decision, ab) + } + if ab["approved"] != tc.wantApproved || ab["approve_all"] != tc.wantAll { + t.Errorf("decision %q: approved=%v approve_all=%v, want %v/%v", + tc.decision, ab["approved"], ab["approve_all"], tc.wantApproved, tc.wantAll) + } + } + + // Unknown decision must fail without touching the local API. + cmd := DeviceCommand{ID: "cmd-bad", Kind: "approval.respond", Payload: mustPayload(t, map[string]any{"approval_id": "x", "decision": "shrug"})} + if status, _ := conn.executeCommand(context.Background(), cmd); status != "error" { + t.Fatalf("unknown decision: status = %q, want error", status) + } +} + +func TestUnknownCommandKind(t *testing.T) { + conn := newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1") + status, result := conn.executeCommand(context.Background(), DeviceCommand{ID: "c", Kind: "nope"}) + if status != "error" { + t.Fatalf("status = %q, want error", status) + } + if !strings.Contains(fmt.Sprint(result), "unknown command kind") { + t.Fatalf("result = %v, want unknown-kind error", result) + } +} + +// --- poll loop tests --- + +func TestPollLoopExecutesAndAcks(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + local, localSrv := newFakeLocal(t) + + cloud.pollScripts = [][]DeviceCommand{{ + {ID: "cmd-1", Kind: "chat.send", Payload: mustPayload(t, map[string]any{"text": "hi", "channel": "console"})}, + }} + + conn := newTestConnector(t, cloudSrv.URL, localSrv.URL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go conn.pollLoop(ctx) + + waitFor(t, func() bool { return cloud.ackCount() == 1 }, "one ack") + cloud.mu.Lock() + ack := cloud.acks["cmd-1"] + cloud.mu.Unlock() + if ack.Status != "ok" { + t.Fatalf("ack status = %q, want ok", ack.Status) + } + res, ok := ack.Result.(map[string]any) + if !ok || res["session_id"] != local.chatSessionID { + t.Fatalf("ack result = %v, want session_id %q", ack.Result, local.chatSessionID) + } +} + +func TestPollLoopBackoffOnError(t *testing.T) { + cloud := newMockCloud() + cloud.pollFail.Store(true) + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go conn.pollLoop(ctx) + + // With a 5ms→40ms exponential backoff, ~250ms must allow several retries. + time.Sleep(250 * time.Millisecond) + if n := cloud.pollCount.Load(); n < 3 { + t.Fatalf("poll attempts = %d, want >= 3 (backoff retry)", n) + } + cancel() +} + +// --- event pump tests --- + +func wsMsg(t *testing.T, typ, taskID string, data any) []byte { + t.Helper() + raw, err := json.Marshal(map[string]any{"type": typ, "task_id": taskID, "data": data}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestEventPumpDurableAndEphemeral(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + batcher := newEventBatcher(conn) + go batcher.run(ctx) + + // Durable: user_message, tool_call, task_status (global envelope, id in data). + conn.handleWSEvent(ctx, batcher, wsMsg(t, "user_message", "s1", map[string]string{"content": "hi", "source": "console"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_call", "s1", map[string]string{"name": "read"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "task_status", "", map[string]any{"task_id": "s1", "status": "running", "running": true})) + // Ephemeral: token-level deltas. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "chunk"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "token_update", "s1", map[string]int64{"total_tokens": 42})) + // Global non-session event: skipped entirely. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "mcp_changed", "", map[string]string{"name": "x"})) + + waitFor(t, func() bool { return len(cloud.allEvents()) == 3 }, "3 durable events uploaded") + waitFor(t, func() bool { return cloud.ephemeralCount() == 2 }, "2 ephemeral events forwarded") + + events := cloud.allEvents() + for i, ev := range events { + if ev.Seq != int64(i+1) { + t.Fatalf("events[%d].Seq = %d, want %d (per-session monotonic from 1)", i, ev.Seq, i+1) + } + } + wantKinds := []string{"user_message", "tool_call", "task_status"} + for i, ev := range events { + if ev.Kind != wantKinds[i] { + t.Errorf("events[%d].Kind = %q, want %q", i, ev.Kind, wantKinds[i]) + } + var payload map[string]any + if err := json.Unmarshal(ev.Payload, &payload); err != nil || payload["type"] != wantKinds[i] { + t.Errorf("events[%d] payload = %s, want original WS message with type %q", i, ev.Payload, wantKinds[i]) + } + } +} + +func TestEventPumpLastSeqResumeAndConflictResync(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "/proj": {{UUID: "s1", Project: "/proj", Status: "idle"}}, + }, nil + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Startup upsert: server already holds events up to seq 41 for s1. + cloud.lastSeq["s1"] = 41 + if err := conn.syncSessions(ctx); err != nil { + t.Fatalf("syncSessions: %v", err) + } + if got := conn.seq.Next("s1"); got != 42 { + t.Fatalf("first seq after upsert = %d, want 42 (last_seq 41 续号)", got) + } + // Rewind the allocator via Seed to replay the scenario through handleWSEvent. + conn.seq.Seed("s1", 0) // no-op: never moves backwards + + batcher := newEventBatcher(conn) + + // The next batch conflicts (another incarnation beat us): server says + // max_seq=57, so numbering must resync to 58. flushAll is synchronous, so + // the Resync has happened by the time it returns — no ticker involved. + cloud.conflictMaxSeq = 57 + cloud.conflictNextEvents.Store(true) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_call", "s1", map[string]string{"name": "read"})) + batcher.flushAll(ctx) + if cloud.conflictNextEvents.Load() { + t.Fatal("conflict response was not consumed by the first flush") + } + + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_result", "s1", map[string]string{"name": "read"})) + batcher.flushAll(ctx) + if got := len(cloud.allEvents()); got != 1 { + t.Fatalf("uploaded events after conflict = %d, want 1 (conflicted batch skipped server-side)", got) + } + if got := cloud.allEvents()[0].Seq; got != 58 { + t.Fatalf("seq after conflict = %d, want 58 (max_seq 57 + 1)", got) + } +} + +// TestEventPumpOverRealWS exercises the WS dial + message parse path against a +// real websocket server (the connector speaks the internal/web WS protocol: +// server→client WSEvent JSON, no subscribe needed). +func TestEventPumpOverRealWS(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + wsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/ws" { + http.NotFound(w, r) + return + } + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = c.Close() }() + _ = c.WriteMessage(websocket.TextMessage, wsMsg(t, "user_message", "s1", map[string]string{"content": "hi"})) + _ = c.WriteMessage(websocket.TextMessage, wsMsg(t, "agent_text", "s1", map[string]string{"text": "delta"})) + // Keep the socket open until the client goes away. + for { + if _, _, err := c.ReadMessage(); err != nil { + return + } + } + })) + t.Cleanup(wsSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, wsSrv.URL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + batcher := newEventBatcher(conn) + go batcher.run(ctx) + go func() { _ = conn.pumpEvents(ctx, batcher) }() + + waitFor(t, func() bool { return len(cloud.allEvents()) == 1 }, "durable event via real WS") + waitFor(t, func() bool { return cloud.ephemeralCount() == 1 }, "ephemeral event via real WS") + if got := cloud.allEvents()[0].Seq; got != 1 { + t.Fatalf("seq = %d, want 1", got) + } +} + +// TestEventPumpSynthesizesAgentMessage pins the durable assistant-text path: +// ephemeral agent_text deltas accumulate per session and, when agent_done +// arrives, the connector synthesizes a durable agent_message event carrying +// the full text, batched right after agent_done. A done with no buffered +// text synthesizes nothing. +func TestEventPumpSynthesizesAgentMessage(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + batcher := newEventBatcher(conn) + + // Deltas are ephemeral (still forwarded live) AND buffered. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "Hello, "})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "world"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_done", "s1", map[string]any{})) + batcher.flushAll(ctx) + + events := cloud.allEvents() + if len(events) != 2 { + t.Fatalf("uploaded events = %d, want 2 (agent_done + agent_message)", len(events)) + } + if events[0].Kind != "agent_done" || events[1].Kind != "agent_message" { + t.Fatalf("kinds = %q, %q; want agent_done followed by agent_message", events[0].Kind, events[1].Kind) + } + if events[0].Seq != 1 || events[1].Seq != 2 { + t.Fatalf("seqs = %d, %d; want 1, 2", events[0].Seq, events[1].Seq) + } + var payload struct { + Type string `json:"type"` + Data struct { + Text string `json:"text"` + } `json:"data"` + } + if err := json.Unmarshal(events[1].Payload, &payload); err != nil { + t.Fatalf("agent_message payload: %v", err) + } + if payload.Type != "agent_message" || payload.Data.Text != "Hello, world" { + t.Fatalf("agent_message payload = %s, want data.text %q", events[1].Payload, "Hello, world") + } + + // The buffer was consumed: a second done without deltas adds no message. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_done", "s1", map[string]any{})) + batcher.flushAll(ctx) + if got := len(cloud.allEvents()); got != 3 { + t.Fatalf("uploaded events after empty done = %d, want 3 (bare agent_done)", got) + } +} + +// TestEventPumpSessionResetClearsAgentText pins that session_reset drops any +// buffered deltas, so a reset followed by done synthesizes no agent_message. +func TestEventPumpSessionResetClearsAgentText(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + batcher := newEventBatcher(conn) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "partial"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "session_reset", "s1", map[string]any{})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_done", "s1", map[string]any{})) + batcher.flushAll(ctx) + + for _, ev := range cloud.allEvents() { + if ev.Kind == "agent_message" { + t.Fatalf("agent_message uploaded after session_reset cleared the buffer: %s", ev.Payload) + } + } +} + +// TestAgentTextBufferCap pins the 256KB per-session cap: the buffer stops +// growing past the cap and take marks the text truncated. +func TestAgentTextBufferCap(t *testing.T) { + bufs := newAgentTextBuffers() + bufs.append("s1", strings.Repeat("a", agentTextBufCap+1024)) + text := bufs.take("s1") + if !strings.HasSuffix(text, "[…truncated by the device connector at 256KB]") { + t.Fatalf("capped text missing truncation marker (len=%d)", len(text)) + } + if !strings.HasPrefix(text, strings.Repeat("a", agentTextBufCap)) { + t.Fatal("capped text must keep the first 256KB of deltas") + } + // take cleared the buffer. + if got := bufs.take("s1"); got != "" { + t.Fatalf("second take = %q, want empty", got) + } +} + +// --- session sync tests --- + +func TestSyncSessionsUpsert(t *testing.T) { + cloud := newMockCloud() + cloudSrv := httptest.NewServer(cloud.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "/proj": { + {UUID: "s1", Project: "/proj", Model: "m1", Status: "running", Title: "t1"}, + {UUID: "s2", Project: "/proj", Model: "m1", Status: "idle"}, + }, + }, nil + } + cloud.lastSeq["s1"] = 7 + + if err := conn.syncSessions(context.Background()); err != nil { + t.Fatalf("syncSessions: %v", err) + } + + cloud.mu.Lock() + reqs := cloud.sessionReqs + cloud.mu.Unlock() + if len(reqs) != 1 || len(reqs[0]) != 2 { + t.Fatalf("session upserts = %v, want one round with 2 sessions", reqs) + } + byID := map[string]SessionUpsert{} + for _, s := range reqs[0] { + byID[s.SessionID] = s + } + if byID["s1"].Status != "running" { + t.Errorf("s1 status = %q, want running", byID["s1"].Status) + } + if byID["s2"].Status != "idle" { + t.Errorf("s2 status = %q, want idle", byID["s2"].Status) + } + // Meta is the SessionMeta JSON, as-is. + var meta session.SessionMeta + if err := json.Unmarshal(byID["s1"].Meta, &meta); err != nil || meta.Title != "t1" || meta.Model != "m1" { + t.Errorf("s1 meta = %s, want SessionMeta JSON with title t1", byID["s1"].Meta) + } + // last_seq seeds the allocator (续号). + if got := conn.seq.Next("s1"); got != 8 { + t.Errorf("seq after upsert = %d, want 8 (last_seq 7 + 1)", got) + } + if got := conn.seq.Next("s2"); got != 1 { + t.Errorf("seq for unseen session = %d, want 1", got) + } +} + +// --- startup gate --- + +func TestShouldConnect(t *testing.T) { + creds := &Credentials{DeviceToken: "tok"} + if !ShouldConnect(true, creds) { + t.Error("logged in + auto_connect → should connect") + } + if ShouldConnect(false, creds) { + t.Error("auto_connect=false → must not connect") + } + if ShouldConnect(true, nil) { + t.Error("not logged in → must not connect") + } + if ShouldConnect(true, &Credentials{}) { + t.Error("empty device token → must not connect") + } +} diff --git a/internal/cloud/credentials.go b/internal/cloud/credentials.go new file mode 100644 index 00000000..5912ab5a --- /dev/null +++ b/internal/cloud/credentials.go @@ -0,0 +1,157 @@ +// Package cloud implements the jcloud (device relay) client side: device-code +// login (RFC 8628), device registration, and the on-disk device credentials. +// See cloud/docs/17-jcode-device-relay.md §3 for the interface contract. +package cloud + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +const credentialsFile = "cloud.json" + +// Credentials is the device identity persisted at ~/.jcode/cloud.json. A +// missing file means "not logged in" and is never an error. +type Credentials struct { + CloudURL string `json:"cloud_url"` + DeviceID string `json:"device_id"` + DeviceToken string `json:"device_token"` + DeviceName string `json:"device_name"` + // PublicKey / PrivateKey are the X25519 device identity key pair, base64 + // (standard encoding) encoded raw keys. The public key is registered with + // the orchestrator for later E2E key exchange (CEK wrapping). + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + // KeyGen is the current CEK generation this device holds. + KeyGen int `json:"key_gen"` + // CEK is the account-level AES-256-GCM content encryption key, base64. + // Empty means "not initialized yet" (pre-M5 credentials file); it is + // lazily generated on first need (see crypto.go EnsureCEK). + CEK string `json:"cek,omitempty"` + // Fingerprint is the stable machine fingerprint SOURCE (M16): the OS + // machine id, or a "fallback::" string generated once. + // It never leaves the machine — only its sha256 (FingerprintHash) is sent + // to the orchestrator for login dedup. Empty on pre-M16 files; resolved + // on the fly then (see ResolveFingerprintSource). + Fingerprint string `json:"fingerprint,omitempty"` +} + +// GenerateIdentityKeyPair creates a fresh X25519 device identity key pair and +// returns both keys base64-encoded (standard encoding, raw 32-byte keys). +func GenerateIdentityKeyPair() (publicKey, privateKey string, err error) { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return "", "", fmt.Errorf("generate X25519 identity key: %w", err) + } + return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), + base64.StdEncoding.EncodeToString(priv.Bytes()), nil +} + +// CredentialsPath returns the full path to the credentials file +// (~/.jcode/cloud.json). +func CredentialsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".jcode", credentialsFile), nil +} + +// LoadCredentials reads ~/.jcode/cloud.json. A missing file is not an error: +// it returns (nil, nil) to mean "not logged in". +func LoadCredentials() (*Credentials, error) { + path, err := CredentialsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("failed to read credentials file %s: %w", path, err) + } + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials file %s: %w", path, err) + } + return &creds, nil +} + +// SaveCredentials writes credentials atomically to ~/.jcode/cloud.json with +// owner-only permissions (file 0600, directory 0700), mirroring the pattern +// used by config.saveConfig. +func SaveCredentials(creds *Credentials) error { + path, err := CredentialsPath() + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create config directory %s: %w", dir, err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("failed to secure config directory %s: %w", dir, err) + } + + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + + tmp, err := os.CreateTemp(dir, "."+credentialsFile+".tmp-*") + if err != nil { + return fmt.Errorf("failed to create temporary credentials file in %s: %w", dir, err) + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + if tmpPath != "" { + _ = os.Remove(tmpPath) + } + }() + + // The file carries the device token and private identity key; keep it + // owner-only regardless of CreateTemp's own defaults. + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("failed to secure temporary credentials file %s: %w", tmpPath, err) + } + if n, err := tmp.Write(data); err != nil { + return fmt.Errorf("failed to write temporary credentials file %s: %w", tmpPath, err) + } else if n != len(data) { + return fmt.Errorf("failed to write temporary credentials file %s: %w", tmpPath, io.ErrShortWrite) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("failed to sync temporary credentials file %s: %w", tmpPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temporary credentials file %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to replace credentials file %s: %w", path, err) + } + tmpPath = "" + return nil +} + +// DeleteCredentials removes ~/.jcode/cloud.json. A missing file is not an +// error (already logged out). +func DeleteCredentials() error { + path, err := CredentialsPath() + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to remove credentials file %s: %w", path, err) + } + return nil +} diff --git a/internal/cloud/crypto.go b/internal/cloud/crypto.go new file mode 100644 index 00000000..7eda9881 --- /dev/null +++ b/internal/cloud/crypto.go @@ -0,0 +1,501 @@ +// crypto.go implements the M5 E2E encryption layer (定稿契约,勿改 wire 格式): +// +// - CEK: one 32-byte AES-256-GCM key per account, stored base64 in +// ~/.jcode/cloud.json (`cek`, generation in `key_gen`). Lazily generated +// on first need (connector start / `jcode cloud` commands), atomically +// written back, cached in-process. +// - Envelope: {"enc":"aes-256-gcm","key_gen":N,"nonce":"","ct":""}. +// Sealed fields: uplink events/ephemeral payload, sessions upsert meta, +// ack result; downlink command payload. Grey rule: a JSON object payload +// with a string `enc` field is treated as an envelope and decrypted; +// anything else is read as plaintext (M3/M4 compatibility). +// - Pairing wrap (ECIES/P-256): ephemeral P-256 key pair → +// ECDH(ephemeral, requester pubkey) → HKDF-SHA256(shared, salt=nil, +// info="jcode-device-cek") → 32B wrap key → AES-256-GCM over +// {"cek":"","key_gen":N}. +// - Recovery: the CEK doubles as 256 bits of BIP39 entropy → 24-word phrase. +package cloud + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "strings" + "sync" + + "golang.org/x/crypto/hkdf" + + "github.com/tyler-smith/go-bip39" +) + +// envelopeAlg is the `enc` marker of a sealed payload. +const envelopeAlg = "aes-256-gcm" + +// cekSize is the CEK length in bytes (AES-256). +const cekSize = 32 + +// hkdfInfo is the HKDF-SHA256 info string for pairing wrap-key derivation. +const hkdfInfo = "jcode-device-cek" + +// Envelope is the sealed-payload wire format. +type Envelope struct { + Enc string `json:"enc"` + KeyGen int `json:"key_gen"` + Nonce string `json:"nonce"` // base64, 12 bytes + CT string `json:"ct"` // base64 ciphertext +} + +// EnvelopeCipher seals/opens payloads with one CEK generation. +type EnvelopeCipher struct { + key []byte + keyGen int + aead cipher.AEAD +} + +// NewEnvelopeCipher builds a cipher from a raw 32-byte CEK and its generation. +func NewEnvelopeCipher(cek []byte, keyGen int) (*EnvelopeCipher, error) { + if len(cek) != cekSize { + return nil, fmt.Errorf("CEK must be %d bytes, got %d", cekSize, len(cek)) + } + if keyGen < 1 { + return nil, fmt.Errorf("key_gen must be >= 1, got %d", keyGen) + } + aead, err := newAEAD(cek) + if err != nil { + return nil, err + } + key := make([]byte, cekSize) + copy(key, cek) + return &EnvelopeCipher{key: key, keyGen: keyGen, aead: aead}, nil +} + +func newAEAD(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("gcm: %w", err) + } + return aead, nil +} + +// KeyGen reports the CEK generation this cipher seals with. +func (e *EnvelopeCipher) KeyGen() int { return e.keyGen } + +// CEK returns a copy of the raw CEK bytes. +func (e *EnvelopeCipher) CEK() []byte { + out := make([]byte, len(e.key)) + copy(out, e.key) + return out +} + +// Seal encrypts plaintext into the envelope wire format (as JSON). +func (e *EnvelopeCipher) Seal(plaintext []byte) (json.RawMessage, error) { + nonce := make([]byte, e.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("seal: nonce: %w", err) + } + env := Envelope{ + Enc: envelopeAlg, + KeyGen: e.keyGen, + Nonce: base64.StdEncoding.EncodeToString(nonce), + CT: base64.StdEncoding.EncodeToString(e.aead.Seal(nil, nonce, plaintext, nil)), + } + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("seal: marshal envelope: %w", err) + } + return json.RawMessage(data), nil +} + +// IsEnvelope applies the grey-scale detection rule: raw is a JSON object with +// a string `enc` field. +func IsEnvelope(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var probe struct { + Enc string `json:"enc"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return false + } + return probe.Enc != "" +} + +// Open decrypts an envelope payload. The caller must have applied IsEnvelope +// first (or use OpenMaybe); passing plaintext here is an error. +func (e *EnvelopeCipher) Open(raw json.RawMessage) ([]byte, error) { + var env Envelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("open: parse envelope: %w", err) + } + if env.Enc != envelopeAlg { + return nil, fmt.Errorf("open: unsupported enc %q", env.Enc) + } + nonce, err := base64.StdEncoding.DecodeString(env.Nonce) + if err != nil { + return nil, fmt.Errorf("open: nonce: %w", err) + } + ct, err := base64.StdEncoding.DecodeString(env.CT) + if err != nil { + return nil, fmt.Errorf("open: ct: %w", err) + } + plain, err := e.aead.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("open: decrypt failed (wrong key or corrupted data): %w", err) + } + return plain, nil +} + +// OpenMaybe decrypts raw when it is an envelope, otherwise returns it +// unchanged (plaintext grey-scale passthrough). The boolean reports whether +// decryption happened. +func (e *EnvelopeCipher) OpenMaybe(raw json.RawMessage) ([]byte, bool, error) { + if !IsEnvelope(raw) { + return raw, false, nil + } + plain, err := e.Open(raw) + if err != nil { + return nil, true, err + } + return plain, true, nil +} + +// --- CEK lifecycle (lazy generate + atomic write-back + in-process cache) --- + +var cekCache struct { + mu sync.Mutex + cipher *EnvelopeCipher +} + +// ResetCEKCache clears the in-process CEK cache. Called on logout and by +// tests that swap the credentials file. +func ResetCEKCache() { + cekCache.mu.Lock() + defer cekCache.mu.Unlock() + cekCache.cipher = nil +} + +// GenerateCEK returns a fresh random 32-byte CEK. +func GenerateCEK() ([]byte, error) { + cek := make([]byte, cekSize) + if _, err := io.ReadFull(rand.Reader, cek); err != nil { + return nil, fmt.Errorf("generate CEK: %w", err) + } + return cek, nil +} + +// EnsureCEK returns the account CEK cipher, lazily generating and persisting +// the CEK on first use (atomic write-back to ~/.jcode/cloud.json). The cipher +// is cached in-process afterwards. Requires the device to be logged in. +func EnsureCEK() (*EnvelopeCipher, error) { + cekCache.mu.Lock() + defer cekCache.mu.Unlock() + if cekCache.cipher != nil { + return cekCache.cipher, nil + } + creds, err := LoadCredentials() + if err != nil { + return nil, err + } + if creds == nil { + return nil, errors.New("not logged in: run `jcode login` first") + } + cipher, changed, err := ensureCEKLocked(creds) + if err != nil { + return nil, err + } + if changed { + if err := SaveCredentials(creds); err != nil { + return nil, fmt.Errorf("persist generated CEK: %w", err) + } + } + cekCache.cipher = cipher + return cipher, nil +} + +// ensureCEKLocked builds the cipher from creds, generating a CEK into creds +// when missing. changed reports whether creds was mutated (caller persists). +func ensureCEKLocked(creds *Credentials) (cipher *EnvelopeCipher, changed bool, err error) { + if creds.KeyGen < 1 { + creds.KeyGen = 1 + changed = true + } + if creds.CEK == "" { + cek, err := GenerateCEK() + if err != nil { + return nil, false, err + } + creds.CEK = base64.StdEncoding.EncodeToString(cek) + return mustCipher(cek, creds.KeyGen), true, nil + } + cek, err := base64.StdEncoding.DecodeString(creds.CEK) + if err != nil { + return nil, false, fmt.Errorf("decode stored CEK: %w", err) + } + c, err := NewEnvelopeCipher(cek, creds.KeyGen) + if err != nil { + return nil, false, err + } + return c, changed, nil +} + +func mustCipher(cek []byte, keyGen int) *EnvelopeCipher { + c, err := NewEnvelopeCipher(cek, keyGen) + if err != nil { // cek is freshly generated with the right size + panic(err) + } + return c +} + +// RotateCEK generates a new CEK at generation key_gen+1, persists it, and +// updates the in-process cache. Already-paired clients keep the old CEK and +// must re-pair to read new content. +func RotateCEK() (*EnvelopeCipher, error) { + cekCache.mu.Lock() + defer cekCache.mu.Unlock() + creds, err := LoadCredentials() + if err != nil { + return nil, err + } + if creds == nil { + return nil, errors.New("not logged in: run `jcode login` first") + } + keyGen := creds.KeyGen + 1 + if keyGen < 1 { + keyGen = 1 + } + cek, err := GenerateCEK() + if err != nil { + return nil, err + } + creds.CEK = base64.StdEncoding.EncodeToString(cek) + creds.KeyGen = keyGen + if err := SaveCredentials(creds); err != nil { + return nil, fmt.Errorf("persist rotated CEK: %w", err) + } + cipher := mustCipher(cek, keyGen) + cekCache.cipher = cipher + return cipher, nil +} + +// RecoverCEK rebuilds the CEK from a 24-word recovery phrase and persists it +// (keeping the stored key_gen, default 1), overwriting any existing CEK. The +// caller is responsible for warning and confirmation before calling. +func RecoverCEK(phrase string) (*EnvelopeCipher, error) { + cek, err := CEKFromPhrase(phrase) + if err != nil { + return nil, err + } + cekCache.mu.Lock() + defer cekCache.mu.Unlock() + creds, err := LoadCredentials() + if err != nil { + return nil, err + } + if creds == nil { + return nil, errors.New("not logged in: run `jcode login` first") + } + if creds.KeyGen < 1 { + creds.KeyGen = 1 + } + creds.CEK = base64.StdEncoding.EncodeToString(cek) + if err := SaveCredentials(creds); err != nil { + return nil, fmt.Errorf("persist recovered CEK: %w", err) + } + cipher := mustCipher(cek, creds.KeyGen) + cekCache.cipher = cipher + return cipher, nil +} + +// --- recovery phrase (BIP39: the 32-byte CEK is the 256-bit entropy) --- + +// CEKToPhrase encodes a 32-byte CEK as its 24-word BIP39 recovery phrase. +func CEKToPhrase(cek []byte) (string, error) { + if len(cek) != cekSize { + return "", fmt.Errorf("CEK must be %d bytes for a 24-word phrase, got %d", cekSize, len(cek)) + } + phrase, err := bip39.NewMnemonic(cek) + if err != nil { + return "", fmt.Errorf("encode recovery phrase: %w", err) + } + return phrase, nil +} + +// CEKFromPhrase decodes a 24-word BIP39 recovery phrase back into the CEK. +func CEKFromPhrase(phrase string) ([]byte, error) { + phrase = strings.Join(strings.Fields(strings.TrimSpace(phrase)), " ") + if !bip39.IsMnemonicValid(phrase) { + return nil, fmt.Errorf("invalid recovery phrase (want 24 BIP39 words)") + } + cek, err := bip39.EntropyFromMnemonic(phrase) + if err != nil { + return nil, fmt.Errorf("decode recovery phrase: %w", err) + } + if len(cek) != cekSize { + return nil, fmt.Errorf("recovery phrase yields %d bytes, want a %d-byte CEK (24 words)", len(cek), cekSize) + } + return cek, nil +} + +// --- pairing wrap (ECIES / P-256) --- + +// CEKWrap is the ECIES-wrapped CEK sent back to an approved pairing requester. +type CEKWrap struct { + EphemeralPubKey string `json:"ephemeral_pubkey"` // base64 SPKI DER, P-256 + Nonce string `json:"nonce"` // base64, 12 bytes + CT string `json:"ct"` // base64 ciphertext +} + +// wrappedCEKPayload is the plaintext inside a CEKWrap. +type wrappedCEKPayload struct { + CEK string `json:"cek"` + KeyGen int `json:"key_gen"` +} + +// parseP256SPKI decodes a base64 SPKI DER P-256 public key into an ECDH key. +func parseP256SPKI(b64 string) (*ecdh.PublicKey, error) { + der, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("pubkey base64: %w", err) + } + pubAny, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("pubkey SPKI: %w", err) + } + ecdsaPub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return nil, fmt.Errorf("pubkey is %T, want an ECDSA P-256 key", pubAny) + } + if ecdsaPub.Curve != elliptic.P256() { + return nil, fmt.Errorf("pubkey curve is not P-256") + } + ecdhPub, err := ecdsaPub.ECDH() + if err != nil { + return nil, fmt.Errorf("pubkey ECDH: %w", err) + } + return ecdhPub, nil +} + +// ecdhPubToSPKI converts an ECDH P-256 public key to SPKI DER bytes. The +// ecdh key's Bytes() is the SEC 1 uncompressed point (0x04 || X || Y). +func ecdhPubToSPKI(pub *ecdh.PublicKey) ([]byte, error) { + raw := pub.Bytes() + if len(raw) != 65 || raw[0] != 0x04 { + return nil, fmt.Errorf("unexpected P-256 point encoding (%d bytes)", len(raw)) + } + x := new(big.Int).SetBytes(raw[1:33]) + y := new(big.Int).SetBytes(raw[33:65]) + return x509.MarshalPKIXPublicKey(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}) +} + +// deriveWrapKey runs ECDH + HKDF-SHA256(shared, salt=nil, info="jcode-device-cek"). +func deriveWrapKey(priv *ecdh.PrivateKey, pub *ecdh.PublicKey) ([]byte, error) { + shared, err := priv.ECDH(pub) + if err != nil { + return nil, fmt.Errorf("ECDH: %w", err) + } + hk := hkdf.New(sha256.New, shared, nil, []byte(hkdfInfo)) + key := make([]byte, cekSize) + if _, err := io.ReadFull(hk, key); err != nil { + return nil, fmt.Errorf("HKDF: %w", err) + } + return key, nil +} + +// WrapCEK encrypts the CEK for a pairing requester: requesterPubKeyB64 is the +// requester's P-256 public key (base64 SPKI DER, as generated by WebCrypto). +func WrapCEK(requesterPubKeyB64 string, cek []byte, keyGen int) (*CEKWrap, error) { + if len(cek) != cekSize { + return nil, fmt.Errorf("CEK must be %d bytes, got %d", cekSize, len(cek)) + } + requesterPub, err := parseP256SPKI(requesterPubKeyB64) + if err != nil { + return nil, fmt.Errorf("requester pubkey: %w", err) + } + eph, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("ephemeral key: %w", err) + } + wrapKey, err := deriveWrapKey(eph, requesterPub) + if err != nil { + return nil, err + } + aead, err := newAEAD(wrapKey) + if err != nil { + return nil, err + } + plain, err := json.Marshal(wrappedCEKPayload{ + CEK: base64.StdEncoding.EncodeToString(cek), + KeyGen: keyGen, + }) + if err != nil { + return nil, fmt.Errorf("marshal wrap payload: %w", err) + } + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("wrap nonce: %w", err) + } + ephDER, err := ecdhPubToSPKI(eph.PublicKey()) + if err != nil { + return nil, err + } + return &CEKWrap{ + EphemeralPubKey: base64.StdEncoding.EncodeToString(ephDER), + Nonce: base64.StdEncoding.EncodeToString(nonce), + CT: base64.StdEncoding.EncodeToString(aead.Seal(nil, nonce, plain, nil)), + }, nil +} + +// UnwrapCEK reverses WrapCEK with the requester's P-256 private key. The +// device side never calls this in production — it exists for the requester +// side and for round-trip tests. +func UnwrapCEK(wrap *CEKWrap, requesterPriv *ecdh.PrivateKey) (cek []byte, keyGen int, err error) { + ephPub, err := parseP256SPKI(wrap.EphemeralPubKey) + if err != nil { + return nil, 0, fmt.Errorf("ephemeral pubkey: %w", err) + } + wrapKey, err := deriveWrapKey(requesterPriv, ephPub) + if err != nil { + return nil, 0, err + } + aead, err := newAEAD(wrapKey) + if err != nil { + return nil, 0, err + } + nonce, err := base64.StdEncoding.DecodeString(wrap.Nonce) + if err != nil { + return nil, 0, fmt.Errorf("wrap nonce: %w", err) + } + ct, err := base64.StdEncoding.DecodeString(wrap.CT) + if err != nil { + return nil, 0, fmt.Errorf("wrap ct: %w", err) + } + plain, err := aead.Open(nil, nonce, ct, nil) + if err != nil { + return nil, 0, fmt.Errorf("unwrap failed (wrong key or corrupted wrap): %w", err) + } + var payload wrappedCEKPayload + if err := json.Unmarshal(plain, &payload); err != nil { + return nil, 0, fmt.Errorf("unwrap payload: %w", err) + } + cek, err = base64.StdEncoding.DecodeString(payload.CEK) + if err != nil { + return nil, 0, fmt.Errorf("unwrap cek: %w", err) + } + return cek, payload.KeyGen, nil +} diff --git a/internal/cloud/crypto_test.go b/internal/cloud/crypto_test.go new file mode 100644 index 00000000..c86f84d6 --- /dev/null +++ b/internal/cloud/crypto_test.go @@ -0,0 +1,448 @@ +package cloud + +import ( + "bytes" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "os" + "strings" + "testing" +) + +func testCipher(t *testing.T) *EnvelopeCipher { + t.Helper() + c, err := NewEnvelopeCipher(bytes.Repeat([]byte{0x42}, cekSize), 1) + if err != nil { + t.Fatalf("NewEnvelopeCipher: %v", err) + } + return c +} + +func TestEnvelopeRoundTrip(t *testing.T) { + c := testCipher(t) + plaintext := json.RawMessage(`{"type":"agent_message","task_id":"s1","data":{"text":"hello"}}`) + + sealed, err := c.Seal(plaintext) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if !IsEnvelope(sealed) { + t.Fatalf("sealed payload not detected as envelope: %s", sealed) + } + var env Envelope + if err := json.Unmarshal(sealed, &env); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if env.Enc != envelopeAlg || env.KeyGen != 1 || env.Nonce == "" || env.CT == "" { + t.Fatalf("envelope fields = %+v", env) + } + nonce, err := base64.StdEncoding.DecodeString(env.Nonce) + if err != nil || len(nonce) != 12 { + t.Fatalf("nonce must be 12 bytes base64, got %d bytes (err=%v)", len(nonce), err) + } + + opened, err := c.Open(sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(opened, plaintext) { + t.Fatalf("Open = %s, want %s", opened, plaintext) + } +} + +func TestGreyDetectionPlaintextPassthrough(t *testing.T) { + c := testCipher(t) + // M3/M4-era plaintext payloads: no `enc` field, or `enc` present but not a + // string — both must be read as-is. + plaintexts := []json.RawMessage{ + json.RawMessage(`{"type":"agent_text","data":{"text":"hi"}}`), + json.RawMessage(`{"enc":123,"foo":1}`), // non-string enc is NOT an envelope + json.RawMessage(`"just a string"`), // not even an object + json.RawMessage(`[1,2,3]`), // array + } + for _, p := range plaintexts { + if IsEnvelope(p) { + t.Errorf("IsEnvelope(%s) = true, want false", p) + } + got, wasEncrypted, err := c.OpenMaybe(p) + if err != nil || wasEncrypted || !bytes.Equal(got, p) { + t.Errorf("OpenMaybe(%s) = %s, %v, %v; want passthrough", p, got, wasEncrypted, err) + } + } + // And a real envelope IS detected. + sealed, err := c.Seal([]byte(`{"x":1}`)) + if err != nil { + t.Fatal(err) + } + if _, wasEncrypted, err := c.OpenMaybe(sealed); err != nil || !wasEncrypted { + t.Errorf("OpenMaybe(envelope) wasEncrypted=%v err=%v, want true, nil", wasEncrypted, err) + } +} + +func TestOpenWrongKeyFails(t *testing.T) { + c1 := testCipher(t) + c2, err := NewEnvelopeCipher(bytes.Repeat([]byte{0x99}, cekSize), 1) + if err != nil { + t.Fatal(err) + } + sealed, err := c1.Seal([]byte("secret")) + if err != nil { + t.Fatal(err) + } + if _, err := c2.Open(sealed); err == nil { + t.Fatal("Open with the wrong key succeeded, want decryption failure") + } +} + +func TestNewEnvelopeCipherRejectsBadKey(t *testing.T) { + if _, err := NewEnvelopeCipher([]byte("short"), 1); err == nil { + t.Fatal("16-byte CEK accepted, want error") + } + if _, err := NewEnvelopeCipher(bytes.Repeat([]byte{1}, cekSize), 0); err == nil { + t.Fatal("key_gen=0 accepted, want error") + } +} + +// TestCrossEndVector pins the WebCrypto-interop vector shared with the console +// side (/jcode-cloud-relay/shared/test-vectors.json): fixed CEK + nonce must +// reproduce the exact ciphertext, and Open must recover the plaintext. +func TestCrossEndVector(t *testing.T) { + const ( + cekB64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" // bytes 0x00..0x1f + nonceB64 = "amNvZGUtcmVsYXkh" // "jcode-relay!" + ctB64 = "6TAl4JwmlZHU7dpwXh4NAzD72pWPn88OJKMWYJf1O1ztH6FrtOLSOHHb1s6UjkOtRs773hZQx7hi4bssDGxj6BdewORp7BsiBmijVdmnCNehQws=" + ) + plaintext := `{"type":"agent_message","task_id":"s1","data":{"text":"hello E2E"}}` + + cek, err := base64.StdEncoding.DecodeString(cekB64) + if err != nil { + t.Fatal(err) + } + c, err := NewEnvelopeCipher(cek, 1) + if err != nil { + t.Fatal(err) + } + + // Decrypt the pinned envelope. + envelope := json.RawMessage(`{"enc":"aes-256-gcm","key_gen":1,"nonce":"` + nonceB64 + `","ct":"` + ctB64 + `"}`) + opened, err := c.Open(envelope) + if err != nil { + t.Fatalf("Open(pinned vector): %v", err) + } + if string(opened) != plaintext { + t.Fatalf("Open(pinned vector) = %s, want %s", opened, plaintext) + } + + // Re-encrypt with the same key + nonce and require byte-identical ct. + nonce, _ := base64.StdEncoding.DecodeString(nonceB64) + ct := c.aead.Seal(nil, nonce, []byte(plaintext), nil) + if base64.StdEncoding.EncodeToString(ct) != ctB64 { + t.Fatalf("re-encryption ct = %s, want pinned %s", base64.StdEncoding.EncodeToString(ct), ctB64) + } +} + +// TestSharedVectorsFile reads the cross-implementation vectors shared with the +// console side (jcode-cloud-relay/shared/test-vectors.json: one Go-produced, +// one WebCrypto-produced) and requires every envelope to Open to exactly its +// recorded plaintext. Skipped when the checkout lacks the sibling directory. +func TestSharedVectorsFile(t *testing.T) { + candidates := []string{ + "../../../jcode-cloud-relay/shared/test-vectors.json", + "../../../../jcode-cloud-relay/shared/test-vectors.json", + } + var raw []byte + for _, p := range candidates { + if b, err := os.ReadFile(p); err == nil { + raw = b + break + } + } + if raw == nil { + t.Skip("jcode-cloud-relay/shared/test-vectors.json not found next to the jcode repo") + } + var file struct { + Vectors []struct { + Origin string `json:"origin"` + CekB64 string `json:"cek_b64"` + Plaintext string `json:"plaintext"` + Envelope struct { + Enc string `json:"enc"` + KeyGen int `json:"key_gen"` + Nonce string `json:"nonce"` + Ct string `json:"ct"` + } `json:"envelope"` + } `json:"vectors"` + } + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatal(err) + } + if len(file.Vectors) == 0 { + t.Fatal("shared vectors file is empty") + } + for _, v := range file.Vectors { + t.Run(v.Origin, func(t *testing.T) { + cek, err := base64.StdEncoding.DecodeString(v.CekB64) + if err != nil { + t.Fatal(err) + } + c, err := NewEnvelopeCipher(cek, v.Envelope.KeyGen) + if err != nil { + t.Fatal(err) + } + env, _ := json.Marshal(v.Envelope) + opened, err := c.Open(env) + if err != nil { + t.Fatalf("Open(%s vector): %v", v.Origin, err) + } + if string(opened) != v.Plaintext { + t.Fatalf("Open(%s vector) = %s, want %s", v.Origin, opened, v.Plaintext) + } + }) + } +} + +// --- ECIES pairing wrap --- + +// p256Requester generates a requester key pair the way the browser does: +// returns the SPKI DER base64 pubkey (what it sends) and the ECDH private key +// (what it keeps). +func p256Requester(t *testing.T) (pubB64 string, priv *ecdh.PrivateKey) { + t.Helper() + ecdsaPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(&ecdsaPriv.PublicKey) + if err != nil { + t.Fatal(err) + } + priv, err = ecdsaPriv.ECDH() + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(der), priv +} + +func TestWrapUnwrapRoundTrip(t *testing.T) { + pubB64, priv := p256Requester(t) + cek := bytes.Repeat([]byte{0x7a}, cekSize) + + wrap, err := WrapCEK(pubB64, cek, 3) + if err != nil { + t.Fatalf("WrapCEK: %v", err) + } + if wrap.EphemeralPubKey == "" || wrap.Nonce == "" || wrap.CT == "" { + t.Fatalf("incomplete wrap: %+v", wrap) + } + // Ephemeral pubkey must itself be a valid P-256 SPKI. + if _, err := parseP256SPKI(wrap.EphemeralPubKey); err != nil { + t.Fatalf("ephemeral pubkey not parseable SPKI: %v", err) + } + + gotCEK, gotGen, err := UnwrapCEK(wrap, priv) + if err != nil { + t.Fatalf("UnwrapCEK: %v", err) + } + if !bytes.Equal(gotCEK, cek) || gotGen != 3 { + t.Fatalf("UnwrapCEK = (%x, %d), want (%x, 3)", gotCEK, gotGen, cek) + } +} + +func TestUnwrapWrongKeyFails(t *testing.T) { + pubB64, _ := p256Requester(t) + _, otherPriv := p256Requester(t) + wrap, err := WrapCEK(pubB64, bytes.Repeat([]byte{0x7a}, cekSize), 1) + if err != nil { + t.Fatal(err) + } + if _, _, err := UnwrapCEK(wrap, otherPriv); err == nil { + t.Fatal("UnwrapCEK with the wrong private key succeeded, want failure") + } +} + +func TestWrapCEKRejectsBadPubKey(t *testing.T) { + if _, err := WrapCEK("!!!not-base64!!!", bytes.Repeat([]byte{1}, cekSize), 1); err == nil { + t.Fatal("WrapCEK accepted garbage pubkey, want error") + } + if _, err := WrapCEK(base64.StdEncoding.EncodeToString([]byte("not an spki")), bytes.Repeat([]byte{1}, cekSize), 1); err == nil { + t.Fatal("WrapCEK accepted non-SPKI pubkey, want error") + } +} + +// --- recovery phrase --- + +func TestPhraseRoundTrip(t *testing.T) { + cek, err := GenerateCEK() + if err != nil { + t.Fatal(err) + } + phrase, err := CEKToPhrase(cek) + if err != nil { + t.Fatalf("CEKToPhrase: %v", err) + } + if words := strings.Fields(phrase); len(words) != 24 { + t.Fatalf("phrase has %d words, want 24", len(words)) + } + got, err := CEKFromPhrase(phrase) + if err != nil { + t.Fatalf("CEKFromPhrase: %v", err) + } + if !bytes.Equal(got, cek) { + t.Fatal("CEKFromPhrase(CEKToPhrase(cek)) != cek") + } + // Extra whitespace / newlines are tolerated. + got, err = CEKFromPhrase(" " + strings.ReplaceAll(phrase, " ", " \n ") + " ") + if err != nil || !bytes.Equal(got, cek) { + t.Fatalf("CEKFromPhrase with extra whitespace = %v, %v", got, err) + } +} + +func TestPhraseRejectsInvalid(t *testing.T) { + if _, err := CEKFromPhrase("foo bar baz"); err == nil { + t.Fatal("invalid phrase accepted") + } + if _, err := CEKFromPhrase(""); err == nil { + t.Fatal("empty phrase accepted") + } + if _, err := CEKToPhrase([]byte("short")); err == nil { + t.Fatal("CEKToPhrase accepted non-32-byte key") + } +} + +// --- CEK lifecycle (lazy generate + persist + cache) --- + +func setupHome(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + ResetCEKCache() + t.Cleanup(ResetCEKCache) +} + +func writeTestCreds(t *testing.T, creds *Credentials) { + t.Helper() + if err := SaveCredentials(creds); err != nil { + t.Fatalf("SaveCredentials: %v", err) + } +} + +func TestEnsureCEKLazyGeneratePersistCache(t *testing.T) { + setupHome(t) + writeTestCreds(t, &Credentials{CloudURL: "https://c.example", DeviceID: "d1", DeviceToken: "tok", KeyGen: 1}) + + c1, err := EnsureCEK() + if err != nil { + t.Fatalf("EnsureCEK: %v", err) + } + if c1.KeyGen() != 1 { + t.Fatalf("key_gen = %d, want 1", c1.KeyGen()) + } + // Persisted back to disk. + creds, err := LoadCredentials() + if err != nil || creds == nil { + t.Fatalf("LoadCredentials = %+v, %v", creds, err) + } + if creds.CEK == "" { + t.Fatal("CEK not persisted to cloud.json") + } + stored, err := base64.StdEncoding.DecodeString(creds.CEK) + if err != nil || !bytes.Equal(stored, c1.CEK()) { + t.Fatalf("stored CEK does not match the cached cipher") + } + // In-process cache: same pointer, and survives a cleared-file reload. + c2, err := EnsureCEK() + if err != nil || c2 != c1 { + t.Fatalf("EnsureCEK cache = %p, %v; want %p", c2, err, c1) + } + // Fresh process (cache cleared) loads the same CEK from disk. + ResetCEKCache() + c3, err := EnsureCEK() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(c3.CEK(), c1.CEK()) { + t.Fatal("reloaded CEK differs from the persisted one") + } +} + +func TestEnsureCEKRequiresLogin(t *testing.T) { + setupHome(t) + if _, err := EnsureCEK(); err == nil { + t.Fatal("EnsureCEK without credentials succeeded, want error") + } +} + +func TestEnsureCEKOldFileWithoutCEKStaysPlainUntilGenerated(t *testing.T) { + setupHome(t) + // Pre-M5 credentials file: no cek field at all. + writeTestCreds(t, &Credentials{CloudURL: "https://c.example", DeviceID: "d1", DeviceToken: "tok", KeyGen: 1}) + creds, err := LoadCredentials() + if err != nil || creds == nil { + t.Fatal(err) + } + if creds.CEK != "" { + t.Fatal("old credentials file must load with empty CEK (uninitialized)") + } +} + +func TestRotateCEK(t *testing.T) { + setupHome(t) + writeTestCreds(t, &Credentials{CloudURL: "https://c.example", DeviceID: "d1", DeviceToken: "tok", KeyGen: 1}) + old, err := EnsureCEK() + if err != nil { + t.Fatal(err) + } + rotated, err := RotateCEK() + if err != nil { + t.Fatalf("RotateCEK: %v", err) + } + if rotated.KeyGen() != 2 { + t.Fatalf("key_gen = %d, want 2", rotated.KeyGen()) + } + if bytes.Equal(rotated.CEK(), old.CEK()) { + t.Fatal("rotated CEK equals the old one") + } + creds, _ := LoadCredentials() + if creds.KeyGen != 2 { + t.Fatalf("persisted key_gen = %d, want 2", creds.KeyGen) + } + // The old key no longer opens new envelopes; the new one does. + sealed, err := rotated.Seal([]byte("post-rotation")) + if err != nil { + t.Fatal(err) + } + if _, err := old.Open(sealed); err == nil { + t.Fatal("old key opened a post-rotation envelope") + } +} + +func TestRecoverCEK(t *testing.T) { + setupHome(t) + writeTestCreds(t, &Credentials{CloudURL: "https://c.example", DeviceID: "d1", DeviceToken: "tok", KeyGen: 1}) + cipher, err := EnsureCEK() + if err != nil { + t.Fatal(err) + } + phrase, err := CEKToPhrase(cipher.CEK()) + if err != nil { + t.Fatal(err) + } + // Simulate total loss + recovery: rotate away, then recover from phrase. + if _, err := RotateCEK(); err != nil { + t.Fatal(err) + } + recovered, err := RecoverCEK(phrase) + if err != nil { + t.Fatalf("RecoverCEK: %v", err) + } + if !bytes.Equal(recovered.CEK(), cipher.CEK()) { + t.Fatal("recovered CEK differs from the phrase's CEK") + } + if recovered.KeyGen() != 2 { // keeps the stored generation + t.Fatalf("recovered key_gen = %d, want 2 (stored generation kept)", recovered.KeyGen()) + } +} diff --git a/internal/cloud/crypto_wiring_test.go b/internal/cloud/crypto_wiring_test.go new file mode 100644 index 00000000..a47b27ea --- /dev/null +++ b/internal/cloud/crypto_wiring_test.go @@ -0,0 +1,268 @@ +// crypto_wiring_test.go covers the M5 encryption wiring in the connector: +// uplink sealing (durable events, ephemeral events, sessions meta, ack +// result) and downlink opening (command payloads), each in both the +// encrypted and the plaintext grey path. +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +func wiringCipher(t *testing.T) *EnvelopeCipher { + t.Helper() + c, err := NewEnvelopeCipher(bytes.Repeat([]byte{0x42}, cekSize), 1) + if err != nil { + t.Fatal(err) + } + return c +} + +// withCipher injects a cipher into a test connector (bypassing the lazy +// EnsureCEK, which would touch the real credentials file). +func withCipher(conn *Connector, cipher *EnvelopeCipher) *Connector { + conn.cipher = cipher + return conn +} + +func TestDurableEventSealedWhenCipherActive(t *testing.T) { + mock := newMockCloud() + srv := httptest.NewServer(mock.handler()) + defer srv.Close() + conn := withCipher(newTestConnector(t, srv.URL, "http://127.0.0.1:1"), wiringCipher(t)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + batcher := newEventBatcher(conn) + go batcher.run(ctx) + + msg := `{"type":"user_message","task_id":"s1","data":{"text":"secret hello"}}` + conn.handleWSEvent(ctx, batcher, []byte(msg)) + + waitFor(t, func() bool { return len(mock.allEvents()) == 1 }, "sealed durable upload") + got := mock.allEvents()[0] + if !IsEnvelope(got.Payload) { + t.Fatalf("durable payload is not an envelope: %s", got.Payload) + } + plain, err := conn.cipher.Open(got.Payload) + if err != nil { + t.Fatalf("Open: %v", err) + } + if string(plain) != msg { + t.Fatalf("decrypted payload = %s, want %s", plain, msg) + } +} + +func TestDurableEventPlaintextWithoutCipher(t *testing.T) { + mock := newMockCloud() + srv := httptest.NewServer(mock.handler()) + defer srv.Close() + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") // no cipher: grey path + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + batcher := newEventBatcher(conn) + go batcher.run(ctx) + + msg := `{"type":"user_message","task_id":"s1","data":{"text":"plain hello"}}` + conn.handleWSEvent(ctx, batcher, []byte(msg)) + + waitFor(t, func() bool { return len(mock.allEvents()) == 1 }, "plaintext durable upload") + got := mock.allEvents()[0] + if IsEnvelope(got.Payload) { + t.Fatalf("plaintext-path payload unexpectedly sealed: %s", got.Payload) + } + if string(got.Payload) != msg { + t.Fatalf("payload = %s, want %s", got.Payload, msg) + } +} + +func TestEphemeralSealedWhenCipherActive(t *testing.T) { + mock := newMockCloud() + srv := httptest.NewServer(mock.handler()) + defer srv.Close() + conn := withCipher(newTestConnector(t, srv.URL, "http://127.0.0.1:1"), wiringCipher(t)) + + ctx := context.Background() + batcher := newEventBatcher(conn) + msg := `{"type":"agent_text","task_id":"s1","data":{"text":"token"}}` + conn.handleWSEvent(ctx, batcher, []byte(msg)) + + waitFor(t, func() bool { return mock.ephemeralCount() == 1 }, "ephemeral upload") + mock.mu.Lock() + rec := mock.ephemeral[0] + mock.mu.Unlock() + if !IsEnvelope(rec.payload) { + t.Fatalf("ephemeral payload is not an envelope: %s", rec.payload) + } + plain, err := conn.cipher.Open(rec.payload) + if err != nil { + t.Fatal(err) + } + if string(plain) != msg { + t.Fatalf("decrypted ephemeral = %s, want %s", plain, msg) + } +} + +func TestSessionsMetaSealedWhenCipherActive(t *testing.T) { + conn := withCipher(newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1"), wiringCipher(t)) + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "proj": {{UUID: "s1", Title: "secret title", Status: "idle"}}, + }, nil + } + upserts, err := conn.collectSessions() + if err != nil { + t.Fatal(err) + } + if len(upserts) != 1 { + t.Fatalf("upserts = %d, want 1", len(upserts)) + } + if !IsEnvelope(upserts[0].Meta) { + t.Fatalf("meta is not an envelope: %s", upserts[0].Meta) + } + plain, err := conn.cipher.Open(upserts[0].Meta) + if err != nil { + t.Fatal(err) + } + var meta session.SessionMeta + if err := json.Unmarshal(plain, &meta); err != nil { + t.Fatal(err) + } + if meta.UUID != "s1" || meta.Title != "secret title" { + t.Fatalf("decrypted meta = %+v", meta) + } +} + +func TestSessionsMetaPlaintextWithoutCipher(t *testing.T) { + conn := newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{"proj": {{UUID: "s1"}}}, nil + } + upserts, err := conn.collectSessions() + if err != nil { + t.Fatal(err) + } + if IsEnvelope(upserts[0].Meta) { + t.Fatalf("plaintext-path meta unexpectedly sealed: %s", upserts[0].Meta) + } +} + +func TestAckResultSealedWhenCipherActive(t *testing.T) { + local, localSrv := newFakeLocal(t) + mock := newMockCloud() + cloudSrv := httptest.NewServer(mock.handler()) + defer cloudSrv.Close() + conn := withCipher(newTestConnector(t, cloudSrv.URL, localSrv.URL), wiringCipher(t)) + + cmd := DeviceCommand{ + ID: "cmd-enc-1", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{"text": "hi"}), + } + conn.executeAndAck(context.Background(), cmd) + + waitFor(t, func() bool { return mock.ackCount() == 1 }, "ack upload") + mock.mu.Lock() + ack := mock.acks["cmd-enc-1"] + mock.mu.Unlock() + if ack.Status != "ok" { + t.Fatalf("ack status = %q, result = %v", ack.Status, ack.Result) + } + // The result crossed the wire as an envelope; decrypt it. + raw, err := json.Marshal(ack.Result) + if err != nil { + t.Fatal(err) + } + if !IsEnvelope(raw) { + t.Fatalf("ack result is not an envelope: %s", raw) + } + plain, err := conn.cipher.Open(raw) + if err != nil { + t.Fatal(err) + } + var result map[string]string + if err := json.Unmarshal(plain, &result); err != nil { + t.Fatal(err) + } + if result["session_id"] != local.chatSessionID { + t.Fatalf("decrypted ack result = %v, want session_id %q", result, local.chatSessionID) + } +} + +func TestDownlinkEncryptedPayloadDecrypted(t *testing.T) { + local, localSrv := newFakeLocal(t) + mock := newMockCloud() + cloudSrv := httptest.NewServer(mock.handler()) + defer cloudSrv.Close() + cipher := wiringCipher(t) + conn := withCipher(newTestConnector(t, cloudSrv.URL, localSrv.URL), cipher) + + // The console seals the command payload with the account CEK. + sealed, err := cipher.Seal(mustPayload(t, map[string]any{"text": "encrypted hello", "channel": "console"})) + if err != nil { + t.Fatal(err) + } + cmd := DeviceCommand{ID: "cmd-enc-2", Kind: "chat.send", Payload: sealed} + conn.executeAndAck(context.Background(), cmd) + + waitFor(t, func() bool { return mock.ackCount() == 1 }, "ack upload") + local.mu.Lock() + if len(local.chatBodies) != 1 { + local.mu.Unlock() + t.Fatalf("local chat calls = %d, want 1", len(local.chatBodies)) + } + body := local.chatBodies[0] + local.mu.Unlock() + if body["message"] != "encrypted hello" || body["source"] != "console" { + t.Fatalf("local chat body = %v, want decrypted payload", body) + } + mock.mu.Lock() + status := mock.acks["cmd-enc-2"].Status + mock.mu.Unlock() + if status != "ok" { + t.Fatalf("ack status = %q, want ok", status) + } +} + +func TestDownlinkPlaintextAcceptedWithCipherActive(t *testing.T) { + local, localSrv := newFakeLocal(t) + conn := withCipher(newTestConnector(t, "http://127.0.0.1:1", localSrv.URL), wiringCipher(t)) + + // Grey rule: a plaintext (no `enc`) downlink payload still dispatches. + cmd := DeviceCommand{ID: "cmd-grey-1", Kind: "chat.send", Payload: mustPayload(t, map[string]any{"text": "plain still ok"})} + status, _ := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("plaintext command with active cipher = %q, want ok", status) + } + if body := local.chatBody(); body["message"] != "plain still ok" { + t.Fatalf("local chat body = %v", body) + } +} + +func TestDownlinkEnvelopeWithoutCipherRejected(t *testing.T) { + mock := newMockCloud() + cloudSrv := httptest.NewServer(mock.handler()) + defer cloudSrv.Close() + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") // no cipher + + sealed, err := wiringCipher(t).Seal(mustPayload(t, map[string]any{"text": "hi"})) + if err != nil { + t.Fatal(err) + } + cmd := DeviceCommand{ID: "cmd-enc-3", Kind: "chat.send", Payload: sealed} + conn.executeAndAck(context.Background(), cmd) + + waitFor(t, func() bool { return mock.ackCount() == 1 }, "error ack") + mock.mu.Lock() + ack := mock.acks["cmd-enc-3"] + mock.mu.Unlock() + if ack.Status != "error" { + t.Fatalf("ack status = %q, want error (encrypted command, no CEK)", ack.Status) + } +} diff --git a/internal/cloud/events.go b/internal/cloud/events.go new file mode 100644 index 00000000..00f2e442 --- /dev/null +++ b/internal/cloud/events.go @@ -0,0 +1,425 @@ +// events.go is the connector's uplink event path: it subscribes to the local +// web server's WS event stream (/api/ws), classifies each event as durable or +// ephemeral, assigns per-session seq numbers, and uploads — durable events in +// batches (persisted by the orchestrator for offline replay), ephemeral events +// fire-and-forget (SSE fanout only). +package cloud + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// eventDurability is the explicit durable/ephemeral classification of every WS +// event type emitted by internal/web (ws.go broadcast sites) and +// internal/handler (WebHandler emit sites). Kinds are the WS event `type` +// strings, uploaded as-is. +// +// Durable = complete message-level / state-change events: the orchestrator +// persists them and remote clients replay them after a reconnect. Ephemeral = +// token-level increments that only make sense live; they ride the SSE fanout +// and are never stored. +// +// Any type NOT listed here defaults to DURABLE — 宁可多存不可丢. +var eventDurability = map[string]bool{ + // --- durable: complete message-level events --- + "user_message": true, // user message (carries the channel source marker) + "agent_message": true, // full assistant message; never emitted by internal/web — the connector synthesizes it at agent_done from the accumulated agent_text deltas (see handleWSEvent) + "tool_call": true, // tool invocation (name + args) + "tool_result": true, // tool completion (output / error / denied) + "approval_request": true, // approval prompt awaiting a decision + "ask_user_request": true, // ask_user question awaiting an answer + // --- durable: session state changes --- + "agent_start": true, // run started (session busy) + "agent_done": true, // run finished / stopped / failed (also the "error" carrier) + "task_status": true, // idle/running flip (global envelope; task_id lives in data) + "todo_update": true, // todo list changed + "goal_update": true, // session goal changed + "session_reset": true, // session history reset + "mode_changed": true, // session mode switch (build/plan/full_access) + "model_changed": true, // session model switch + // --- durable: subagent lifecycle (done carries the final result) --- + "subagent_event": true, + // --- ephemeral: token-level increments (lossy by design) --- + "agent_text": false, // streaming assistant text chunk; the full message is replayable from the session file and the terminal state rides agent_done + "token_update": false, // cumulative token counters, fire after every LLM call + "subagent_progress": false, // intermediate subagent tool progress lines +} + +// isDurableEvent reports whether a WS event type is uploaded as a durable +// event. Unknown types default to durable. +func isDurableEvent(eventType string) bool { + durable, known := eventDurability[eventType] + return !known || durable +} + +// wsEvent mirrors web.WSEvent on the wire (the connector is a pure client and +// must not import internal/web). +type wsEvent struct { + Type string `json:"type"` + TaskID string `json:"task_id,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// seqAllocator hands out per-session monotonically increasing seq numbers +// (1-based). It is seeded from the server's per-session last_seq at startup / +// on every session upsert, and resynced to the server's max_seq whenever an +// upload reports a conflict. +type seqAllocator struct { + mu sync.Mutex + next map[string]int64 +} + +func newSeqAllocator() *seqAllocator { + return &seqAllocator{next: make(map[string]int64)} +} + +// Next assigns the next seq for sid. +func (a *seqAllocator) Next(sid string) int64 { + a.mu.Lock() + defer a.mu.Unlock() + n := a.next[sid] + if n < 1 { + n = 1 + } + a.next[sid] = n + 1 + return n +} + +// Seed resumes numbering after the server's last_seq, never moving backwards. +func (a *seqAllocator) Seed(sid string, lastSeq int64) { + a.mu.Lock() + defer a.mu.Unlock() + if lastSeq+1 > a.next[sid] { + a.next[sid] = lastSeq + 1 + } +} + +// Resync restarts numbering after the server's max_seq following an upload +// conflict (some of our seqs were already taken by an earlier incarnation). +func (a *seqAllocator) Resync(sid string, maxSeq int64) { + a.mu.Lock() + defer a.mu.Unlock() + a.next[sid] = maxSeq + 1 +} + +// agentTextBufCap bounds one session's accumulated agent_text buffer so a +// runaway session cannot grow connector memory without limit. Beyond the cap +// the buffer stops growing and the synthesized agent_message is marked +// truncated. +const agentTextBufCap = 256 * 1024 + +// agentTextBuffers accumulates the ephemeral agent_text deltas per session so +// the connector can synthesize a durable agent_message event (the full +// assistant message) when the run finishes: agent_text is token-level and +// lossy, so without this the cloud replay has no assistant text at all. The +// buffer is in-memory only — a connector restart loses any not-yet-finalized +// text (acceptable: the live SSE stream carried the same deltas). +type agentTextBuffers struct { + mu sync.Mutex + bufs map[string]*agentTextBuf +} + +type agentTextBuf struct { + sb strings.Builder + truncated bool +} + +func newAgentTextBuffers() *agentTextBuffers { + return &agentTextBuffers{bufs: make(map[string]*agentTextBuf)} +} + +// append folds one agent_text delta into the session's buffer. +func (b *agentTextBuffers) append(sid, delta string) { + if delta == "" { + return + } + b.mu.Lock() + defer b.mu.Unlock() + buf := b.bufs[sid] + if buf == nil { + buf = &agentTextBuf{} + b.bufs[sid] = buf + } + if buf.sb.Len() >= agentTextBufCap { + buf.truncated = true + return + } + if room := agentTextBufCap - buf.sb.Len(); len(delta) > room { + buf.sb.WriteString(delta[:room]) + buf.truncated = true + return + } + buf.sb.WriteString(delta) +} + +// take returns the accumulated text (empty when nothing was buffered) and +// clears the session's buffer. +func (b *agentTextBuffers) take(sid string) string { + b.mu.Lock() + defer b.mu.Unlock() + buf := b.bufs[sid] + if buf == nil { + return "" + } + delete(b.bufs, sid) + text := buf.sb.String() + if buf.truncated { + text += "\n\n[…truncated by the device connector at 256KB]" + } + return text +} + +// clear drops the session's buffer without reading it (session_reset). +func (b *agentTextBuffers) clear(sid string) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.bufs, sid) +} + +// eventBatcher accumulates durable events per session and flushes them on a +// time window or when a session's buffer reaches the size cap. +type eventBatcher struct { + c *Connector + window time.Duration + max int + + mu sync.Mutex + pending map[string][]EventUpload +} + +// requeueCap bounds how many undelivered events one session may buffer (e.g. +// during a cloud outage); beyond it the oldest are dropped with a warning. +const requeueCap = 500 + +func newEventBatcher(c *Connector) *eventBatcher { + return &eventBatcher{ + c: c, + window: c.batchWindow(), + max: c.batchMax(), + pending: make(map[string][]EventUpload), + } +} + +// add buffers one event, flushing the session's batch inline when it hits the +// size cap. The payload is sealed once here (when encryption is active) so a +// requeued batch keeps the same ciphertext across retries. +func (b *eventBatcher) add(ctx context.Context, sid string, ev EventUpload) { + ev.Payload = b.c.sealUplink(ev.Payload) + b.mu.Lock() + b.pending[sid] = append(b.pending[sid], ev) + full := len(b.pending[sid]) >= b.max + var batch []EventUpload + if full { + batch = b.pending[sid] + delete(b.pending, sid) + } + b.mu.Unlock() + if full { + b.upload(ctx, sid, batch) + } +} + +// run flushes all non-empty batches every window until ctx is done. +func (b *eventBatcher) run(ctx context.Context) { + ticker := time.NewTicker(b.window) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + b.flushAll(ctx) + } + } +} + +func (b *eventBatcher) flushAll(ctx context.Context) { + b.mu.Lock() + batches := b.pending + b.pending = make(map[string][]EventUpload) + b.mu.Unlock() + for sid, batch := range batches { + if len(batch) > 0 { + b.upload(ctx, sid, batch) + } + } +} + +// upload POSTs one batch. Conflicted seqs are skipped server-side and the +// allocator resyncs to the server's max_seq. On transport/HTTP failure the +// batch is requeued (front, capped) so the next tick retries it. +func (b *eventBatcher) upload(ctx context.Context, sid string, batch []EventUpload) { + resp, err := b.c.client.UploadEvents(ctx, b.c.token, sid, batch) + if err != nil { + if ctx.Err() == nil { + b.c.logf("event upload for session %s failed (%d events, will retry): %v", sid, len(batch), err) + } + b.mu.Lock() + kept := b.pending[sid] + b.pending[sid] = append(batch, kept...) + if len(b.pending[sid]) > requeueCap { + dropped := len(b.pending[sid]) - requeueCap + b.pending[sid] = b.pending[sid][dropped:] + b.c.logf("event buffer for session %s overflowed, dropped %d oldest events", sid, dropped) + } + b.mu.Unlock() + return + } + if len(resp.Conflicted) > 0 { + b.c.logf("event upload for session %s: %d accepted, %d conflicted (max_seq=%d), resyncing", + sid, len(resp.Accepted), len(resp.Conflicted), resp.MaxSeq) + b.c.seq.Resync(sid, resp.MaxSeq) + } +} + +// eventPumpLoop maintains the local WS subscription, reconnecting with +// backoff whenever the stream drops. +func (c *Connector) eventPumpLoop(ctx context.Context) { + bo := c.backoff() + batcher := newEventBatcher(c) + go batcher.run(ctx) + for { + err := c.pumpEvents(ctx, batcher) + if ctx.Err() != nil { + return + } + c.logf("local event stream disconnected: %v", err) + bo.Reset() // a live connection resets the backoff before the next wait + if werr := bo.Wait(ctx); werr != nil { + return + } + } +} + +// pumpEvents runs one WS connection until it breaks. +func (c *Connector) pumpEvents(ctx context.Context, batcher *eventBatcher) error { + wsURL := "ws" + strings.TrimPrefix(c.cfg.LocalBase, "http") + "/api/ws" + header := http.Header{} + if c.cfg.LocalToken != "" { + // Same bearer-via-subprotocol handshake the browser uses (see + // internal/web/auth.go): ["jcode-auth", ""]. + header.Set("Sec-WebSocket-Protocol", "jcode-auth, "+c.cfg.LocalToken) + } + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, _, err := dialer.DialContext(ctx, wsURL, header) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + // Unblock ReadMessage on shutdown. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-done: + } + }() + + c.logf("subscribed to local event stream %s", wsURL) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return err + } + c.handleWSEvent(ctx, batcher, msg) + } +} + +// handleWSEvent classifies and routes one local WS event uplink. +func (c *Connector) handleWSEvent(ctx context.Context, batcher *eventBatcher, msg []byte) { + var ev wsEvent + if err := json.Unmarshal(msg, &ev); err != nil || ev.Type == "" { + return + } + // Resolve the owning session. Task-tagged events carry it on the envelope; + // task_status is a global envelope with the id inside data. Everything else + // global (mcp_changed, pong, model_changed without task, …) has no session + // to key seq/upload on and is skipped. + sid := ev.TaskID + if sid == "" && ev.Type == "task_status" { + var d struct { + TaskID string `json:"task_id"` + } + if json.Unmarshal(ev.Data, &d) == nil { + sid = d.TaskID + } + } + if sid == "" { + return + } + + // M19 per-session sync gate: events of sessions without an explicit sync + // opt-in are dropped here — durable AND ephemeral, with NO seq allocated + // (a gapless per-session seq stream is preserved for the events that do + // upload). Turning sync off stops the upload from that event on; the + // history already uploaded stays on the orchestrator (turning sync off + // never deletes cloud-side data). Turning it back on resumes uploads from + // that moment forward — earlier local history is NOT backfilled (first + // version; the pump is a live stream and has no replay path). + if !c.syncEnabled(sid) { + // Drop any agent_text buffered while the session was enabled: if sync + // is re-enabled mid-run, the synthesized agent_message must only cover + // text streamed after the re-enable point. + c.text.clear(sid) + return + } + + if !isDurableEvent(ev.Type) { + // Accumulate agent_text deltas so a durable agent_message can be + // synthesized when the run finishes (see agentTextBuffers). + if ev.Type == "agent_text" { + var d struct { + Text string `json:"text"` + } + if json.Unmarshal(ev.Data, &d) == nil { + c.text.append(sid, d.Text) + } + } + // Ephemeral: forward immediately, drop on failure — never retried. + if err := c.client.SendEphemeral(ctx, c.token, sid, ev.Type, c.sealUplink(msg)); err != nil && ctx.Err() == nil { + c.logf("ephemeral event %q for session %s dropped: %v", ev.Type, sid, err) + } + return + } + // Durable: assign the per-session seq and batch. The payload is the full + // original WS message JSON (type + task_id + data); the batcher seals it + // into an E2E envelope when encryption is active. + batcher.add(ctx, sid, EventUpload{Seq: c.seq.Next(sid), Kind: ev.Type, Payload: json.RawMessage(msg)}) + + switch ev.Type { + case "agent_done": + // Synthesize the durable full-text assistant message from the + // accumulated agent_text deltas, batched right after agent_done. + if text := c.text.take(sid); text != "" { + var done struct { + Error string `json:"error"` + Stopped bool `json:"stopped"` + } + _ = json.Unmarshal(ev.Data, &done) + payload, err := json.Marshal(map[string]any{ + "type": "agent_message", + "task_id": sid, + "data": map[string]any{ + "text": text, + "error": done.Error, + "stopped": done.Stopped, + }, + }) + if err == nil { + batcher.add(ctx, sid, EventUpload{Seq: c.seq.Next(sid), Kind: "agent_message", Payload: payload}) + } + } + case "session_reset": + c.text.clear(sid) + } +} diff --git a/internal/cloud/events_unit_test.go b/internal/cloud/events_unit_test.go new file mode 100644 index 00000000..9ecf4047 --- /dev/null +++ b/internal/cloud/events_unit_test.go @@ -0,0 +1,76 @@ +package cloud + +import "testing" + +// TestEventDurabilityClassification pins the durable/ephemeral class of every +// WS event type the local web server emits today, plus the default for +// unknown types (durable — 宁可多存不可丢). +func TestEventDurabilityClassification(t *testing.T) { + durable := []string{ + // complete message-level events + "user_message", "agent_message", "tool_call", "tool_result", + "approval_request", "ask_user_request", + // session state changes + "agent_start", "agent_done", "task_status", + "todo_update", "goal_update", "session_reset", + "mode_changed", "model_changed", + // subagent lifecycle + "subagent_event", + } + for _, typ := range durable { + if !isDurableEvent(typ) { + t.Errorf("event %q classified ephemeral, want durable", typ) + } + } + + ephemeral := []string{ + "agent_text", // streaming assistant delta + "token_update", // cumulative token counters + "subagent_progress", // intermediate progress lines + } + for _, typ := range ephemeral { + if isDurableEvent(typ) { + t.Errorf("event %q classified durable, want ephemeral", typ) + } + } + + if !isDurableEvent("some_future_event_type") { + t.Error("unknown event type must default to durable") + } +} + +func TestSeqAllocatorMonotonicFromOne(t *testing.T) { + a := newSeqAllocator() + for want := int64(1); want <= 3; want++ { + if got := a.Next("s1"); got != want { + t.Fatalf("Next(s1) = %d, want %d", got, want) + } + } + // Independent per-session counters. + if got := a.Next("s2"); got != 1 { + t.Fatalf("Next(s2) = %d, want 1", got) + } +} + +func TestSeqAllocatorSeedResumesLastSeq(t *testing.T) { + a := newSeqAllocator() + a.Seed("s1", 41) // server already has up to seq 41 + if got := a.Next("s1"); got != 42 { + t.Fatalf("Next after Seed(41) = %d, want 42", got) + } + // A stale (lower) seed must never move the counter backwards. + a.Seed("s1", 10) + if got := a.Next("s1"); got != 43 { + t.Fatalf("Next after stale Seed = %d, want 43", got) + } +} + +func TestSeqAllocatorResyncOnConflict(t *testing.T) { + a := newSeqAllocator() + a.Next("s1") // 1 + a.Next("s1") // 2 + a.Resync("s1", 57) // server reports max_seq 57 on conflict + if got := a.Next("s1"); got != 58 { + t.Fatalf("Next after Resync(57) = %d, want 58", got) + } +} diff --git a/internal/cloud/fingerprint.go b/internal/cloud/fingerprint.go new file mode 100644 index 00000000..9df583b9 --- /dev/null +++ b/internal/cloud/fingerprint.go @@ -0,0 +1,171 @@ +package cloud + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +// Machine fingerprint (M16 — cloud/docs/17-jcode-device-relay.md §3): a stable +// per-machine identity the orchestrator uses to dedup repeated `jcode login` +// runs from the same computer (one devices row per machine, not one per +// login). The source string itself NEVER leaves the machine — only its +// sha256 (FingerprintHash) is sent, on the token poll and on register. +// +// Source resolution order (ResolveFingerprintSource): +// 1. the value already persisted in ~/.jcode/cloud.json — it never changes +// once written, so a hardware-id failure later cannot split the device; +// 2. the OS-level machine id (macOS IOPlatformUUID, Linux /etc/machine-id, +// Windows registry MachineGuid); +// 3. a fallback of hostname + a persistent random — generated once and saved +// into cloud.json's fingerprint field with the rest of the credentials. + +// fingerprintDeps carries every side effect machineID needs, so tests can +// drive all three OS branches table-driven. +type fingerprintDeps struct { + goos string + execOutput func(name string, args ...string) ([]byte, error) + readFile func(path string) ([]byte, error) +} + +func defaultFingerprintDeps() fingerprintDeps { + return fingerprintDeps{ + goos: runtime.GOOS, + execOutput: func(name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, name, args...).Output() + }, + readFile: os.ReadFile, + } +} + +// machineID returns the OS-level stable machine id, or "" when the platform +// is unknown or the id cannot be read (the caller then falls back). +func machineID(d fingerprintDeps) string { + switch d.goos { + case "darwin": + out, err := d.execOutput("ioreg", "-rd1", "-c", "IOPlatformExpertDevice") + if err != nil { + return "" + } + return parseIOPlatformUUID(string(out)) + case "linux": + for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { + b, err := d.readFile(path) + if err != nil { + continue + } + if id := strings.TrimSpace(string(b)); id != "" { + return id + } + } + return "" + case "windows": + out, err := d.execOutput("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid") + if err != nil { + return "" + } + return parseMachineGUID(string(out)) + } + return "" +} + +// parseIOPlatformUUID extracts the UUID from `ioreg -rd1 -c +// IOPlatformExpertDevice` output: a line like +// +// "IOPlatformUUID" = "1A2B3C4D-...." +func parseIOPlatformUUID(out string) string { + for _, line := range strings.Split(out, "\n") { + if !strings.Contains(line, "IOPlatformUUID") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || !strings.Contains(key, "IOPlatformUUID") { + continue + } + return strings.Trim(strings.TrimSpace(value), `"`) + } + return "" +} + +// parseMachineGUID extracts the guid from `reg query ... /v MachineGuid` +// output: a line like +// +// MachineGuid REG_SZ 1a2b3c4d-.... +func parseMachineGUID(out string) string { + fields := strings.Fields(out) + for i, f := range fields { + if f == "MachineGuid" && i+2 < len(fields) && strings.EqualFold(fields[i+1], "REG_SZ") { + return fields[i+2] + } + } + return "" +} + +// HardwareFingerprintSource returns the OS-level machine id ("" when +// unavailable) — exported for callers (login --status, the connector) that +// must NOT generate a fresh fallback random on the spot. +func HardwareFingerprintSource() string { + return machineID(defaultFingerprintDeps()) +} + +// FingerprintHash maps a fingerprint source to the ONLY form that ever leaves +// the machine: sha256 hex with a domain separator. The raw hardware id is +// never sent to the orchestrator. +func FingerprintHash(source string) string { + sum := sha256.Sum256([]byte("jcode-device-fingerprint:" + source)) + return hex.EncodeToString(sum[:]) +} + +// ResolveFingerprintSource returns the stable fingerprint source for this +// machine: the persisted cloud.json value if present (stable by definition), +// else the OS machine id, else a freshly generated fallback +// ("fallback::"). +// +// The fallback is stable only once persisted: the caller MUST write the +// returned source into Credentials.Fingerprint when it saves the credentials +// file (both login paths do). Saving a hardware-derived source too is +// deliberate: it pins the identity against a later ioreg/machine-id failure. +func ResolveFingerprintSource() (string, error) { + creds, err := LoadCredentials() + if err != nil { + return "", err + } + if creds != nil && creds.Fingerprint != "" { + return creds.Fingerprint, nil + } + if id := HardwareFingerprintSource(); id != "" { + return id, nil + } + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown-host" + } + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "fallback:" + hostname + ":" + hex.EncodeToString(b[:]), nil +} + +// fingerprintHashForCreds is the connector's register-time fingerprint: the +// persisted source when present, the hardware id otherwise (never a fresh +// fallback — the connector does not own the credentials file, so it could not +// persist one). "" when neither exists (register then simply omits the field). +func fingerprintHashForCreds(creds *Credentials) string { + source := creds.Fingerprint + if source == "" { + source = HardwareFingerprintSource() + } + if source == "" { + return "" + } + return FingerprintHash(source) +} diff --git a/internal/cloud/fingerprint_test.go b/internal/cloud/fingerprint_test.go new file mode 100644 index 00000000..b3d0fdee --- /dev/null +++ b/internal/cloud/fingerprint_test.go @@ -0,0 +1,240 @@ +package cloud + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// machineIDTable covers every OS branch of machineID with mocked exec/file +// reads — no real ioreg/reg/machine-id involved. +func TestMachineID(t *testing.T) { + failExec := func(string, ...string) ([]byte, error) { return nil, errors.New("exec failed") } + failRead := func(string) ([]byte, error) { return nil, errors.New("read failed") } + + tests := []struct { + name string + deps fingerprintDeps + want string + }{ + { + name: "darwin parses IOPlatformUUID", + deps: fingerprintDeps{ + goos: "darwin", + execOutput: func(string, ...string) ([]byte, error) { + return []byte("+-o Root \n \"IOPlatformUUID\" = \"1A2B3C4D-5E6F-7788-99AA-BBCCDDEEFF00\"\n"), nil + }, + }, + want: "1A2B3C4D-5E6F-7788-99AA-BBCCDDEEFF00", + }, + { + name: "darwin ioreg failure", + deps: fingerprintDeps{goos: "darwin", execOutput: failExec}, + want: "", + }, + { + name: "darwin output without the key", + deps: fingerprintDeps{ + goos: "darwin", + execOutput: func(string, ...string) ([]byte, error) { return []byte("nothing here\n"), nil }, + }, + want: "", + }, + { + name: "linux reads /etc/machine-id", + deps: fingerprintDeps{ + goos: "linux", + readFile: func(path string) ([]byte, error) { + if path != "/etc/machine-id" { + t.Fatalf("readFile(%q), want /etc/machine-id first", path) + } + return []byte("a1b2c3d4e5f60718293a4b5c6d7e8f90\n"), nil + }, + }, + want: "a1b2c3d4e5f60718293a4b5c6d7e8f90", + }, + { + name: "linux falls back to the dbus machine-id", + deps: fingerprintDeps{ + goos: "linux", + readFile: func(path string) ([]byte, error) { + if path == "/var/lib/dbus/machine-id" { + return []byte("deadbeef\n"), nil + } + return nil, errors.New("missing") + }, + }, + want: "deadbeef", + }, + { + name: "linux empty machine-id keeps looking", + deps: fingerprintDeps{ + goos: "linux", + readFile: func(path string) ([]byte, error) { + if path == "/var/lib/dbus/machine-id" { + return []byte("cafe\n"), nil + } + return []byte(" \n"), nil + }, + }, + want: "cafe", + }, + { + name: "linux unreadable", + deps: fingerprintDeps{goos: "linux", readFile: failRead}, + want: "", + }, + { + name: "windows parses MachineGuid", + deps: fingerprintDeps{ + goos: "windows", + execOutput: func(string, ...string) ([]byte, error) { + return []byte("\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography\r\n MachineGuid REG_SZ 9f8e7d6c-5b4a-3928-1706-050403020100\r\n"), nil + }, + }, + want: "9f8e7d6c-5b4a-3928-1706-050403020100", + }, + { + name: "windows reg failure", + deps: fingerprintDeps{goos: "windows", execOutput: failExec}, + want: "", + }, + { + name: "unsupported platform", + deps: fingerprintDeps{goos: "plan9"}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := machineID(tt.deps); got != tt.want { + t.Fatalf("machineID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFingerprintHash(t *testing.T) { + h := FingerprintHash("some-source") + if len(h) != 64 { + t.Fatalf("FingerprintHash() = %q, want 64 hex chars", h) + } + for _, c := range h { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Fatalf("FingerprintHash() = %q, not lowercase hex", h) + } + } + if FingerprintHash("some-source") != h { + t.Fatal("FingerprintHash() is not deterministic") + } + if FingerprintHash("other-source") == h { + t.Fatal("FingerprintHash() collides for different sources") + } +} + +// TestResolveFingerprintSourcePersisted: a fingerprint already stored in +// cloud.json always wins — even over the hardware id — so the device identity +// survives an ioreg/machine-id failure (M16: 一旦生成不再变). +func TestResolveFingerprintSourcePersisted(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := SaveCredentials(&Credentials{ + CloudURL: "https://cloud.example.com", DeviceID: "d1", DeviceToken: "t", + Fingerprint: "persisted-source", + }); err != nil { + t.Fatalf("SaveCredentials() error = %v", err) + } + src, err := ResolveFingerprintSource() + if err != nil { + t.Fatalf("ResolveFingerprintSource() error = %v", err) + } + if src != "persisted-source" { + t.Fatalf("ResolveFingerprintSource() = %q, want the persisted value", src) + } +} + +// TestResolveFingerprintSourceStableAcrossCalls: without a persisted value the +// source is either the (stable) hardware id or a fallback that becomes stable +// once the caller persists it — the login flow's contract. +func TestResolveFingerprintSourceStableAcrossCalls(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + first, err := ResolveFingerprintSource() + if err != nil { + t.Fatalf("ResolveFingerprintSource() error = %v", err) + } + if first == "" { + t.Fatal("ResolveFingerprintSource() = empty") + } + if HardwareFingerprintSource() != "" { + // Hardware id present: resolution is stable without persisting. + second, _ := ResolveFingerprintSource() + if second != first { + t.Fatalf("hardware-derived source changed: %q -> %q", first, second) + } + return + } + // Fallback path (no hardware id, e.g. a VM without machine-id): the caller + // persists the generated source, and every later call returns it. + if !strings.HasPrefix(first, "fallback:") { + t.Fatalf("fallback source = %q, want fallback: prefix", first) + } + if err := SaveCredentials(&Credentials{ + CloudURL: "https://cloud.example.com", DeviceID: "d1", DeviceToken: "t", + Fingerprint: first, + }); err != nil { + t.Fatalf("SaveCredentials() error = %v", err) + } + second, err := ResolveFingerprintSource() + if err != nil { + t.Fatalf("ResolveFingerprintSource() error = %v", err) + } + if second != first { + t.Fatalf("persisted fallback not reused: %q -> %q", first, second) + } +} + +// TestFingerprintRoundTripThroughCredentials: the fingerprint field survives +// the atomic save/load cycle and lands in the JSON file. +func TestFingerprintRoundTripThroughCredentials(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + creds := &Credentials{ + CloudURL: "https://cloud.example.com", DeviceID: "d1", DeviceToken: "tok", + DeviceName: "n", PublicKey: "pk", PrivateKey: "sk", KeyGen: 1, + Fingerprint: "fp-source", + } + if err := SaveCredentials(creds); err != nil { + t.Fatalf("SaveCredentials() error = %v", err) + } + raw, err := os.ReadFile(filepath.Join(home, ".jcode", "cloud.json")) + if err != nil { + t.Fatalf("read cloud.json: %v", err) + } + if !strings.Contains(string(raw), `"fingerprint": "fp-source"`) { + t.Fatalf("cloud.json missing fingerprint field: %s", raw) + } + got, err := LoadCredentials() + if err != nil || got == nil { + t.Fatalf("LoadCredentials() = %+v, %v", got, err) + } + if got.Fingerprint != "fp-source" { + t.Fatalf("loaded fingerprint = %q, want fp-source", got.Fingerprint) + } +} + +func TestFingerprintHashForCreds(t *testing.T) { + // Persisted source wins. + if got, want := fingerprintHashForCreds(&Credentials{Fingerprint: "src"}), FingerprintHash("src"); got != want { + t.Fatalf("fingerprintHashForCreds(persisted) = %q, want %q", got, want) + } + // No persisted source: hardware id when available, "" otherwise. + hw := HardwareFingerprintSource() + got := fingerprintHashForCreds(&Credentials{}) + if hw == "" && got != "" { + t.Fatalf("fingerprintHashForCreds(no hw) = %q, want empty", got) + } + if hw != "" && got != FingerprintHash(hw) { + t.Fatalf("fingerprintHashForCreds(hw) = %q, want hash of %q", got, hw) + } +} diff --git a/internal/cloud/identity_test.go b/internal/cloud/identity_test.go new file mode 100644 index 00000000..c00fb859 --- /dev/null +++ b/internal/cloud/identity_test.go @@ -0,0 +1,104 @@ +package cloud + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestCredentialsRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + want := &Credentials{ + CloudURL: "https://cloud.j-code.net", + DeviceID: "device-42", + DeviceToken: "dev-token-abc", + DeviceName: "jack-macbook", + PublicKey: "cHVia2V5", + PrivateKey: "cHJpdmtleQ==", + KeyGen: 1, + } + if err := SaveCredentials(want); err != nil { + t.Fatalf("SaveCredentials() error = %v", err) + } + + got, err := LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials() error = %v", err) + } + if got == nil { + t.Fatal("LoadCredentials() = nil, want saved credentials") + } + if *got != *want { + t.Fatalf("LoadCredentials() = %+v, want %+v", got, want) + } + + assertPermission(t, filepath.Join(home, ".jcode"), 0o700) + assertPermission(t, filepath.Join(home, ".jcode", credentialsFile), 0o600) +} + +func TestLoadCredentialsMissingMeansLoggedOut(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + got, err := LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials() error = %v, want nil for missing file", err) + } + if got != nil { + t.Fatalf("LoadCredentials() = %+v, want nil for missing file", got) + } +} + +func TestDeleteCredentials(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Deleting when absent is not an error. + if err := DeleteCredentials(); err != nil { + t.Fatalf("DeleteCredentials() (absent) error = %v", err) + } + + if err := SaveCredentials(&Credentials{CloudURL: "https://cloud.j-code.net", KeyGen: 1}); err != nil { + t.Fatalf("SaveCredentials() error = %v", err) + } + if err := DeleteCredentials(); err != nil { + t.Fatalf("DeleteCredentials() error = %v", err) + } + got, err := LoadCredentials() + if err != nil || got != nil { + t.Fatalf("LoadCredentials() after delete = %+v, %v; want nil, nil", got, err) + } +} + +func TestGenerateIdentityKeyPair(t *testing.T) { + pub, priv, err := GenerateIdentityKeyPair() + if err != nil { + t.Fatalf("GenerateIdentityKeyPair() error = %v", err) + } + pubRaw, err := base64.StdEncoding.DecodeString(pub) + if err != nil { + t.Fatalf("public key is not valid base64: %v", err) + } + privRaw, err := base64.StdEncoding.DecodeString(priv) + if err != nil { + t.Fatalf("private key is not valid base64: %v", err) + } + if len(pubRaw) != 32 || len(privRaw) != 32 { + t.Fatalf("X25519 raw key lengths = %d/%d, want 32/32", len(pubRaw), len(privRaw)) + } + if pub == priv { + t.Fatal("public and private keys must differ") + } +} + +func assertPermission(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("permission %s = %#o, want %#o", path, got, want) + } +} diff --git a/internal/cloud/inbox.go b/internal/cloud/inbox.go new file mode 100644 index 00000000..9f7df5b8 --- /dev/null +++ b/internal/cloud/inbox.go @@ -0,0 +1,138 @@ +// inbox.go lands non-image chat attachments (M12) at the fixed per-session +// location ~/.jcode/inbox//. The message text then +// references them ("[附件] name → path") so the agent can read them with its +// file tools. Attachment bytes travel inside the E2E envelope, so they are +// only ever plaintext on-device. +package cloud + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +const ( + // maxAttachmentBytes is the per-attachment decoded size limit (2MB), + // enforced connector-side per the M12 contract. + maxAttachmentBytes = 2 << 20 + // maxAttachmentCount is the per-command attachment count limit. + maxAttachmentCount = 5 + + inboxDirMode = 0o700 + inboxFileMode = 0o600 +) + +// chatAttachment is one non-image attachment in a chat.send payload. +type chatAttachment struct { + Name string `json:"name"` + Mime string `json:"mime"` + DataB64 string `json:"data_b64"` +} + +// attachmentRef is one landed attachment, for the message reference list. +type attachmentRef struct { + Name string + Path string +} + +// decodeAttachments enforces the count/size limits and decodes every +// attachment BEFORE any side effect, so a breach acks error without touching +// the session, the config, or the filesystem. +func decodeAttachments(atts []chatAttachment) ([][]byte, error) { + if len(atts) > maxAttachmentCount { + return nil, fmt.Errorf("attachments: %d files exceed the %d-file limit", len(atts), maxAttachmentCount) + } + decoded := make([][]byte, len(atts)) + for i, a := range atts { + data, err := base64.StdEncoding.DecodeString(a.DataB64) + if err != nil { + // Tolerate unpadded base64. + data, err = base64.RawStdEncoding.DecodeString(a.DataB64) + if err != nil { + return nil, fmt.Errorf("attachment %q: invalid base64 data", a.Name) + } + } + if len(data) > maxAttachmentBytes { + return nil, fmt.Errorf("attachment %q: %d bytes exceed the 2MB limit", a.Name, len(data)) + } + decoded[i] = data + } + return decoded, nil +} + +// sanitizeInboxName strips anything that could escape the inbox directory or +// create hidden/confusing files: path separators (both kinds), leading dots +// ("..", ".env"), and control characters. An empty result falls back to +// "attachment". +func sanitizeInboxName(name string) string { + name = strings.ReplaceAll(name, "\\", "/") + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + var b strings.Builder + for _, r := range name { + if unicode.IsControl(r) { + continue + } + b.WriteRune(r) + } + name = strings.TrimSpace(strings.TrimLeft(b.String(), ".")) + if name == "" { + return "attachment" + } + return name +} + +// writeInboxAttachments writes decoded attachments under root//, +// creating the directory 0700 and files 0600. Name collisions (including +// pre-existing files) get a numeric suffix ("report.pdf" → "report-2.pdf"). +// The session id is sanitized too — it is normally a server-minted UUID, but +// it crosses the trust boundary inside the command envelope. Returns the +// reference list in input order. +func writeInboxAttachments(root, sessionID string, atts []chatAttachment, decoded [][]byte) ([]attachmentRef, error) { + dir := filepath.Join(root, sanitizeInboxName(sessionID)) + if err := os.MkdirAll(dir, inboxDirMode); err != nil { + return nil, fmt.Errorf("create inbox dir: %w", err) + } + // MkdirAll does not fix the mode of a pre-existing dir. + if err := os.Chmod(dir, inboxDirMode); err != nil { + return nil, fmt.Errorf("secure inbox dir: %w", err) + } + refs := make([]attachmentRef, 0, len(atts)) + for i, a := range atts { + name := sanitizeInboxName(a.Name) + path := filepath.Join(dir, name) + for n := 2; ; n++ { + if _, err := os.Stat(path); os.IsNotExist(err) { + break + } + path = filepath.Join(dir, suffixedName(name, n)) + } + if err := os.WriteFile(path, decoded[i], inboxFileMode); err != nil { + return nil, fmt.Errorf("write attachment %q: %w", a.Name, err) + } + _ = os.Chmod(path, inboxFileMode) // umask may have cleared bits + refs = append(refs, attachmentRef{Name: filepath.Base(path), Path: path}) + } + return refs, nil +} + +// suffixedName inserts "-n" before the extension: "report.pdf" → "report-2.pdf". +func suffixedName(name string, n int) string { + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + return fmt.Sprintf("%s-%d%s", base, n, ext) +} + +// attachmentReferenceList renders the reference lines appended to the message +// text: "[附件] name → path", one per line. +func attachmentReferenceList(refs []attachmentRef) string { + var b strings.Builder + for _, r := range refs { + fmt.Fprintf(&b, "\n[附件] %s → %s", r.Name, r.Path) + } + return b.String() +} diff --git a/internal/cloud/mode_ceiling_test.go b/internal/cloud/mode_ceiling_test.go new file mode 100644 index 00000000..fc443e85 --- /dev/null +++ b/internal/cloud/mode_ceiling_test.go @@ -0,0 +1,101 @@ +// mode_ceiling_test.go covers the M20 cloud mode ceiling: a cloud-originated +// chat.send asking for full_access (bypass) — under any alias — is refused at +// the protocol layer with ack error mode_not_allowed_for_cloud, before any +// local side effect. plan/approval/auto stay available on both dispatch paths. +package cloud + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestCloudForbiddenMode(t *testing.T) { + forbidden := []string{"full_access", "FULL_ACCESS", " full_access ", "full-access", "fullaccess", "bypass", "bypass_permissions", "BypassPermissions"} + for _, m := range forbidden { + if !cloudForbiddenMode(m) { + t.Errorf("cloudForbiddenMode(%q) = false, want true", m) + } + } + allowed := []string{"", "approval", "plan", "auto", "build"} + for _, m := range allowed { + if cloudForbiddenMode(m) { + t.Errorf("cloudForbiddenMode(%q) = true, want false", m) + } + } +} + +// The ceiling fires on BOTH dispatch paths (legacy one-shot and M12 compose) +// and leaves no local side effects. +func TestChatSendModeCeilingRejected(t *testing.T) { + cases := []struct { + name string + payload map[string]any + }{ + {"legacy full_access", map[string]any{"text": "hi", "mode": "full_access"}}, + {"compose full_access", map[string]any{"text": "hi", "mode": "full_access", "project_path": "/tmp/p"}}, + {"compose bypass alias", map[string]any{"text": "hi", "mode": "bypass", "goal": "g"}}, + {"compose uppercase", map[string]any{"text": "hi", "mode": "FULL_ACCESS", "effort": "low"}}, + // goal_armed ignores mode — but a payload declaring the intent is + // refused before the goal endpoint is touched. + {"goal_armed full_access", map[string]any{"text": "obj", "mode": "full_access", "goal_armed": true}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + cmd := DeviceCommand{ID: "cmd-ceiling", Kind: "chat.send", Payload: mustPayload(t, tc.payload)} + status, result := conn.executeCommand(context.Background(), cmd) + if status != "error" { + t.Fatalf("status = %q, result = %v; want error", status, result) + } + if !strings.Contains(fmt.Sprint(result), "mode_not_allowed_for_cloud") { + t.Fatalf("result = %v, want mode_not_allowed_for_cloud", result) + } + if calls, _ := local.snapshot(); len(calls) != 0 { + t.Fatalf("local calls = %v, want none (no side effects on a rejected mode)", calls) + } + }) + } +} + +// plan/approval/auto pass the ceiling: the legacy path forwards the mode to +// /api/chat; the compose path switches it on the focused engine via /api/mode. +func TestChatSendModeCeilingAllowed(t *testing.T) { + for _, m := range []string{"approval", "plan", "auto"} { + t.Run("legacy "+m, func(t *testing.T) { + local, localSrv := newFakeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + cmd := DeviceCommand{ + ID: "cmd-ok", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{"text": "hi", "mode": m}), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + if got := local.chatBody()["mode"]; got != m { + t.Errorf("chat mode = %v, want %q", got, m) + } + }) + t.Run("compose "+m, func(t *testing.T) { + local, localSrv := newFakeComposeLocal(t) + conn := newTestConnector(t, "http://127.0.0.1:1", localSrv.URL) + cmd := DeviceCommand{ + ID: "cmd-ok-compose", + Kind: "chat.send", + Payload: mustPayload(t, map[string]any{"text": "hi", "mode": m, "project_path": "/tmp/p"}), + } + status, result := conn.executeCommand(context.Background(), cmd) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + _, bodies := local.snapshot() + if got := bodies["/api/mode"][0]["mode"]; got != m { + t.Errorf("/api/mode body = %v, want mode %q", bodies["/api/mode"], m) + } + }) + } +} diff --git a/internal/cloud/pairing_inbox.go b/internal/cloud/pairing_inbox.go new file mode 100644 index 00000000..86c9ce2c --- /dev/null +++ b/internal/cloud/pairing_inbox.go @@ -0,0 +1,190 @@ +// pairing_inbox.go handles pairing.request downlink commands (M11-W1): a +// request carrying an offer_id comes from a QR-code claim and is approved +// automatically (scan-to-pair); any other request is parked in an in-memory +// pending inbox until the user approves or denies it from the web UI (the CLI +// `jcode cloud approve|deny` path keeps working against the orchestrator +// directly). The inbox lives on the Connector because pairings only arrive +// through its poll loop; the Supervisor delegates the web endpoints to it. +package cloud + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" +) + +// ErrUnknownPairing is returned by ApprovePairing/DenyPairing when the id is +// not in the connector's pending inbox (already handled, or never received). +var ErrUnknownPairing = errors.New("no such pending pairing") + +// maxPendingPairings bounds the in-memory inbox; the oldest entry is dropped +// when the cap is hit (a flooded inbox must never grow unboundedly). +const maxPendingPairings = 32 + +// PendingPairing is one pairing.request parked for user approval. PubKey is +// kept in memory only (needed to wrap the CEK on approve) and never serialized. +type PendingPairing struct { + PairingID string `json:"pairing_id"` + Label string `json:"label"` + ReceivedAt time.Time `json:"received_at"` + PubKey string `json:"-"` +} + +// PairedInfo records the most recent approval so the web UI can notify +// ("device X paired via QR code"). Auto is true for offer-based (QR scan) +// approvals, false for manual approvals. +type PairedInfo struct { + PairingID string `json:"pairing_id"` + Label string `json:"label"` + Auto bool `json:"auto"` + PairedAt time.Time `json:"paired_at"` +} + +// pairingRequestPayload is the payload of a pairing.request command, as sent +// by the orchestrator when a client (console browser / mobile) asks to pair. +type pairingRequestPayload struct { + PairingID string `json:"pairing_id"` + Label string `json:"label"` + Kty string `json:"kty"` // "P-256" + PubKey string `json:"pubkey"` // requester P-256 public key, base64 SPKI DER + OfferID string `json:"offer_id,omitempty"` +} + +// execPairingRequest handles a pairing.request command: an offer-carrying +// request is auto-approved (the QR scan IS the authorization), anything else +// is parked in the pending inbox. Either way the ack reports "ok"; only a +// malformed payload or a failed auto-approve surfaces as "error". +func (c *Connector) execPairingRequest(ctx context.Context, cmd DeviceCommand) (string, any) { + var p pairingRequestPayload + if err := json.Unmarshal(cmd.Payload, &p); err != nil { + return "error", map[string]string{"error": fmt.Sprintf("invalid pairing.request payload: %v", err)} + } + if p.PairingID == "" || p.PubKey == "" { + return "error", map[string]string{"error": "pairing.request: pairing_id and pubkey are required"} + } + if p.OfferID != "" { + if err := c.approvePairing(ctx, p.PairingID, p.Label, p.PubKey, true); err != nil { + return "error", map[string]string{"error": fmt.Sprintf("auto-approve pairing %s: %v", p.PairingID, err)} + } + c.logf("pairing %s (%q) auto-approved via QR offer %s", p.PairingID, p.Label, p.OfferID) + return "ok", map[string]string{"status": "approved", "auto": "true"} + } + c.addPendingPairing(p) + c.logf("pairing %s (%q) parked for approval", p.PairingID, p.Label) + return "ok", map[string]string{"status": "pending"} +} + +// addPendingPairing parks a pairing request, replacing any previous entry +// with the same id and capping the inbox at maxPendingPairings. +func (c *Connector) addPendingPairing(p pairingRequestPayload) { + c.pairMu.Lock() + defer c.pairMu.Unlock() + entry := PendingPairing{ + PairingID: p.PairingID, + Label: p.Label, + ReceivedAt: time.Now().UTC(), + PubKey: p.PubKey, + } + for i, e := range c.pending { + if e.PairingID == p.PairingID { + c.pending[i] = entry + return + } + } + if len(c.pending) >= maxPendingPairings { + c.pending = c.pending[1:] + } + c.pending = append(c.pending, entry) +} + +// PendingPairings snapshots the pending inbox (oldest first). +func (c *Connector) PendingPairings() []PendingPairing { + c.pairMu.Lock() + defer c.pairMu.Unlock() + out := make([]PendingPairing, len(c.pending)) + copy(out, c.pending) + return out +} + +// LastPaired reports the most recent approval, ok=false when none happened +// since the connector started. +func (c *Connector) LastPaired() (PairedInfo, bool) { + c.pairMu.Lock() + defer c.pairMu.Unlock() + if c.lastPaired == nil { + return PairedInfo{}, false + } + return *c.lastPaired, true +} + +// popPending removes id from the inbox, returning ErrUnknownPairing when +// absent. +func (c *Connector) popPending(id string) (PendingPairing, error) { + c.pairMu.Lock() + defer c.pairMu.Unlock() + for i, e := range c.pending { + if e.PairingID == id { + c.pending = append(c.pending[:i], c.pending[i+1:]...) + return e, nil + } + } + return PendingPairing{}, fmt.Errorf("%w: %s", ErrUnknownPairing, id) +} + +// ApprovePairing wraps the CEK for a pending requester and responds to the +// orchestrator (web endpoint path; manual approval). +func (c *Connector) ApprovePairing(ctx context.Context, id string) error { + p, err := c.popPending(id) + if err != nil { + return err + } + return c.approvePairing(ctx, p.PairingID, p.Label, p.PubKey, false) +} + +// DenyPairing responds denial to the orchestrator for a pending request. +func (c *Connector) DenyPairing(ctx context.Context, id string) error { + p, err := c.popPending(id) + if err != nil { + return err + } + if err := c.client.RespondPairing(ctx, c.token, p.PairingID, false, nil); err != nil { + return fmt.Errorf("respond to pairing %s: %w", p.PairingID, err) + } + return nil +} + +// approvePairing wraps the CEK for the requester pubkey and POSTs the +// approval, then records the last-paired notification. The CEK cipher is the +// connector's own when encryption is active, otherwise it is lazily +// initialized from the credentials file (same as `jcode cloud approve`). +func (c *Connector) approvePairing(ctx context.Context, id, label, pubKey string, auto bool) error { + cipher := c.cipher + if cipher == nil { + var err error + cipher, err = EnsureCEK() + if err != nil { + return err + } + } + wrap, err := WrapCEK(pubKey, cipher.CEK(), cipher.KeyGen()) + if err != nil { + return fmt.Errorf("wrap CEK: %w", err) + } + if err := c.client.RespondPairing(ctx, c.token, id, true, wrap); err != nil { + return fmt.Errorf("respond to pairing %s: %w", id, err) + } + c.pairMu.Lock() + c.lastPaired = &PairedInfo{PairingID: id, Label: label, Auto: auto, PairedAt: time.Now().UTC()} + // A manual approval of an inbox entry already popped it; an auto approval + // may still find a duplicate parked entry — drop it either way. + for i, e := range c.pending { + if e.PairingID == id { + c.pending = append(c.pending[:i], c.pending[i+1:]...) + break + } + } + c.pairMu.Unlock() + return nil +} diff --git a/internal/cloud/pairing_inbox_test.go b/internal/cloud/pairing_inbox_test.go new file mode 100644 index 00000000..d88f6fff --- /dev/null +++ b/internal/cloud/pairing_inbox_test.go @@ -0,0 +1,254 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/config" +) + +// pairingMock is an httptest orchestrator that records pairing respond calls. +type pairingMock struct { + t *testing.T + + respondCalls int + lastApprove bool + lastWrap *CEKWrap + lastID string +} + +func newPairingMock(t *testing.T) (*pairingMock, *httptest.Server) { + m := &pairingMock{t: t} + mux := http.NewServeMux() + mux.HandleFunc("POST /internal/v1/device/pairings/{id}/respond", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("respond: Authorization = %q", r.Header.Get("Authorization")) + } + var body struct { + Approve bool `json:"approve"` + Wrap *CEKWrap `json:"wrap"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("respond: decode body: %v", err) + } + m.respondCalls++ + m.lastID = r.PathValue("id") + m.lastApprove = body.Approve + m.lastWrap = body.Wrap + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + return m, httptest.NewServer(mux) +} + +func pairingRequestCmd(t *testing.T, id, label, pubKey, offerID string) DeviceCommand { + t.Helper() + payload := map[string]string{ + "pairing_id": id, + "label": label, + "kty": "P-256", + "pubkey": pubKey, + } + if offerID != "" { + payload["offer_id"] = offerID + } + return DeviceCommand{ID: "cmd-" + id, Kind: "pairing.request", Payload: mustPayload(t, payload)} +} + +// A pairing.request without an offer_id is parked in the pending inbox and +// acked ok; nothing is sent to the orchestrator. +func TestPairingRequestParksPending(t *testing.T) { + pubB64, _ := p256Requester(t) + mock, srv := newPairingMock(t) + defer srv.Close() + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") + + status, result := conn.executeCommand(context.Background(), pairingRequestCmd(t, "p1", "jcode mobile", pubB64, "")) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + if mock.respondCalls != 0 { + t.Fatalf("respond called %d times, want 0 for a parked request", mock.respondCalls) + } + pending := conn.PendingPairings() + if len(pending) != 1 || pending[0].PairingID != "p1" || pending[0].Label != "jcode mobile" { + t.Fatalf("PendingPairings = %+v", pending) + } + if pending[0].ReceivedAt.IsZero() { + t.Error("received_at must be set") + } + if pending[0].PubKey != pubB64 { + t.Error("pubkey must be retained in memory for the later approve") + } + // The API serialization must not leak the requester pubkey. + if data, _ := json.Marshal(pending[0]); strings.Contains(string(data), pubB64) { + t.Errorf("marshaled PendingPairing leaks pubkey: %s", data) + } +} + +// A pairing.request carrying an offer_id (QR scan claim) is auto-approved: +// the CEK is wrapped for the requester and responded without user action. +func TestPairingRequestOfferAutoApproves(t *testing.T) { + pubB64, priv := p256Requester(t) + mock, srv := newPairingMock(t) + defer srv.Close() + + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") + cek, err := GenerateCEK() + if err != nil { + t.Fatal(err) + } + cipher, err := NewEnvelopeCipher(cek, 1) + if err != nil { + t.Fatal(err) + } + conn.cipher = cipher + + status, result := conn.executeCommand(context.Background(), pairingRequestCmd(t, "p2", "jcode android", pubB64, "offer-1")) + if status != "ok" { + t.Fatalf("status = %q, result = %v", status, result) + } + if mock.respondCalls != 1 || !mock.lastApprove || mock.lastID != "p2" { + t.Fatalf("respond calls = %d (id %q, approve %v), want 1 approve for p2", mock.respondCalls, mock.lastID, mock.lastApprove) + } + if mock.lastWrap == nil { + t.Fatal("auto-approve respond must carry the CEK wrap") + } + // The requester can unwrap the CEK with its private key. + gotCEK, keyGen, err := UnwrapCEK(mock.lastWrap, priv) + if err != nil { + t.Fatalf("UnwrapCEK: %v", err) + } + if keyGen != 1 || string(gotCEK) != string(cek) { + t.Fatalf("unwrapped (key_gen=%d, cek match=%v), want key_gen=1 and the device CEK", keyGen, string(gotCEK) == string(cek)) + } + // Nothing parked; the approval is recorded for the UI toast. + if got := conn.PendingPairings(); len(got) != 0 { + t.Fatalf("PendingPairings = %+v, want empty after auto-approve", got) + } + lp, ok := conn.LastPaired() + if !ok || lp.PairingID != "p2" || lp.Label != "jcode android" || !lp.Auto { + t.Fatalf("LastPaired = %+v, %v — want the auto approval recorded", lp, ok) + } +} + +// Manual approval from the web endpoint path wraps the CEK and responds; +// denial responds without a wrap; both consume the inbox entry. +func TestApproveDenyPairing(t *testing.T) { + pubB64, priv := p256Requester(t) + mock, srv := newPairingMock(t) + defer srv.Close() + + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") + cek, err := GenerateCEK() + if err != nil { + t.Fatal(err) + } + cipher, err := NewEnvelopeCipher(cek, 2) + if err != nil { + t.Fatal(err) + } + conn.cipher = cipher + + ctx := context.Background() + for _, id := range []string{"p1", "p2"} { + if status, result := conn.executeCommand(ctx, pairingRequestCmd(t, id, "device "+id, pubB64, "")); status != "ok" { + t.Fatalf("park %s: status = %q, result = %v", id, status, result) + } + } + + if err := conn.ApprovePairing(ctx, "p1"); err != nil { + t.Fatalf("ApprovePairing: %v", err) + } + if mock.respondCalls != 1 || !mock.lastApprove || mock.lastWrap == nil { + t.Fatalf("approve respond = calls %d approve %v wrap %v", mock.respondCalls, mock.lastApprove, mock.lastWrap != nil) + } + if _, keyGen, err := UnwrapCEK(mock.lastWrap, priv); err != nil || keyGen != 2 { + t.Fatalf("UnwrapCEK = key_gen %d, err %v — want key_gen 2", keyGen, err) + } + lp, ok := conn.LastPaired() + if !ok || lp.PairingID != "p1" || lp.Auto { + t.Fatalf("LastPaired = %+v, %v — want manual approval of p1", lp, ok) + } + + if err := conn.DenyPairing(ctx, "p2"); err != nil { + t.Fatalf("DenyPairing: %v", err) + } + if mock.respondCalls != 2 || mock.lastApprove || mock.lastWrap != nil { + t.Fatalf("deny respond = calls %d approve %v wrap %v", mock.respondCalls, mock.lastApprove, mock.lastWrap != nil) + } + + if got := conn.PendingPairings(); len(got) != 0 { + t.Fatalf("PendingPairings = %+v, want empty", got) + } + // Both ids are consumed — resolving them again reports unknown pairing. + if err := conn.ApprovePairing(ctx, "p1"); !errors.Is(err, ErrUnknownPairing) { + t.Fatalf("re-approve err = %v, want ErrUnknownPairing", err) + } + if err := conn.DenyPairing(ctx, "nope"); !errors.Is(err, ErrUnknownPairing) { + t.Fatalf("deny unknown err = %v, want ErrUnknownPairing", err) + } +} + +// Malformed commands are acked error and touch nothing. +func TestPairingRequestInvalid(t *testing.T) { + _, srv := newPairingMock(t) + defer srv.Close() + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") + + if status, _ := conn.executeCommand(context.Background(), DeviceCommand{ID: "c", Kind: "pairing.request", Payload: mustPayload(t, map[string]string{"label": "x"})}); status != "error" { + t.Fatalf("missing ids: status = %q, want error", status) + } + if status, _ := conn.executeCommand(context.Background(), DeviceCommand{ID: "c", Kind: "pairing.request", Payload: json.RawMessage(`{`)}); status != "error" { + t.Fatalf("bad json: status = %q, want error", status) + } + if got := conn.PendingPairings(); len(got) != 0 { + t.Fatalf("PendingPairings = %+v, want empty", got) + } +} + +// SyncCredentials starts the connector when credentials appear and stops it +// when they are removed (web login/logout hook). +func TestSupervisorSyncCredentials(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + sup := NewSupervisor(&config.Config{}, 0, "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sup.Start(ctx) + + sup.mu.Lock() + if sup.conn != nil { + sup.mu.Unlock() + t.Fatal("connector running without credentials") + } + sup.mu.Unlock() + + if err := SaveCredentials(&Credentials{ + CloudURL: "http://127.0.0.1:1", DeviceID: "dev-1", DeviceToken: "tok", DeviceName: "t", + }); err != nil { + t.Fatal(err) + } + sup.SyncCredentials() + sup.mu.Lock() + if sup.conn == nil { + sup.mu.Unlock() + t.Fatal("connector not started after credentials appeared") + } + sup.mu.Unlock() + + if err := DeleteCredentials(); err != nil { + t.Fatal(err) + } + sup.SyncCredentials() + sup.mu.Lock() + if sup.conn != nil { + sup.mu.Unlock() + t.Fatal("connector still running after credentials removed") + } + sup.mu.Unlock() +} diff --git a/internal/cloud/pairings.go b/internal/cloud/pairings.go new file mode 100644 index 00000000..96c75c1d --- /dev/null +++ b/internal/cloud/pairings.go @@ -0,0 +1,89 @@ +// pairings.go implements the device-side pairing approval endpoints +// (M5 task book, device-token authenticated, under /internal/v1/device): +// +// GET /internal/v1/device/pairings?status=pending → {pairings:[...]} +// GET /internal/v1/device/pairings/{id} → pairing (incl. pubkey) +// POST /internal/v1/device/pairings/{id}/respond → {approve, wrap?} +// +// The pairing requester (console browser / mobile) generated a P-256 key pair +// and sent its SPKI DER base64 as `pubkey`; on approval the device wraps the +// CEK for that key (see crypto.go WrapCEK) and returns it via respond. +package cloud + +import ( + "context" + "fmt" + "net/url" +) + +// Pairing is one pairing request as seen by the device. +type Pairing struct { + ID string `json:"id"` + Label string `json:"label"` + PubKey string `json:"pubkey"` // requester P-256 public key, base64 SPKI DER + Status string `json:"status"` // "pending" | "approved" | "denied" + CreatedAt string `json:"created_at"` +} + +// ListPairings queries pairing requests, optionally filtered by status +// (empty = all): GET /internal/v1/device/pairings[?status=pending]. +func (c *Client) ListPairings(ctx context.Context, token, status string) ([]Pairing, error) { + path := "/internal/v1/device/pairings" + if status != "" { + path += "?status=" + url.QueryEscape(status) + } + var out struct { + Pairings []Pairing `json:"pairings"` + } + if _, err := c.get(ctx, path, token, &out); err != nil { + return nil, err + } + return out.Pairings, nil +} + +// GetPairing fetches one pairing request (including the requester pubkey): +// GET /internal/v1/device/pairings/{id}. +func (c *Client) GetPairing(ctx context.Context, token, id string) (*Pairing, error) { + var out Pairing + if _, err := c.get(ctx, "/internal/v1/device/pairings/"+url.PathEscape(id), token, &out); err != nil { + return nil, err + } + if out.ID == "" { + return nil, fmt.Errorf("pairing %s: empty response from %s", id, c.BaseURL) + } + return &out, nil +} + +// RespondPairing approves or denies a pairing request: +// POST /internal/v1/device/pairings/{id}/respond. wrap carries the ECIES- +// wrapped CEK and is required on approval, nil on denial. +func (c *Client) RespondPairing(ctx context.Context, token, id string, approve bool, wrap *CEKWrap) error { + body := struct { + Approve bool `json:"approve"` + Wrap *CEKWrap `json:"wrap,omitempty"` + }{Approve: approve, Wrap: wrap} + return c.post(ctx, "/internal/v1/device/pairings/"+url.PathEscape(id)+"/respond", token, body, nil) +} + +// PairingOffer is the answer of POST /internal/v1/device/pairing-offers +// (M11-W3): a short-lived, single-use secret the device renders as a QR code; +// a mobile client claims it to create a pairing request flagged with the +// offer_id, which the device then auto-approves (scan-to-pair). +type PairingOffer struct { + OfferID string `json:"offer_id"` + Secret string `json:"secret"` + ExpiresAt string `json:"expires_at"` // RFC 3339 +} + +// CreatePairingOffer mints a pairing offer: +// POST /internal/v1/device/pairing-offers (Bearer device token, empty body). +func (c *Client) CreatePairingOffer(ctx context.Context, token string) (*PairingOffer, error) { + var out PairingOffer + if err := c.post(ctx, "/internal/v1/device/pairing-offers", token, map[string]string{}, &out); err != nil { + return nil, err + } + if out.OfferID == "" || out.Secret == "" { + return nil, fmt.Errorf("incomplete pairing offer response from %s", c.BaseURL) + } + return &out, nil +} diff --git a/internal/cloud/pairings_test.go b/internal/cloud/pairings_test.go new file mode 100644 index 00000000..97b1e17c --- /dev/null +++ b/internal/cloud/pairings_test.go @@ -0,0 +1,122 @@ +package cloud + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPairingEndpoints(t *testing.T) { + pending := Pairing{ID: "p1", Label: "chrome on mac", PubKey: "cHVia2V5", Status: "pending", CreatedAt: "2026-07-20T10:00:00Z"} + var gotRespond struct { + called bool + Approve bool `json:"approve"` + Wrap *CEKWrap `json:"wrap"` + } + setRespond := func(approve bool, wrap *CEKWrap) { + gotRespond.called = true + gotRespond.Approve = approve + gotRespond.Wrap = wrap + } + mux := http.NewServeMux() + mux.HandleFunc("GET /internal/v1/device/pairings", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("pairings: Authorization = %q", r.Header.Get("Authorization")) + } + if r.URL.Query().Get("status") != "pending" { + t.Errorf("pairings: status query = %q, want pending", r.URL.Query().Get("status")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"pairings": []Pairing{pending}}) + }) + mux.HandleFunc("GET /internal/v1/device/pairings/{id}", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("id") != "p1" { + t.Errorf("get pairing id = %q, want p1", r.PathValue("id")) + } + _ = json.NewEncoder(w).Encode(pending) + }) + mux.HandleFunc("POST /internal/v1/device/pairings/{id}/respond", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Approve bool `json:"approve"` + Wrap *CEKWrap `json:"wrap"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("respond: decode body: %v", err) + } + setRespond(body.Approve, body.Wrap) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + client := NewClient(srv.URL) + ctx := context.Background() + + list, err := client.ListPairings(ctx, "tok", "pending") + if err != nil { + t.Fatalf("ListPairings: %v", err) + } + if len(list) != 1 || list[0].ID != "p1" || list[0].PubKey != "cHVia2V5" { + t.Fatalf("ListPairings = %+v", list) + } + + got, err := client.GetPairing(ctx, "tok", "p1") + if err != nil { + t.Fatalf("GetPairing: %v", err) + } + if got.Label != "chrome on mac" || got.CreatedAt == "" { + t.Fatalf("GetPairing = %+v", got) + } + + wrap := &CEKWrap{EphemeralPubKey: "ZXBo", Nonce: "bm9uY2U=", CT: "Y3Q="} + if err := client.RespondPairing(ctx, "tok", "p1", true, wrap); err != nil { + t.Fatalf("RespondPairing approve: %v", err) + } + if !gotRespond.called || !gotRespond.Approve || gotRespond.Wrap == nil || gotRespond.Wrap.EphemeralPubKey != "ZXBo" { + t.Fatalf("approve respond body = %+v", gotRespond) + } + + if err := client.RespondPairing(ctx, "tok", "p1", false, nil); err != nil { + t.Fatalf("RespondPairing deny: %v", err) + } + if gotRespond.Approve || gotRespond.Wrap != nil { + t.Fatalf("deny respond body = %+v, want approve=false and no wrap", gotRespond) + } +} + +func TestCreatePairingOffer(t *testing.T) { + var gotAuth string + mux := http.NewServeMux() + mux.HandleFunc("POST /internal/v1/device/pairing-offers", func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]string{ + "offer_id": "of-1", + "secret": "s3cret", + "expires_at": "2026-07-21T04:00:00Z", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + offer, err := NewClient(srv.URL).CreatePairingOffer(context.Background(), "tok") + if err != nil { + t.Fatalf("CreatePairingOffer: %v", err) + } + if gotAuth != "Bearer tok" { + t.Errorf("Authorization = %q, want Bearer tok", gotAuth) + } + if offer.OfferID != "of-1" || offer.Secret != "s3cret" || offer.ExpiresAt != "2026-07-21T04:00:00Z" { + t.Fatalf("offer = %+v", offer) + } +} + +func TestCreatePairingOfferIncomplete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"offer_id": "of-1"}) + })) + defer srv.Close() + if _, err := NewClient(srv.URL).CreatePairingOffer(context.Background(), "tok"); err == nil { + t.Fatal("CreatePairingOffer accepted a response without secret, want error") + } +} diff --git a/internal/cloud/relay.go b/internal/cloud/relay.go new file mode 100644 index 00000000..2a2010ee --- /dev/null +++ b/internal/cloud/relay.go @@ -0,0 +1,155 @@ +// relay.go implements the jcode ↔ orchestrator device-relay protocol +// (cloud/docs/17-jcode-device-relay.md §4): long-poll command delivery, +// command acks, heartbeats, session-index upserts and event uploads. +// +// Payload fields are opaque to the server: since M5 they are E2E envelopes +// (see crypto.go) whenever the device's CEK cipher is active, plaintext +// during the pre-CEK grey period. Sealing/opening happens in the Connector +// (sealUplink / openDownlink), not in these transport helpers. +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// DeviceCommand is one downlink command delivered by the poll endpoint. +type DeviceCommand struct { + ID string `json:"id"` + Kind string `json:"kind"` // chat.send | chat.stop | approval.respond | … + SessionID string `json:"session_id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// CommandAck is the uplink execution receipt for one command. +type CommandAck struct { + Status string `json:"status"` // "ok" | "error" + Result any `json:"result,omitempty"` +} + +// SessionUpsert mirrors one local session's index entry to the cloud. +type SessionUpsert struct { + SessionID string `json:"session_id"` + Status string `json:"status"` // "running" | "idle" + Meta json.RawMessage `json:"meta"` // SessionMeta JSON, as-is +} + +// SessionSeqInfo is the server's per-session high-water mark, returned by the +// sessions upsert so the event pump can resume seq numbering after a restart. +type SessionSeqInfo struct { + SessionID string `json:"session_id"` + LastSeq int64 `json:"last_seq"` +} + +// SessionsUpsertResponse is the answer of POST /internal/v1/device/sessions. +type SessionsUpsertResponse struct { + Sessions []SessionSeqInfo `json:"sessions"` +} + +// EventUpload is one durable event in a batch upload. +type EventUpload struct { + Seq int64 `json:"seq"` + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// EventsUploadResponse is the answer of POST +// /internal/v1/device/sessions/{sid}/events. Conflicted lists seqs the server +// already had (skipped); MaxSeq is the server's high-water mark for the +// session and is used to resync the local allocator. +type EventsUploadResponse struct { + Accepted []int64 `json:"accepted"` + Conflicted []int64 `json:"conflicted"` + MaxSeq int64 `json:"max_seq"` +} + +// Heartbeat keeps the device marked online: POST +// /internal/v1/device/heartbeat (empty body). +func (c *Client) Heartbeat(ctx context.Context, token string) error { + return c.post(ctx, "/internal/v1/device/heartbeat", token, map[string]string{}, nil) +} + +// PollCommands long-polls the downlink queue: GET +// /internal/v1/device/poll?wait=. ok is false on a 204 (no commands), +// in which case the caller should poll again immediately. +func (c *Client) PollCommands(ctx context.Context, token string, wait time.Duration) (cmds []DeviceCommand, ok bool, err error) { + path := "/internal/v1/device/poll?wait=" + url.QueryEscape(wait.String()) + var out struct { + Commands []DeviceCommand `json:"commands"` + } + status, err := c.get(ctx, path, token, &out) + if err != nil { + return nil, false, err + } + if status == http.StatusNoContent { + return nil, false, nil + } + return out.Commands, true, nil +} + +// AckCommand reports a command's execution result: POST +// /internal/v1/device/commands/{id}/ack. +func (c *Client) AckCommand(ctx context.Context, token, id string, ack CommandAck) error { + return c.post(ctx, "/internal/v1/device/commands/"+url.PathEscape(id)+"/ack", token, ack, nil) +} + +// UpsertSessions mirrors the local session index to the cloud: POST +// /internal/v1/device/sessions. The response carries each session's last_seq +// for event-pump seq resumption. capabilities is the M12 device-capabilities +// mirror (DeviceCapabilities JSON, sealed when the CEK cipher is active), +// stored by the orchestrator in devices.capabilities; nil omits the field. +func (c *Client) UpsertSessions(ctx context.Context, token string, sessions []SessionUpsert, capabilities json.RawMessage) (*SessionsUpsertResponse, error) { + var out SessionsUpsertResponse + body := struct { + Sessions []SessionUpsert `json:"sessions"` + Capabilities json.RawMessage `json:"capabilities,omitempty"` + }{Sessions: sessions, Capabilities: capabilities} + if err := c.post(ctx, "/internal/v1/device/sessions", token, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UploadEvents appends a batch of durable events: POST +// /internal/v1/device/sessions/{sid}/events. Idempotent — the server skips +// (sid, seq) pairs it already holds and reports them as conflicted. +func (c *Client) UploadEvents(ctx context.Context, token, sid string, events []EventUpload) (*EventsUploadResponse, error) { + var out EventsUploadResponse + body := struct { + Events []EventUpload `json:"events"` + }{Events: events} + if err := c.post(ctx, "/internal/v1/device/sessions/"+url.PathEscape(sid)+"/events", token, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SendEphemeral forwards one ephemeral (token-level) event: POST +// /internal/v1/device/sessions/{sid}/ephemeral. Fire-and-forget — failures are +// dropped by the caller, never retried. +func (c *Client) SendEphemeral(ctx context.Context, token, sid, kind string, payload json.RawMessage) error { + body := struct { + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` + }{Kind: kind, Payload: payload} + return c.post(ctx, "/internal/v1/device/sessions/"+url.PathEscape(sid)+"/ephemeral", token, body, nil) +} + +// ShouldConnect reports whether the relay connector should start: the device +// must be logged in (creds present with a token) and auto_connect must not be +// explicitly disabled. Kept pure so the startup gate is unit-testable. +func ShouldConnect(autoConnect bool, creds *Credentials) bool { + if !autoConnect || creds == nil { + return false + } + return creds.DeviceToken != "" +} + +// errUnexpectedStatus is a small helper for local-control-plane calls. +func errUnexpectedStatus(api string, status int, body string) error { + return fmt.Errorf("%s: unexpected status %d: %s", api, status, body) +} diff --git a/internal/cloud/sessions.go b/internal/cloud/sessions.go new file mode 100644 index 00000000..617e0464 --- /dev/null +++ b/internal/cloud/sessions.go @@ -0,0 +1,156 @@ +// sessions.go mirrors the local session index (~/.jcode/sessions/session.json) +// to the cloud: one full upsert at startup, then an upsert whenever the index +// file changes (detected by mtime polling — deliberately no fsnotify). The +// upsert response carries each session's last_seq, which seeds the event +// pump's per-session seq allocator (续号). +package cloud + +import ( + "context" + "encoding/json" + "os" + "time" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/session" +) + +// collectSessions builds the upsert list from the local session index. +// Status is "running" only for sessions the local web layer marked running; +// everything else reports "idle". Meta is the SessionMeta JSON, sealed into +// an E2E envelope when the CEK cipher is active (plaintext otherwise — the +// server stores it opaquely either way). +func (c *Connector) collectSessions() ([]SessionUpsert, error) { + listFn := c.cfg.ListSessionsFn + if listFn == nil { + listFn = session.ListAllSessions + } + all, err := listFn() + if err != nil { + return nil, err + } + upserts := make([]SessionUpsert, 0, len(all)) + for _, metas := range all { + for _, m := range metas { + if m.UUID == "" { + continue + } + // M19: only sessions with an explicit sync opt-in are reported. + // Unsynced sessions simply never appear cloud-side; nothing is + // deleted remotely either — history uploaded while the session was + // enabled stays on the orchestrator when the user turns sync off. + if !c.syncEnabled(m.UUID) { + continue + } + status := "idle" + if m.Status == "running" { + status = "running" + } + metaJSON, err := json.Marshal(m) + if err != nil { + continue + } + upserts = append(upserts, SessionUpsert{ + SessionID: m.UUID, + Status: status, + Meta: c.sealUplink(metaJSON), + }) + } + } + return upserts, nil +} + +// syncSessions performs one upsert round and seeds the seq allocator from the +// server's last_seq answers. The M12 capabilities mirror rides the same +// request (top-level `capabilities` field), sealed like the session meta when +// the CEK cipher is active. Capabilities collection is best-effort: a failure +// there must not fail the session sync. +func (c *Connector) syncSessions(ctx context.Context) error { + upserts, err := c.collectSessions() + if err != nil { + return err + } + var capsJSON json.RawMessage + if caps := c.collectCapabilities(ctx); caps != nil { + if data, err := json.Marshal(caps); err == nil { + capsJSON = c.sealUplink(data) + } + } + resp, err := c.client.UpsertSessions(ctx, c.token, upserts, capsJSON) + if err != nil { + return err + } + for _, s := range resp.Sessions { + c.seq.Seed(s.SessionID, s.LastSeq) + } + c.logf("session index synced (%d sessions)", len(upserts)) + return nil +} + +// sessionSyncLoop re-upserts whenever the session index file's mtime changes. +// It also watches the M19 sync store (~/.jcode/cloud-sessions.json): toggling +// a session's sync switch does not touch the session index, so without this +// an opt-in would only surface on the next index write — with it the freshly +// enabled session's metadata is upserted within one poll interval. +func (c *Connector) sessionSyncLoop(ctx context.Context) { + pathFn := c.cfg.IndexPathFn + if pathFn == nil { + pathFn = config.SessionsIndexPath + } + path, err := pathFn() + if err != nil { + c.logf("session index path unavailable, index sync disabled: %v", err) + return + } + + // Baseline: Run already did the initial full upsert, so only react to + // changes after this point. + lastMod := time.Time{} + if fi, err := os.Stat(path); err == nil { + lastMod = fi.ModTime() + } + + // Sync-store watch baseline (empty when the gate failed to load — nothing + // syncs anyway, so there is nothing to react to). + syncPath := "" + var syncMod time.Time + var syncSize int64 = -1 + if store := c.syncGate(); store != nil { + syncPath = store.Path() + if fi, err := os.Stat(syncPath); err == nil { + syncMod, syncSize = fi.ModTime(), fi.Size() + } + } + + ticker := time.NewTicker(c.indexPollInterval()) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + changed := false + if fi, err := os.Stat(path); err == nil { + if !fi.ModTime().Equal(lastMod) { + lastMod = fi.ModTime() + changed = true + } + } + // index may not exist yet: not an error, just no change. + if syncPath != "" { + if fi, err := os.Stat(syncPath); err == nil { + if !fi.ModTime().Equal(syncMod) || fi.Size() != syncSize { + syncMod, syncSize = fi.ModTime(), fi.Size() + changed = true + } + } + } + if !changed { + continue + } + if err := c.syncSessions(ctx); err != nil && ctx.Err() == nil { + c.logf("session upsert failed: %v", err) + } + } + } +} diff --git a/internal/cloud/supervisor.go b/internal/cloud/supervisor.go new file mode 100644 index 00000000..97ddfa72 --- /dev/null +++ b/internal/cloud/supervisor.go @@ -0,0 +1,292 @@ +// supervisor.go owns the cloud relay connector lifecycle so the relay can be +// stopped/started without a process restart (the settings auto_connect toggle +// hot-applies), and exposes the live Status served at GET /api/cloud/status. +package cloud + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/cnjack/jcode/internal/config" +) + +// Status is the JSON snapshot of the cloud relay served by the web API. +type Status struct { + LoggedIn bool `json:"logged_in"` + AutoConnect bool `json:"auto_connect"` + State string `json:"state"` // "offline" | "connecting" | "online" | "error" + DeviceName string `json:"device_name"` + CloudURL string `json:"cloud_url"` + Error string `json:"error,omitempty"` +} + +// Supervisor owns the relay Connector lifecycle: Start launches the connector +// when the device is logged in with cloud.auto_connect enabled, SetAutoConnect +// persists and hot-applies the toggle, and Status reports the live snapshot. +// Like the connector itself it is strictly best-effort — failures are logged +// warnings, never errors that could affect the web server. All methods are +// safe for concurrent use. +type Supervisor struct { + cfg *config.Config + port int + webToken string + + // Version is the jcode version reported at device register. It lives in + // the command package (import cycle), so callers set it before Start. + Version string + + mu sync.Mutex + rootCtx context.Context // web server shutdown context, captured by Start + creds *Credentials // credentials the running connector was started with + conn *Connector + cancel context.CancelFunc +} + +// NewSupervisor returns a Supervisor bound to cfg (mutated live by +// SetAutoConnect), the local web control plane port and its auth token. +func NewSupervisor(cfg *config.Config, port int, webToken string) *Supervisor { + return &Supervisor{cfg: cfg, port: port, webToken: webToken} +} + +// Start applies the current config/credentials: when the device is logged in +// and auto_connect is enabled the connector is launched under a child of ctx +// (web server shutdown tears it down); otherwise the supervisor stays idle. +func (s *Supervisor) Start(ctx context.Context) { + creds, err := LoadCredentials() + if err != nil { + config.Logger().Printf("[cloud] failed to load credentials, relay connector disabled: %v", err) + } + s.mu.Lock() + defer s.mu.Unlock() + s.rootCtx = ctx + s.creds = creds + if ShouldConnect(config.CloudAutoConnect(s.cfg), creds) { + s.startLocked() + } +} + +// Status snapshots the relay state. Credentials are re-read from disk so a +// login/logout from another process is reflected without a restart. +func (s *Supervisor) Status() Status { + creds, _ := LoadCredentials() + st := s.baseStatus(creds) + if !st.LoggedIn || !st.AutoConnect { + return st + } + s.mu.Lock() + conn := s.conn + s.mu.Unlock() + if conn == nil { + return st + } + st.State, st.Error = conn.Status() + return st +} + +// baseStatus builds the Status without any live connector state ("offline"). +// device_name falls back to the OS hostname; cloud_url follows the resolution +// chain config.cloud.url → credentials URL → DefaultCloudURL. +func (s *Supervisor) baseStatus(creds *Credentials) Status { + loggedIn := creds != nil && creds.DeviceToken != "" + deviceName := "" + if creds != nil { + deviceName = creds.DeviceName + } + if deviceName == "" { + deviceName, _ = os.Hostname() + } + return Status{ + LoggedIn: loggedIn, + AutoConnect: config.CloudAutoConnect(s.cfg), + State: StateOffline, + DeviceName: deviceName, + CloudURL: s.cloudURL(creds), + } +} + +// SetAutoConnect persists cloud.auto_connect (preserving the stored +// Enabled/URL/E2EE fields, same read/modify/write pattern as `jcode login`) +// and hot-applies it: enabling starts the connector when credentials are +// valid, disabling stops it. On a SaveConfig failure the in-memory config is +// rolled back and the error returned. +func (s *Supervisor) SetAutoConnect(enabled bool) error { + previous := s.cfg.CloudSettings() + s.cfg.SetCloud(&config.CloudConfig{ + Enabled: previous.Enabled, + URL: previous.URL, + AutoConnect: &enabled, + E2EE: previous.E2EE, + SyncDefault: previous.SyncDefault, + }) + if err := config.SaveConfig(s.cfg); err != nil { + if previous == (config.CloudConfig{}) { + s.cfg.SetCloud(nil) + } else { + s.cfg.SetCloud(&previous) + } + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if !enabled { + s.stopLocked() + return nil + } + if s.rootCtx == nil || s.conn != nil { + return nil + } + // Re-read credentials: a login may have happened after Start. + creds, err := LoadCredentials() + if err != nil { + config.Logger().Printf("[cloud] failed to load credentials, relay connector disabled: %v", err) + return nil + } + s.creds = creds + if ShouldConnect(true, creds) { + s.startLocked() + } + return nil +} + +// BuildConnector is the pure start decision + construction behind Start: nil +// when the connector should not run (not logged in, or auto_connect explicitly +// disabled). Exported so the command package reuses one code path. +func (s *Supervisor) BuildConnector(creds *Credentials) *Connector { + if !ShouldConnect(config.CloudAutoConnect(s.cfg), creds) { + return nil + } + cloudURL := s.cloudURL(creds) + config.Logger().Printf("[cloud] starting relay connector (cloud=%s, device=%s)", cloudURL, creds.DeviceID) + return NewConnector(ConnectorConfig{ + CloudURL: cloudURL, + Credentials: creds, + // The control plane is always this process's own web server on + // loopback, regardless of the --host bind. + LocalBase: fmt.Sprintf("http://127.0.0.1:%d", s.port), + LocalToken: s.webToken, + Version: s.Version, + // cloud.e2ee=false keeps the M5 plaintext grey path: the connector + // skips CEK initialization entirely. + CipherDisabled: !config.CloudE2EE(s.cfg), + }) +} + +// cloudURL resolves the orchestrator address: config.cloud.url, then the +// credentials' URL, then the public default. +func (s *Supervisor) cloudURL(creds *Credentials) string { + u := s.cfg.CloudSettings().URL + if u == "" && creds != nil { + u = creds.CloudURL + } + if u == "" { + u = DefaultCloudURL + } + return u +} + +// startLocked builds and launches the connector. The caller holds s.mu and +// has already applied the ShouldConnect gate. +func (s *Supervisor) startLocked() { + conn := s.BuildConnector(s.creds) + if conn == nil { + return + } + runCtx, cancel := context.WithCancel(s.rootCtx) + s.conn = conn + s.cancel = cancel + go conn.Run(runCtx) +} + +// stopLocked cancels the running connector (if any). Run returns +// asynchronously; the connector flips its own state to offline on exit. +func (s *Supervisor) stopLocked() { + if s.cancel != nil { + s.cancel() + } + s.conn = nil + s.cancel = nil +} + +// SyncCredentials re-reads the on-disk credentials after a login/logout (web +// API or another process) and reconciles the connector: logged out (or +// auto_connect off) stops it; fresh or replaced credentials (re)start it. +// Like everything cloud-side it is best-effort — a credentials read failure +// is logged and leaves the current state untouched. +func (s *Supervisor) SyncCredentials() { + creds, err := LoadCredentials() + if err != nil { + config.Logger().Printf("[cloud] failed to load credentials, relay connector state unchanged: %v", err) + return + } + s.mu.Lock() + defer s.mu.Unlock() + previous := s.creds + s.creds = creds + if !ShouldConnect(config.CloudAutoConnect(s.cfg), creds) { + s.stopLocked() + return + } + if s.rootCtx == nil { + return + } + // Restart when the identity changed under a running connector (logout + + // login as a different device); otherwise start only when idle. + identityChanged := previous == nil || + previous.DeviceID != creds.DeviceID || previous.DeviceToken != creds.DeviceToken + if identityChanged { + s.stopLocked() + } + if s.conn == nil { + s.startLocked() + } +} + +// --- pairing inbox delegation (M11-W1 web approval endpoints) --- + +// PendingPairings returns the live connector's pending pairing inbox, empty +// when the connector is not running. +func (s *Supervisor) PendingPairings() []PendingPairing { + s.mu.Lock() + conn := s.conn + s.mu.Unlock() + if conn == nil { + return nil + } + return conn.PendingPairings() +} + +// LastPaired reports the live connector's most recent pairing approval. +func (s *Supervisor) LastPaired() (PairedInfo, bool) { + s.mu.Lock() + conn := s.conn + s.mu.Unlock() + if conn == nil { + return PairedInfo{}, false + } + return conn.LastPaired() +} + +// ApprovePairing approves a pending pairing through the live connector. +func (s *Supervisor) ApprovePairing(ctx context.Context, id string) error { + s.mu.Lock() + conn := s.conn + s.mu.Unlock() + if conn == nil { + return fmt.Errorf("cloud relay is not connected") + } + return conn.ApprovePairing(ctx, id) +} + +// DenyPairing denies a pending pairing through the live connector. +func (s *Supervisor) DenyPairing(ctx context.Context, id string) error { + s.mu.Lock() + conn := s.conn + s.mu.Unlock() + if conn == nil { + return fmt.Errorf("cloud relay is not connected") + } + return conn.DenyPairing(ctx, id) +} diff --git a/internal/cloud/supervisor_test.go b/internal/cloud/supervisor_test.go new file mode 100644 index 00000000..99b78ceb --- /dev/null +++ b/internal/cloud/supervisor_test.go @@ -0,0 +1,310 @@ +package cloud + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cnjack/jcode/internal/config" +) + +// supervisorTestCreds writes a logged-in device identity to the temp HOME. +// CloudURL points at an unreachable loopback address so any started connector +// exercises the offline-retry path without network access. +func supervisorTestCreds(t *testing.T) { + t.Helper() + creds := &Credentials{ + CloudURL: "http://127.0.0.1:1", + DeviceID: "dev-1", + DeviceToken: "tok", + DeviceName: "testbox", + } + if err := SaveCredentials(creds); err != nil { + t.Fatal(err) + } +} + +func readPersistedAutoConnect(t *testing.T, home string) *bool { + t.Helper() + data, err := os.ReadFile(filepath.Join(home, ".jcode", "config.json")) + if err != nil { + t.Fatal(err) + } + var disk struct { + Cloud *struct { + AutoConnect *bool `json:"auto_connect"` + } `json:"cloud"` + } + if err := json.Unmarshal(data, &disk); err != nil { + t.Fatal(err) + } + if disk.Cloud == nil { + return nil + } + return disk.Cloud.AutoConnect +} + +// M9-1 scenario 1: no cloud.json → not logged in, offline, no connector. +func TestSupervisorStatusNoCredentials(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + sup := NewSupervisor(&config.Config{}, 8080, "") + sup.Start(context.Background()) + + sup.mu.Lock() + started := sup.conn != nil + sup.mu.Unlock() + if started { + t.Fatal("connector started without credentials") + } + + st := sup.Status() + if st.LoggedIn || st.State != StateOffline || st.Error != "" { + t.Fatalf("status = %+v, want logged_in=false state=offline", st) + } + if !st.AutoConnect { + t.Error("auto_connect must default to true when unset") + } + if st.CloudURL != DefaultCloudURL { + t.Errorf("cloud_url = %q, want %q", st.CloudURL, DefaultCloudURL) + } + if st.DeviceName == "" { + t.Error("device_name must fall back to the OS hostname") + } +} + +// M9-1 scenario 2: logged in but auto_connect=false → offline, no connector. +func TestSupervisorStatusAutoConnectDisabled(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + supervisorTestCreds(t) + off := false + cfg := &config.Config{} + cfg.SetCloud(&config.CloudConfig{Enabled: true, URL: "https://cloud.example.com", AutoConnect: &off}) + + sup := NewSupervisor(cfg, 8080, "") + sup.Start(context.Background()) + + sup.mu.Lock() + started := sup.conn != nil + sup.mu.Unlock() + if started { + t.Fatal("connector started despite auto_connect=false") + } + + st := sup.Status() + if !st.LoggedIn || st.AutoConnect || st.State != StateOffline { + t.Fatalf("status = %+v, want logged_in=true auto_connect=false state=offline", st) + } + if st.DeviceName != "testbox" { + t.Errorf("device_name = %q, want testbox (from credentials)", st.DeviceName) + } + if st.CloudURL != "https://cloud.example.com" { + t.Errorf("cloud_url = %q, want config.cloud.url", st.CloudURL) + } +} + +// SetAutoConnect persists the toggle and hot-applies it: enabling starts the +// connector without a restart, disabling stops it. M9-1 scenario 3: the cloud +// is unreachable — start must not block and the error must surface in Status. +func TestSupervisorSetAutoConnectHotApply(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + supervisorTestCreds(t) + off := false + cfg := &config.Config{} + // E2EE off keeps the test hermetic (no CEK written to the temp HOME). + cfg.SetCloud(&config.CloudConfig{Enabled: true, AutoConnect: &off, E2EE: &off}) + + sup := NewSupervisor(cfg, 8080, "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sup.Start(ctx) + sup.mu.Lock() + started := sup.conn != nil + sup.mu.Unlock() + if started { + t.Fatal("connector started despite auto_connect=false") + } + + // Enable: persists + starts the connector synchronously (the register + // retry loop runs in the background against the unreachable cloud). + if err := sup.SetAutoConnect(true); err != nil { + t.Fatalf("SetAutoConnect(true): %v", err) + } + if got := readPersistedAutoConnect(t, home); got == nil || !*got { + t.Fatalf("persisted auto_connect = %v, want true", got) + } + sup.mu.Lock() + conn := sup.conn + sup.mu.Unlock() + if conn == nil { + t.Fatal("enable did not start the connector") + } + waitFor(t, func() bool { + st := sup.Status() + return st.State == StateError && st.Error != "" + }, "unreachable cloud surfaces state=error with a message") + + // Disable: persists + stops the connector; status falls back to offline. + if err := sup.SetAutoConnect(false); err != nil { + t.Fatalf("SetAutoConnect(false): %v", err) + } + if got := readPersistedAutoConnect(t, home); got == nil || *got { + t.Fatalf("persisted auto_connect = %v, want false", got) + } + sup.mu.Lock() + stopped := sup.conn == nil + sup.mu.Unlock() + if !stopped { + t.Fatal("disable did not stop the connector") + } + if st := sup.Status(); st.State != StateOffline || st.Error != "" { + t.Fatalf("status after disable = %+v, want offline", st) + } + + // Re-enable: hot start again without a process restart. + if err := sup.SetAutoConnect(true); err != nil { + t.Fatalf("re-enable: %v", err) + } + sup.mu.Lock() + restarted := sup.conn != nil + sup.mu.Unlock() + if !restarted { + t.Fatal("re-enable did not restart the connector") + } +} + +// A SaveConfig failure must roll the in-memory config back and return the +// error (the web layer turns it into a 500). +func TestSupervisorSetAutoConnectSaveFailureRollsBack(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Make ~/.jcode a regular file so SaveConfig cannot create config.json. + if err := os.WriteFile(filepath.Join(home, ".jcode"), []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + sup := NewSupervisor(cfg, 8080, "") + + if err := sup.SetAutoConnect(false); err == nil { + t.Fatal("want SaveConfig error") + } + if !config.CloudAutoConnect(cfg) { + t.Fatal("failed save changed the live auto_connect value") + } + if got := cfg.CloudSettings(); got != (config.CloudConfig{}) { + t.Fatalf("failed save replaced an absent Cloud block: %+v", got) + } +} + +// The register request carries the platform marker: desktop when the Tauri +// sidecar sets JCODE_DESKTOP=1, cli otherwise. +func TestDetectPlatform(t *testing.T) { + if v, ok := os.LookupEnv("JCODE_DESKTOP"); ok { + t.Skipf("JCODE_DESKTOP=%s set in the test environment", v) + } + if got := detectPlatform(); got != "cli" { + t.Errorf("detectPlatform() = %q, want cli", got) + } + t.Setenv("JCODE_DESKTOP", "1") + if got := detectPlatform(); got != "desktop" { + t.Errorf("detectPlatform() = %q, want desktop", got) + } +} + +func TestRegisterLoopSendsPlatform(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("JCODE_DESKTOP", "1") + var got RegisterDeviceRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") + if err := conn.registerLoop(context.Background()); err != nil { + t.Fatal(err) + } + if got.Platform != "desktop" { + t.Fatalf("register platform = %q, want desktop", got.Platform) + } + if state, _ := conn.Status(); state != StateOnline { + t.Fatalf("state after successful register = %q, want online", state) + } +} + +// The register request reports the connector's ACTUAL encryption state as +// `e2ee` (M13): true only with an active CEK cipher and no cloud.e2ee +// disable — the orchestrator gates plaintext downlink on this flag. +func TestRegisterLoopSendsE2EE(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cek := make([]byte, 32) + cipher, err := NewEnvelopeCipher(cek, 1) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + cipher *EnvelopeCipher + disabled bool + want bool + }{ + {"cipher active", cipher, false, true}, + {"cipher but cloud.e2ee=false", cipher, true, false}, + {"no cipher (CEK not initialized)", nil, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got RegisterDeviceRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + conn := newTestConnector(t, srv.URL, "http://127.0.0.1:1") + conn.cipher = tc.cipher + conn.cfg.CipherDisabled = tc.disabled + if err := conn.registerLoop(context.Background()); err != nil { + t.Fatal(err) + } + if got.E2EE != tc.want { + t.Fatalf("register e2ee = %v, want %v", got.E2EE, tc.want) + } + }) + } +} + +// Connector status transitions: connecting/error while the cloud is +// unreachable, offline after ctx cancel (graceful stop). +func TestConnectorStatusTransitions(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + conn := newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1") + if state, _ := conn.Status(); state != StateOffline { + t.Fatalf("state before Run = %q, want offline", state) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { conn.Run(ctx); close(done) }() + + waitFor(t, func() bool { + state, lastErr := conn.Status() + return state == StateError && lastErr != "" + }, "register failure surfaces state=error") + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Run did not return promptly after ctx cancel") + } + waitFor(t, func() bool { + state, _ := conn.Status() + return state == StateOffline + }, "state=offline after stop") +} diff --git a/internal/cloud/sync_filter_test.go b/internal/cloud/sync_filter_test.go new file mode 100644 index 00000000..82a309ea --- /dev/null +++ b/internal/cloud/sync_filter_test.go @@ -0,0 +1,183 @@ +// sync_filter_test.go covers the M19 per-session sync gate in the connector: +// session-upsert filtering, event-pump filtering (durable + ephemeral, no seq +// allocation for dropped events), and the enable/disable switch points. +package cloud + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +// collectSessions upserts only sessions with an explicit opt-in. +func TestCollectSessionsFiltersUnsynced(t *testing.T) { + conn := newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "/proj": { + {UUID: "s1", Status: "idle"}, // enabled via newTestConnector store + {UUID: "s3", Status: "idle"}, // unset → dropped + }, + }, nil + } + // s2 has an entry in the default test store; make it explicit-disabled. + if err := conn.syncStore.Set("s2", false); err != nil { + t.Fatal(err) + } + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "/proj": { + {UUID: "s1", Status: "idle"}, + {UUID: "s2", Status: "idle"}, // explicit disabled → dropped + {UUID: "s3", Status: "idle"}, // unset → dropped + }, + }, nil + } + + upserts, err := conn.collectSessions() + if err != nil { + t.Fatal(err) + } + if len(upserts) != 1 || upserts[0].SessionID != "s1" { + t.Fatalf("upserts = %+v, want only s1", upserts) + } +} + +// A nil/failed store fails closed: nothing is upserted. +func TestCollectSessionsFailsClosedWithoutStore(t *testing.T) { + conn := NewConnector(ConnectorConfig{ + CloudURL: "http://127.0.0.1:1", + Credentials: &Credentials{DeviceID: "dev-1", DeviceToken: "tok"}, + LocalBase: "http://127.0.0.1:1", + SyncStore: nil, // lazy load would hit the real HOME; force the failed state + }) + conn.syncStoreTried = true // simulate "load attempted and failed" + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{"/proj": {{UUID: "s1"}}}, nil + } + upserts, err := conn.collectSessions() + if err != nil { + t.Fatal(err) + } + if len(upserts) != 0 { + t.Fatalf("upserts = %+v, want empty (fail closed)", upserts) + } +} + +// The event pump drops durable AND ephemeral events of unsynced sessions and +// does NOT allocate seq numbers for them. +func TestEventPumpFiltersUnsynced(t *testing.T) { + mock := newMockCloud() + cloudSrv := httptest.NewServer(mock.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") // s1 enabled + batcher := newEventBatcher(conn) + ctx := context.Background() + + // s3 (unset): durable and ephemeral events both dropped. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_call", "s3", map[string]string{"name": "read"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s3", map[string]string{"text": "x"})) + // s1 (enabled): one durable event uploads. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_call", "s1", map[string]string{"name": "read"})) + batcher.flushAll(ctx) + + if got := mock.allEvents(); len(got) != 1 || got[0].Seq != 1 { + t.Fatalf("uploaded events = %+v, want exactly s1/seq=1", got) + } + mock.mu.Lock() + eph := len(mock.ephemeral) + mock.mu.Unlock() + if eph != 0 { + t.Errorf("ephemeral uploads = %d, want 0 (s3 dropped)", eph) + } + // No seq was allocated for the dropped session. + if got := conn.seq.Next("s3"); got != 1 { + t.Errorf("dropped session seq advanced to %d, want untouched (Next=1)", got) + } +} + +// Switch points: enabled→disabled stops uploads immediately (already +// uploaded history is untouched — nothing deletes it); disabled→enabled +// resumes from that point on with a gapless seq stream (the disabled window +// leaves no holes) and no historical backfill. +func TestEventPumpSyncToggleMidStream(t *testing.T) { + mock := newMockCloud() + cloudSrv := httptest.NewServer(mock.handler()) + t.Cleanup(cloudSrv.Close) + + conn := newTestConnector(t, cloudSrv.URL, "http://127.0.0.1:1") // s1 enabled + batcher := newEventBatcher(conn) + ctx := context.Background() + + send := func() { + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_call", "s1", map[string]string{"name": "read"})) + batcher.flushAll(ctx) + } + + send() // enabled: uploads seq 1 + if err := conn.syncStore.Set("s1", false); err != nil { + t.Fatal(err) + } + send() // disabled: dropped, no seq allocated + if err := conn.syncStore.Set("s1", true); err != nil { + t.Fatal(err) + } + send() // re-enabled: uploads seq 2 (no backfill, no gap) + + got := mock.allEvents() + if len(got) != 2 { + t.Fatalf("uploaded %d events, want 2 (the disabled window dropped one)", len(got)) + } + if got[0].Seq != 1 || got[1].Seq != 2 { + t.Errorf("seqs = [%d %d], want [1 2] (gapless across the toggle)", got[0].Seq, got[1].Seq) + } +} + +// Disabling mid-run also drops the accumulated agent_text buffer, so a +// re-enabled run's synthesized agent_message only covers text streamed after +// the re-enable point. +func TestEventPumpDisableDropsTextBuffer(t *testing.T) { + conn := newTestConnector(t, "http://127.0.0.1:1", "http://127.0.0.1:1") + batcher := newEventBatcher(conn) + ctx := context.Background() + + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "before-disable"})) + if err := conn.syncStore.Set("s1", false); err != nil { + t.Fatal(err) + } + // Any event while disabled clears the buffer. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "while-disabled"})) + if err := conn.syncStore.Set("s1", true); err != nil { + t.Fatal(err) + } + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_text", "s1", map[string]string{"text": "after-enable"})) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "agent_done", "s1", map[string]any{})) + + var synth *EventUpload + batcher.mu.Lock() + for _, ev := range batcher.pending["s1"] { + if ev.Kind == "agent_message" { + copy := ev + synth = © + } + } + batcher.mu.Unlock() + if synth == nil { + t.Fatal("agent_done must synthesize an agent_message from post-enable text") + } + var msg struct { + Data struct { + Text string `json:"text"` + } `json:"data"` + } + if err := json.Unmarshal(synth.Payload, &msg); err != nil { + t.Fatal(err) + } + if msg.Data.Text != "after-enable" { + t.Errorf("synthesized text = %q, want %q (pre-disable buffer dropped)", msg.Data.Text, "after-enable") + } +} diff --git a/internal/cloud/sync_store.go b/internal/cloud/sync_store.go new file mode 100644 index 00000000..254bf868 --- /dev/null +++ b/internal/cloud/sync_store.go @@ -0,0 +1,223 @@ +// sync_store.go persists the M19 per-session cloud-sync opt-in at +// ~/.jcode/cloud-sessions.json (0600): a flat {session_id: true|false} map. +// A session WITHOUT an entry is "unset" and never syncs (default OFF) — the +// global cloud.sync_default config is consulted only once, when the web layer +// stamps a brand-new session, so flipping the default never retroactively +// changes existing sessions. +// +// The web API and the relay connector hold separate SyncStore instances on +// the same file; both transparently re-read it when its mtime/size changes, +// so a toggle via the API takes effect in the connector without any explicit +// wiring (event filtering on the next event, metadata upsert on the next +// session-sync tick). +package cloud + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sync" + "time" +) + +const syncSessionsFile = "cloud-sessions.json" + +// SyncStore is the per-session cloud-sync state backed by one JSON file. +// All methods are safe for concurrent use. +type SyncStore struct { + path string + + mu sync.RWMutex + sessions map[string]bool + // modTime/size fingerprint of the last load, used by refresh to pick up + // external writes (the web API's own store instance). + modTime time.Time + size int64 +} + +// SyncStorePath returns the default store path (~/.jcode/cloud-sessions.json). +func SyncStorePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".jcode", syncSessionsFile), nil +} + +// LoadSyncStore opens the store at path. A missing file is not an error — it +// means "no session opted in yet". A parse/IO error IS returned: callers +// decide whether to fail closed (connector: nothing syncs) or surface it +// (web API: 500). +func LoadSyncStore(path string) (*SyncStore, error) { + s := &SyncStore{path: path, sessions: make(map[string]bool)} + if err := s.reload(); err != nil { + return nil, err + } + return s, nil +} + +// Path returns the store's backing file path (the connector's session-sync +// loop watches it for mtime changes). +func (s *SyncStore) Path() string { + return s.path +} + +// reload re-reads the file, replacing the in-memory map. Missing file = +// empty map. Callers must not hold s.mu. +func (s *SyncStore) reload() error { + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + s.mu.Lock() + s.sessions = make(map[string]bool) + s.modTime = time.Time{} + s.size = -1 + s.mu.Unlock() + return nil + } + return fmt.Errorf("failed to read sync store %s: %w", s.path, err) + } + var sessions map[string]bool + if err := json.Unmarshal(data, &sessions); err != nil { + return fmt.Errorf("failed to parse sync store %s: %w", s.path, err) + } + if sessions == nil { + sessions = make(map[string]bool) + } + fi, err := os.Stat(s.path) + if err != nil { + return fmt.Errorf("failed to stat sync store %s: %w", s.path, err) + } + s.mu.Lock() + s.sessions = sessions + s.modTime = fi.ModTime() + s.size = fi.Size() + s.mu.Unlock() + return nil +} + +// refresh reloads the file when it changed on disk since the last load +// (mtime or size). Failures keep the previous in-memory view — a half-written +// file must never flip sessions to unsynced mid-stream. +func (s *SyncStore) refresh() { + fi, err := os.Stat(s.path) + if err != nil { + return // missing/unreadable: keep the current view + } + s.mu.RLock() + same := fi.ModTime().Equal(s.modTime) && fi.Size() == s.size + s.mu.RUnlock() + if same { + return + } + _ = s.reload() +} + +// Enabled reports whether the session has an explicit sync opt-in. Unset +// sessions report false (default OFF). +func (s *SyncStore) Enabled(sessionID string) bool { + s.refresh() + s.mu.RLock() + defer s.mu.RUnlock() + return s.sessions[sessionID] +} + +// Snapshot returns a copy of the explicit per-session map. +func (s *SyncStore) Snapshot() map[string]bool { + s.refresh() + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]bool, len(s.sessions)) + for k, v := range s.sessions { + out[k] = v + } + return out +} + +// Has reports whether the session has an explicit entry (set vs unset). +func (s *SyncStore) Has(sessionID string) bool { + s.refresh() + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.sessions[sessionID] + return ok +} + +// Set records the session's sync state and persists the store atomically +// (0600), overwriting any existing entry. +func (s *SyncStore) Set(sessionID string, enabled bool) error { + s.refresh() // merge external writes before our read-modify-write + s.mu.Lock() + s.sessions[sessionID] = enabled + err := s.saveLocked() + s.mu.Unlock() + return err +} + +// SetIfAbsent records the session's sync state only when no explicit entry +// exists yet (session-creation stamping must never clobber a user toggle). +// It reports whether an entry was written. +func (s *SyncStore) SetIfAbsent(sessionID string, enabled bool) (bool, error) { + s.refresh() + s.mu.Lock() + if _, ok := s.sessions[sessionID]; ok { + s.mu.Unlock() + return false, nil + } + s.sessions[sessionID] = enabled + err := s.saveLocked() + s.mu.Unlock() + return true, err +} + +// saveLocked writes the map atomically (tmp + rename) with owner-only +// permissions. The caller holds s.mu. The in-memory fingerprint is updated +// so the next refresh does not re-read our own write. +func (s *SyncStore) saveLocked() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create config directory %s: %w", dir, err) + } + data, err := json.MarshalIndent(s.sessions, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal sync store: %w", err) + } + tmp, err := os.CreateTemp(dir, "."+syncSessionsFile+".tmp-*") + if err != nil { + return fmt.Errorf("failed to create temporary sync store in %s: %w", dir, err) + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + if tmpPath != "" { + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("failed to secure temporary sync store %s: %w", tmpPath, err) + } + if n, err := tmp.Write(data); err != nil { + return fmt.Errorf("failed to write temporary sync store %s: %w", tmpPath, err) + } else if n != len(data) { + return fmt.Errorf("failed to write temporary sync store %s: %w", tmpPath, io.ErrShortWrite) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("failed to sync temporary sync store %s: %w", tmpPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temporary sync store %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, s.path); err != nil { + return fmt.Errorf("failed to replace sync store %s: %w", s.path, err) + } + tmpPath = "" + if fi, err := os.Stat(s.path); err == nil { + s.modTime = fi.ModTime() + s.size = fi.Size() + } + return nil +} diff --git a/internal/cloud/sync_store_test.go b/internal/cloud/sync_store_test.go new file mode 100644 index 00000000..83de528f --- /dev/null +++ b/internal/cloud/sync_store_test.go @@ -0,0 +1,172 @@ +package cloud + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSyncStoreMissingFileIsEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + store, err := LoadSyncStore(path) + if err != nil { + t.Fatalf("missing file must not be an error: %v", err) + } + if store.Enabled("s1") { + t.Error("unset session must report disabled (default OFF)") + } + if store.Has("s1") { + t.Error("unset session must report Has=false") + } + if got := store.Snapshot(); len(got) != 0 { + t.Errorf("Snapshot() = %v, want empty", got) + } +} + +func TestSyncStoreRoundTripAndPermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + store, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if err := store.Set("s1", true); err != nil { + t.Fatal(err) + } + if err := store.Set("s2", false); err != nil { + t.Fatal(err) + } + if !store.Enabled("s1") || store.Enabled("s2") || store.Enabled("s3") { + t.Fatal("in-memory state wrong after Set") + } + + // Owner-only file permissions. + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Errorf("file perm = %o, want 600", perm) + } + + // A fresh instance on the same file sees the persisted state. + reloaded, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if !reloaded.Enabled("s1") { + t.Error("s1 must persist as enabled") + } + if reloaded.Enabled("s2") { + t.Error("s2 must persist as disabled") + } + if !reloaded.Has("s2") { + t.Error("s2 must keep its explicit (disabled) entry") + } +} + +func TestSyncStoreSetIfAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + store, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + written, err := store.SetIfAbsent("s1", true) + if err != nil || !written { + t.Fatalf("first SetIfAbsent = (%v, %v), want (true, nil)", written, err) + } + // An existing entry (the user's explicit toggle) is never clobbered. + if err := store.Set("s1", false); err != nil { + t.Fatal(err) + } + written, err = store.SetIfAbsent("s1", true) + if err != nil || written { + t.Fatalf("second SetIfAbsent = (%v, %v), want (false, nil)", written, err) + } + if store.Enabled("s1") { + t.Error("SetIfAbsent must not overwrite the explicit entry") + } +} + +// The connector and the web API hold separate store instances on the same +// file; an external write must become visible without a reload call. +func TestSyncStorePicksUpExternalWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + store, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if store.Enabled("s1") { + t.Fatal("precondition: s1 unset") + } + // External writer (the web API's own instance) enables the session. + other, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if err := other.Set("s1", true); err != nil { + t.Fatal(err) + } + // mtime granularity guard: make sure the write is detectably newer. + if fi, err := os.Stat(path); err == nil { + _ = os.Chtimes(path, fi.ModTime().Add(time.Second), fi.ModTime().Add(time.Second)) + } + if !store.Enabled("s1") { + t.Error("external Set must be picked up via mtime refresh") + } +} + +// A corrupt file fails the initial load; during refresh the previous +// in-memory view is kept (a half-written file must never flip sessions to +// unsynced mid-stream). +func TestSyncStoreCorruptFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadSyncStore(path); err == nil { + t.Fatal("corrupt file must fail the initial load") + } + + // Start healthy, then corrupt externally: the old view survives. + if err := os.WriteFile(path, []byte(`{"s1":true}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{garbage"), 0o600); err != nil { + t.Fatal(err) + } + if fi, err := os.Stat(path); err == nil { + _ = os.Chtimes(path, fi.ModTime().Add(time.Second), fi.ModTime().Add(time.Second)) + } + if !store.Enabled("s1") { + t.Error("refresh over a corrupt file must keep the previous view") + } +} + +// The on-disk shape is the documented flat {session_id: bool} map. +func TestSyncStoreFileShape(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloud-sessions.json") + store, err := LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if err := store.Set("abc", true); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var raw map[string]bool + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("store file must be a flat map: %v", err) + } + if !raw["abc"] || len(raw) != 1 { + t.Errorf("file content = %v", raw) + } +} diff --git a/internal/command/cloud.go b/internal/command/cloud.go new file mode 100644 index 00000000..116399d5 --- /dev/null +++ b/internal/command/cloud.go @@ -0,0 +1,346 @@ +package command + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// newCloudSupervisor builds the cloud relay supervisor: it owns the connector +// lifecycle (start on `jcode web` boot, live stop/start via the settings +// auto_connect toggle) and the status served at GET /api/cloud/status. The +// relay is strictly best-effort: every failure is logged as a warning and it +// never blocks or fails the web server. +func newCloudSupervisor(cfg *config.Config, port int, webToken string) *cloud.Supervisor { + sup := cloud.NewSupervisor(cfg, port, webToken) + sup.Version = Version + return sup +} + +// --- `jcode cloud` command group (M5: pairing approval + CEK management) --- + +// NewCloudCmd returns the `jcode cloud` command group: pairing approval and +// E2E key management against the jcloud orchestrator. Like `jcode login` it +// prints to plain stdout — no TUI involved. +func NewCloudCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cloud", + Short: "Manage the jcloud device relay (pairings, E2E key, status)", + } + cmd.AddCommand( + newCloudPairingsCmd(), + newCloudApproveCmd(), + newCloudDenyCmd(), + newCloudStatusCmd(), + newCloudKeyCmd(), + newCloudRotateKeyCmd(), + newCloudGuideCmd(), + ) + return cmd +} + +// newCloudGuideCmd prints the condensed quick-start (M7): the same core +// content as docs/cloud.md, for users who discover the feature from the CLI. +func newCloudGuideCmd() *cobra.Command { + return &cobra.Command{ + Use: "guide", + Short: "Print the cloud quick-start guide (condensed docs/cloud.md)", + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudGuide(cmd.OutOrStdout()) + }, + } +} + +func runCloudGuide(w io.Writer) error { + _, _ = fmt.Fprint(w, `jcode 云端(jcloud)快速上手 +============================ + +1. 登录设备 + jcode login 默认登录 https://cloud.j-code.net;浏览器确认后自动连接 + jcode login --cloud self-host 云端(必须 https;仅 localhost 开发允许 http) + jcode login --status 查看当前登录状态 + jcode logout 退出登录:吊销设备令牌并清除本地凭据 + +2. 远程使用 + 登录后,在 cloud 控制台(/devices)或手机 app 中选择设备:发起会话、 + 实时查看、停止运行、处理权限审批。设备离线时可翻看历史,但不能操作。 + +3. 新客户端配对(端到端加密) + 会话内容端到端加密,云端只见密文。新浏览器/手机首次使用需配对: + 在客户端发起后,于设备上 10 分钟内批准。 + jcode cloud pairings 查看待批准的配对请求 + jcode cloud approve 批准(拒绝对应 jcode cloud deny ) + +4. 密钥与恢复 + jcode cloud key show-phrase 显示 24 词恢复短语(请离线妥善保存) + jcode cloud key recover 全部设备丢失后,用短语恢复密钥 + jcode cloud rotate-key 更换密钥(已配对客户端需重新配对) + +5. 状态与开关 + jcode cloud status 云端地址 / 设备 id / 密钥代数 / 连通性 + 端到端加密默认开启;排查时可在 ~/.jcode/config.json 设置 + "cloud": { "e2ee": false } 并重启 jcode web(明文上行)。 + +完整文档见 docs/cloud.md。 +`) + return nil +} + +func newCloudPairingsCmd() *cobra.Command { + return &cobra.Command{ + Use: "pairings", + Short: "List pending pairing requests", + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudPairings(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func newCloudApproveCmd() *cobra.Command { + return &cobra.Command{ + Use: "approve ", + Short: "Approve a pairing request (wraps the CEK for the requester)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudApprove(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newCloudDenyCmd() *cobra.Command { + return &cobra.Command{ + Use: "deny ", + Short: "Deny a pairing request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudDeny(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func newCloudStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show cloud relay and E2E key status", + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudStatus(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func newCloudKeyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Manage the account E2E encryption key (CEK)", + } + cmd.AddCommand( + &cobra.Command{ + Use: "show-phrase", + Short: "Show the 24-word recovery phrase for the CEK", + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudKeyShowPhrase(cmd.OutOrStdout(), cmd.InOrStdin()) + }, + }, + &cobra.Command{ + Use: "recover", + Short: "Rebuild the CEK from a 24-word recovery phrase", + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudKeyRecover(cmd.OutOrStdout(), cmd.InOrStdin()) + }, + }, + ) + return cmd +} + +func newCloudRotateKeyCmd() *cobra.Command { + return &cobra.Command{ + Use: "rotate-key", + Short: "Generate a new CEK (key_gen+1); paired clients must re-pair", + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudRotateKey(cmd.OutOrStdout(), cmd.InOrStdin()) + }, + } +} + +// loadDeviceClient loads the device credentials and builds an orchestrator +// client. Errors when not logged in. +func loadDeviceClient() (*cloud.Credentials, *cloud.Client, error) { + creds, err := cloud.LoadCredentials() + if err != nil { + return nil, nil, err + } + if creds == nil { + return nil, nil, fmt.Errorf("not logged in: run `jcode login` first") + } + return creds, cloud.NewClient(creds.CloudURL), nil +} + +// confirm prints prompt and returns true only when the user answers "yes". +func confirm(w io.Writer, in *bufio.Reader, prompt string) bool { + _, _ = fmt.Fprintf(w, "%s [type 'yes' to continue]: ", prompt) + line, _ := in.ReadString('\n') + return strings.TrimSpace(line) == "yes" +} + +func runCloudPairings(ctx context.Context, w io.Writer) error { + creds, client, err := loadDeviceClient() + if err != nil { + return err + } + pairings, err := client.ListPairings(ctx, creds.DeviceToken, "pending") + if err != nil { + return fmt.Errorf("list pairings: %w", err) + } + if len(pairings) == 0 { + _, _ = fmt.Fprintln(w, "No pending pairing requests.") + return nil + } + _, _ = fmt.Fprintln(w, "Pending pairing requests:") + for _, p := range pairings { + _, _ = fmt.Fprintf(w, " %s %-24s %s\n", p.ID, p.Label, p.CreatedAt) + } + _, _ = fmt.Fprintln(w, "\nApprove with `jcode cloud approve `, deny with `jcode cloud deny `.") + return nil +} + +func runCloudApprove(ctx context.Context, w io.Writer, id string) error { + creds, client, err := loadDeviceClient() + if err != nil { + return err + } + pairing, err := client.GetPairing(ctx, creds.DeviceToken, id) + if err != nil { + return fmt.Errorf("get pairing %s: %w", id, err) + } + if pairing.PubKey == "" { + return fmt.Errorf("pairing %s has no requester pubkey", id) + } + cipher, err := cloud.EnsureCEK() + if err != nil { + return err + } + wrap, err := cloud.WrapCEK(pairing.PubKey, cipher.CEK(), cipher.KeyGen()) + if err != nil { + return fmt.Errorf("wrap CEK for pairing %s: %w", id, err) + } + if err := client.RespondPairing(ctx, creds.DeviceToken, id, true, wrap); err != nil { + return fmt.Errorf("respond to pairing %s: %w", id, err) + } + _, _ = fmt.Fprintf(w, "Approved pairing %s (label %q) — CEK key_gen=%d wrapped for the requester.\n", id, pairing.Label, cipher.KeyGen()) + return nil +} + +func runCloudDeny(ctx context.Context, w io.Writer, id string) error { + creds, client, err := loadDeviceClient() + if err != nil { + return err + } + if err := client.RespondPairing(ctx, creds.DeviceToken, id, false, nil); err != nil { + return fmt.Errorf("respond to pairing %s: %w", id, err) + } + _, _ = fmt.Fprintf(w, "Denied pairing %s.\n", id) + return nil +} + +func runCloudStatus(ctx context.Context, w io.Writer) error { + creds, client, err := loadDeviceClient() + if err != nil { + return err + } + _, _ = fmt.Fprintf(w, "cloud url: %s\n", creds.CloudURL) + _, _ = fmt.Fprintf(w, "device name: %s\n", creds.DeviceName) + _, _ = fmt.Fprintf(w, "device id: %s\n", creds.DeviceID) + _, _ = fmt.Fprintf(w, "key gen: %d\n", creds.KeyGen) + cekStatus := "not initialized (plaintext uplink)" + if creds.CEK != "" { + cekStatus = "initialized (E2E encryption active)" + } + _, _ = fmt.Fprintf(w, "cek: %s\n", cekStatus) + if err := client.Heartbeat(ctx, creds.DeviceToken); err != nil { + _, _ = fmt.Fprintf(w, "online: no (heartbeat failed: %v)\n", err) + } else { + _, _ = fmt.Fprintln(w, "online: yes (heartbeat ok)") + } + return nil +} + +func runCloudKeyShowPhrase(w io.Writer, in io.Reader) error { + cipher, err := cloud.EnsureCEK() + if err != nil { + return err + } + reader := bufio.NewReader(in) + _, _ = fmt.Fprintln(w, "WARNING: the recovery phrase decrypts ALL synced content of this account.") + _, _ = fmt.Fprintln(w, "Anyone who sees it can read your sessions. Do not share it, store it safely.") + if !confirm(w, reader, "Reveal the 24-word recovery phrase?") { + _, _ = fmt.Fprintln(w, "Aborted.") + return nil + } + phrase, err := cloud.CEKToPhrase(cipher.CEK()) + if err != nil { + return err + } + _, _ = fmt.Fprintf(w, "\nRecovery phrase (key_gen=%d):\n\n %s\n\n", cipher.KeyGen(), phrase) + _, _ = fmt.Fprintln(w, "Write it down and keep it offline. It is NOT stored anywhere else.") + return nil +} + +func runCloudKeyRecover(w io.Writer, in io.Reader) error { + if _, _, err := loadDeviceClient(); err != nil { + return err + } + reader := bufio.NewReader(in) + _, _ = fmt.Fprintln(w, "Paste the 24-word recovery phrase (single line, space separated):") + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return fmt.Errorf("read recovery phrase: %w", err) + } + phrase := strings.TrimSpace(line) + if _, err := cloud.CEKFromPhrase(phrase); err != nil { + return err + } + if creds, err := cloud.LoadCredentials(); err != nil { + return err + } else if creds != nil && creds.CEK != "" { + _, _ = fmt.Fprintln(w, "WARNING: a CEK already exists on this device. Recovering OVERWRITES it.") + _, _ = fmt.Fprintln(w, "Content encrypted with the current key becomes unreadable for this device.") + if !confirm(w, reader, "Overwrite the existing CEK with the recovered one?") { + _, _ = fmt.Fprintln(w, "Aborted.") + return nil + } + } + cipher, err := cloud.RecoverCEK(phrase) + if err != nil { + return err + } + _, _ = fmt.Fprintf(w, "CEK recovered (key_gen=%d). Uplink is now encrypted with the recovered key.\n", cipher.KeyGen()) + return nil +} + +func runCloudRotateKey(w io.Writer, in io.Reader) error { + if _, _, err := loadDeviceClient(); err != nil { + return err + } + reader := bufio.NewReader(in) + _, _ = fmt.Fprintln(w, "Rotating the CEK generates a new key (key_gen+1).") + _, _ = fmt.Fprintln(w, "Already-paired clients keep the old key and CANNOT read new content until they re-pair.") + if !confirm(w, reader, "Rotate the CEK now?") { + _, _ = fmt.Fprintln(w, "Aborted.") + return nil + } + cipher, err := cloud.RotateCEK() + if err != nil { + return err + } + _, _ = fmt.Fprintf(w, "CEK rotated. New key_gen=%d.\n", cipher.KeyGen()) + _, _ = fmt.Fprintln(w, "Note: authorized clients (console / mobile) must pair again to receive the new key.") + _, _ = fmt.Fprintln(w, "Consider saving the new recovery phrase: `jcode cloud key show-phrase`.") + return nil +} diff --git a/internal/command/cloud_cmd_test.go b/internal/command/cloud_cmd_test.go new file mode 100644 index 00000000..b52db7c2 --- /dev/null +++ b/internal/command/cloud_cmd_test.go @@ -0,0 +1,347 @@ +package command + +import ( + "bytes" + "context" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/cloud" +) + +// pairingTestServer implements the device-token pairing endpoints plus +// heartbeat, recording respond calls for assertions. +type pairingTestServer struct { + srv *httptest.Server + + requesterPriv *ecdh.PrivateKey + requesterPub string // base64 SPKI + + responds []respondRecord +} + +type respondRecord struct { + id string + approve bool + wrap *cloud.CEKWrap +} + +func newPairingTestServer(t *testing.T) *pairingTestServer { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + if err != nil { + t.Fatal(err) + } + ecdhPriv, err := priv.ECDH() + if err != nil { + t.Fatal(err) + } + p := &pairingTestServer{ + requesterPriv: ecdhPriv, + requesterPub: base64.StdEncoding.EncodeToString(der), + } + pairing := cloud.Pairing{ + ID: "pair-1", + Label: "chrome on mac", + PubKey: p.requesterPub, + Status: "pending", + CreatedAt: "2026-07-20T10:00:00Z", + } + mux := http.NewServeMux() + mux.HandleFunc("GET /internal/v1/device/pairings", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"pairings": []cloud.Pairing{pairing}}) + }) + mux.HandleFunc("GET /internal/v1/device/pairings/{id}", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(pairing) + }) + mux.HandleFunc("POST /internal/v1/device/pairings/{id}/respond", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Approve bool `json:"approve"` + Wrap *cloud.CEKWrap `json:"wrap"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + p.responds = append(p.responds, respondRecord{id: r.PathValue("id"), approve: body.Approve, wrap: body.Wrap}) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + mux.HandleFunc("POST /internal/v1/device/heartbeat", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + p.srv = httptest.NewServer(mux) + t.Cleanup(p.srv.Close) + return p +} + +// setupCloudHome points the credentials file at a temp HOME and stores device +// credentials for the test server (no CEK unless withCEK). +func setupCloudHome(t *testing.T, cloudURL string, withCEK bool) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + cloud.ResetCEKCache() + t.Cleanup(cloud.ResetCEKCache) + creds := &cloud.Credentials{ + CloudURL: cloudURL, + DeviceID: "dev-1", + DeviceToken: "tok", + DeviceName: "test-device", + KeyGen: 1, + } + if withCEK { + cek := bytes.Repeat([]byte{0x11}, 32) + creds.CEK = base64.StdEncoding.EncodeToString(cek) + } + if err := cloud.SaveCredentials(creds); err != nil { + t.Fatal(err) + } +} + +func TestRunCloudPairings(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, false) + + var out bytes.Buffer + if err := runCloudPairings(context.Background(), &out); err != nil { + t.Fatalf("runCloudPairings: %v", err) + } + if !strings.Contains(out.String(), "pair-1") || !strings.Contains(out.String(), "chrome on mac") { + t.Fatalf("output = %q, want the pending pairing listed", out.String()) + } +} + +func TestRunCloudApproveWrapsCEK(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, false) // no CEK yet: approve must lazily generate it + + var out bytes.Buffer + if err := runCloudApprove(context.Background(), &out, "pair-1"); err != nil { + t.Fatalf("runCloudApprove: %v", err) + } + if !strings.Contains(out.String(), "chrome on mac") { + t.Fatalf("output = %q, want the approved label", out.String()) + } + if len(pt.responds) != 1 || !pt.responds[0].approve || pt.responds[0].wrap == nil { + t.Fatalf("responds = %+v, want one approval with a wrap", pt.responds) + } + // The requester can unwrap the CEK; it must match what the device persisted. + gotCEK, gotGen, err := cloud.UnwrapCEK(pt.responds[0].wrap, pt.requesterPriv) + if err != nil { + t.Fatalf("UnwrapCEK: %v", err) + } + creds, err := cloud.LoadCredentials() + if err != nil || creds == nil { + t.Fatal("credentials missing after approve") + } + stored, _ := base64.StdEncoding.DecodeString(creds.CEK) + if !bytes.Equal(gotCEK, stored) || gotGen != creds.KeyGen { + t.Fatalf("wrapped CEK (gen %d) does not match stored credentials (gen %d)", gotGen, creds.KeyGen) + } +} + +func TestRunCloudDeny(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, false) + + var out bytes.Buffer + if err := runCloudDeny(context.Background(), &out, "pair-1"); err != nil { + t.Fatalf("runCloudDeny: %v", err) + } + if len(pt.responds) != 1 || pt.responds[0].approve || pt.responds[0].wrap != nil { + t.Fatalf("responds = %+v, want one denial without wrap", pt.responds) + } + if !strings.Contains(out.String(), "Denied pairing pair-1") { + t.Fatalf("output = %q", out.String()) + } +} + +func TestRunCloudStatus(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, true) + + var out bytes.Buffer + if err := runCloudStatus(context.Background(), &out); err != nil { + t.Fatalf("runCloudStatus: %v", err) + } + s := out.String() + for _, want := range []string{pt.srv.URL, "dev-1", "key gen: 1", "initialized", "online: yes"} { + if !strings.Contains(s, want) { + t.Errorf("status output missing %q:\n%s", want, s) + } + } +} + +func TestRunCloudKeyShowPhrase(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, false) + + // Declining the confirmation prints nothing sensitive. + var out bytes.Buffer + if err := runCloudKeyShowPhrase(&out, strings.NewReader("no\n")); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Recovery phrase (") { + t.Fatalf("phrase revealed without confirmation:\n%s", out.String()) + } + + // Confirming reveals exactly 24 words matching the persisted CEK. + out.Reset() + if err := runCloudKeyShowPhrase(&out, strings.NewReader("yes\n")); err != nil { + t.Fatal(err) + } + phrase := extractPhrase(t, out.String()) + cek, err := cloud.CEKFromPhrase(phrase) + if err != nil { + t.Fatalf("printed phrase does not decode: %v", err) + } + creds, _ := cloud.LoadCredentials() + stored, _ := base64.StdEncoding.DecodeString(creds.CEK) + if !bytes.Equal(cek, stored) { + t.Fatal("printed phrase does not match the stored CEK") + } +} + +// extractPhrase pulls the indented 24-word line out of show-phrase output. +func extractPhrase(t *testing.T, output string) string { + t.Helper() + for _, line := range strings.Split(output, "\n") { + if words := strings.Fields(line); len(words) == 24 { + return strings.Join(words, " ") + } + } + t.Fatalf("no 24-word phrase line in output:\n%s", output) + return "" +} + +func TestRunCloudKeyRecover(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, false) + + // Generate a phrase from a known CEK (as if exported on another device). + known, err := cloud.GenerateCEK() + if err != nil { + t.Fatal(err) + } + phrase, err := cloud.CEKToPhrase(known) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + if err := runCloudKeyRecover(&out, strings.NewReader(phrase+"\n")); err != nil { + t.Fatalf("runCloudKeyRecover: %v", err) + } + creds, _ := cloud.LoadCredentials() + stored, _ := base64.StdEncoding.DecodeString(creds.CEK) + if !bytes.Equal(stored, known) { + t.Fatal("recovered CEK does not match the phrase") + } + + // Recovering over an existing CEK requires confirmation; declining keeps it. + out.Reset() + other, _ := cloud.GenerateCEK() + otherPhrase, _ := cloud.CEKToPhrase(other) + if err := runCloudKeyRecover(&out, strings.NewReader(otherPhrase+"\nno\n")); err != nil { + t.Fatal(err) + } + creds, _ = cloud.LoadCredentials() + stored, _ = base64.StdEncoding.DecodeString(creds.CEK) + if !bytes.Equal(stored, known) { + t.Fatal("CEK overwritten despite declined confirmation") + } + + // An invalid phrase is rejected before any overwrite. + out.Reset() + if err := runCloudKeyRecover(&out, strings.NewReader("foo bar baz\n")); err == nil { + t.Fatal("invalid phrase accepted") + } +} + +func TestRunCloudRotateKey(t *testing.T) { + pt := newPairingTestServer(t) + setupCloudHome(t, pt.srv.URL, true) // CEK present at key_gen 1 + + var out bytes.Buffer + if err := runCloudRotateKey(&out, strings.NewReader("yes\n")); err != nil { + t.Fatalf("runCloudRotateKey: %v", err) + } + if !strings.Contains(out.String(), "key_gen=2") { + t.Fatalf("output = %q, want key_gen=2 notice", out.String()) + } + creds, _ := cloud.LoadCredentials() + if creds.KeyGen != 2 || creds.CEK == "" { + t.Fatalf("credentials after rotate = %+v", creds) + } + + // Declining keeps key_gen 1… but we already rotated; use a fresh home. + setupCloudHome(t, pt.srv.URL, true) + out.Reset() + if err := runCloudRotateKey(&out, strings.NewReader("no\n")); err != nil { + t.Fatal(err) + } + creds, _ = cloud.LoadCredentials() + if creds.KeyGen != 1 { + t.Fatalf("key_gen = %d after declined rotation, want 1", creds.KeyGen) + } +} + +func TestCloudCommandsRequireLogin(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cloud.ResetCEKCache() + t.Cleanup(cloud.ResetCEKCache) + + var out bytes.Buffer + if err := runCloudPairings(context.Background(), &out); err == nil { + t.Error("pairings without login succeeded, want error") + } + if err := runCloudApprove(context.Background(), &out, "x"); err == nil { + t.Error("approve without login succeeded, want error") + } + if err := runCloudStatus(context.Background(), &out); err == nil { + t.Error("status without login succeeded, want error") + } +} + +func TestCloudGuideOutput(t *testing.T) { + var out bytes.Buffer + if err := runCloudGuide(&out); err != nil { + t.Fatal(err) + } + text := out.String() + // The guide must stay consistent with the actual CLI surface (verified + // against each command's --help, M7) and point at the full doc. + for _, want := range []string{ + "jcode login", + "jcode login --status", + "jcode logout", + "https://cloud.j-code.net", + "jcode cloud pairings", + "jcode cloud approve", + "jcode cloud deny", + "jcode cloud key show-phrase", + "jcode cloud key recover", + "jcode cloud rotate-key", + "jcode cloud status", + "e2ee", + "docs/cloud.md", + } { + if !strings.Contains(text, want) { + t.Errorf("guide output missing %q", want) + } + } +} diff --git a/internal/command/cloud_test.go b/internal/command/cloud_test.go new file mode 100644 index 00000000..33a162ae --- /dev/null +++ b/internal/command/cloud_test.go @@ -0,0 +1,47 @@ +package command + +import ( + "testing" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +func boolPtr(b bool) *bool { return &b } + +func TestNewCloudConnectorGate(t *testing.T) { + creds := &cloud.Credentials{ + CloudURL: "https://cloud.example.com", + DeviceID: "dev-1", + DeviceToken: "tok", + } + build := func(cfg *config.Config, c *cloud.Credentials, webToken string) *cloud.Connector { + return newCloudSupervisor(cfg, 8080, webToken).BuildConnector(c) + } + + // Not logged in → no connector, regardless of config. + if c := build(&config.Config{}, nil, ""); c != nil { + t.Error("nil credentials must not start the connector") + } + + // auto_connect explicitly false → no connector. + cfg := &config.Config{} + cfg.SetCloud(&config.CloudConfig{Enabled: true, URL: "https://cloud.example.com", AutoConnect: boolPtr(false)}) + if c := build(cfg, creds, ""); c != nil { + t.Error("auto_connect=false must not start the connector") + } + + // Logged in + default (absent) auto_connect → connector built, cloud URL + // taken from config. + cfg.SetCloud(&config.CloudConfig{Enabled: true, URL: "https://cloud.example.com"}) + c := build(cfg, creds, "webtok") + if c == nil { + t.Fatal("logged in with default auto_connect must start the connector") + } + + // No config cloud block → URL falls back to the credentials'. + c = build(&config.Config{}, creds, "") + if c == nil { + t.Fatal("logged in without a config cloud block must still start the connector") + } +} diff --git a/internal/command/login.go b/internal/command/login.go new file mode 100644 index 00000000..d3436c1f --- /dev/null +++ b/internal/command/login.go @@ -0,0 +1,230 @@ +package command + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "time" + + "github.com/spf13/cobra" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// NewLoginCmd returns the `jcode login` command: RFC 8628 device-code sign-in +// to jcloud (see cloud/docs/17-jcode-device-relay.md §3). It prints to plain +// stdout like `jcode update` / `jcode sessions` — no TUI involved. +func NewLoginCmd() *cobra.Command { + var cloudURL, name string + var status bool + cmd := &cobra.Command{ + Use: "login", + Short: "Sign in to jcloud (device code flow)", + RunE: func(cmd *cobra.Command, args []string) error { + if status { + return runLoginStatus() + } + return runLogin(cmd.Context(), cloudURL, name) + }, + } + cmd.Flags().StringVar(&cloudURL, "cloud", cloud.DefaultCloudURL, "jcloud orchestrator URL (https; http allowed only for localhost)") + cmd.Flags().StringVar(&name, "name", "", "device name shown in jcloud (defaults to hostname)") + cmd.Flags().BoolVar(&status, "status", false, "show current login status and exit") + return cmd +} + +// NewLogoutCmd returns the `jcode logout` command: revoke the device token +// remotely (best effort) and clear local credentials. +func NewLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Sign out of jcloud and remove local device credentials", + RunE: func(cmd *cobra.Command, args []string) error { + return runLogout(cmd.Context()) + }, + } +} + +// openBrowser opens url in the system browser. It is a variable so tests can +// stub it out. JCODE_NO_BROWSER=1 disables it entirely (e2e rigs, headless +// environments) — the verification URL is always printed regardless. +var openBrowser = func(url string) error { + if os.Getenv("JCODE_NO_BROWSER") == "1" { + return nil + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +func runLogin(ctx context.Context, rawURL, name string) error { + baseURL, err := cloud.ValidateCloudURL(rawURL) + if err != nil { + return err + } + if existing, err := cloud.LoadCredentials(); err != nil { + return err + } else if existing != nil { + fmt.Printf("Already logged in to %s as device %q (%s).\n", existing.CloudURL, existing.DeviceName, existing.DeviceID) + fmt.Println("Run `jcode logout` first to sign in again.") + return nil + } + + hostname, _ := os.Hostname() + if name == "" { + name = hostname + } + if name == "" { + name = "jcode-device" + } + + // M16: resolve the stable machine fingerprint up front; its sha256 rides + // the token poll (login dedup) and the register call, and the SOURCE is + // persisted into cloud.json so it never changes once set. + fpSource, err := cloud.ResolveFingerprintSource() + if err != nil { + return fmt.Errorf("failed to resolve the machine fingerprint: %w", err) + } + fpHash := cloud.FingerprintHash(fpSource) + + client := cloud.NewClient(baseURL) + + dc, err := client.RequestDeviceCode(ctx, "jcode CLI "+Version) + if err != nil { + return fmt.Errorf("failed to request device code: %w", err) + } + + fmt.Println() + fmt.Println("To sign in to jcloud, open this page in your browser:") + fmt.Println() + fmt.Printf(" %s\n", dc.VerificationURI) + fmt.Println() + fmt.Println("and enter this code:") + fmt.Println() + fmt.Printf(" %s\n", dc.UserCode) + fmt.Println() + if err := openBrowser(dc.VerificationURI); err != nil { + fmt.Printf("(could not open a browser automatically: %v — please open the URL manually)\n", err) + } + fmt.Println("Waiting for authorization... (Ctrl+C to cancel)") + + interval := time.Duration(dc.Interval) * time.Second + expiresIn := time.Duration(dc.ExpiresIn) * time.Second + tok, err := client.PollForToken(ctx, dc.DeviceCode, fpHash, interval, expiresIn) + if err != nil { + switch { + case errors.Is(err, cloud.ErrAuthorizationDenied): + return fmt.Errorf("login failed: %w", err) + case errors.Is(err, cloud.ErrDeviceCodeExpired): + return fmt.Errorf("login failed: %w — run `jcode login` to try again", err) + default: + return fmt.Errorf("login failed while waiting for authorization: %w", err) + } + } + + pubKey, privKey, err := cloud.GenerateIdentityKeyPair() + if err != nil { + return err + } + + if err := client.RegisterDevice(ctx, tok.AccessToken, cloud.RegisterDeviceRequest{ + Name: name, + Hostname: hostname, + JcodeVersion: Version, + PubKey: pubKey, + Fingerprint: fpHash, + }); err != nil { + return fmt.Errorf("failed to register device: %w", err) + } + + creds := &cloud.Credentials{ + CloudURL: baseURL, + DeviceID: tok.DeviceID, + DeviceToken: tok.AccessToken, + DeviceName: name, + PublicKey: pubKey, + PrivateKey: privKey, + KeyGen: 1, + Fingerprint: fpSource, + } + if err := cloud.SaveCredentials(creds); err != nil { + return err + } + + if err := cloud.UpdateConfigCloud(baseURL, true); err != nil { + fmt.Printf("Warning: failed to update %s: %v\n", config.ConfigPath(), err) + } + + fmt.Println() + if tok.Deduped { + fmt.Printf("This machine was recognized (fingerprint %s) — reusing the existing device registration.\n", fpHash[:8]) + } + fmt.Printf("Signed in to %s as device %q (%s).\n", baseURL, name, tok.DeviceID) + fmt.Printf("Credentials saved to %s\n", credentialsPathForDisplay()) + return nil +} + +func runLogout(ctx context.Context) error { + creds, err := cloud.LoadCredentials() + if err != nil { + return err + } + if creds == nil { + fmt.Println("Not logged in.") + return nil + } + + if err := cloud.Logout(ctx, func(format string, args ...any) { + fmt.Printf("Warning: "+format+"\n", args...) + }); err != nil { + return err + } + + fmt.Println("Logged out. Local device credentials removed.") + return nil +} + +func runLoginStatus() error { + creds, err := cloud.LoadCredentials() + if err != nil { + return err + } + if creds == nil { + fmt.Println("Not logged in. Run `jcode login` to sign in.") + return nil + } + fmt.Printf("cloud url: %s\n", creds.CloudURL) + fmt.Printf("device name: %s\n", creds.DeviceName) + fmt.Printf("device id: %s\n", creds.DeviceID) + fmt.Printf("key gen: %d\n", creds.KeyGen) + // M16: show the fingerprint hash prefix (the form the server knows). + // Pre-M16 files lack the persisted source — fall back to the hardware id, + // never to a fresh random (that would print an unstable value). + fpSource := creds.Fingerprint + if fpSource == "" { + fpSource = cloud.HardwareFingerprintSource() + } + if fpSource != "" { + fmt.Printf("fingerprint: %s\n", cloud.FingerprintHash(fpSource)[:8]) + } + return nil +} + +func credentialsPathForDisplay() string { + path, err := cloud.CredentialsPath() + if err != nil { + return "~/.jcode/cloud.json" + } + return path +} diff --git a/internal/command/login_test.go b/internal/command/login_test.go new file mode 100644 index 00000000..65408e25 --- /dev/null +++ b/internal/command/login_test.go @@ -0,0 +1,188 @@ +package command + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// loginTestServer implements the orchestrator-side device-auth contract: +// immediate approval on the first token poll, plus register and revoke. +func loginTestServer(t *testing.T, registered *bool, revoked *int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/device/code", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "dev-code-1", + "user_code": "WXYZ-1234", + "verification_uri": "http://example.com/auth/device", + "expires_in": 60, + "interval": 1, + }) + }) + mux.HandleFunc("POST /auth/device/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "dev-token-1", + "token_type": "device", + "device_id": "device-7", + }) + }) + mux.HandleFunc("POST /internal/v1/device/register", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer dev-token-1" { + t.Errorf("register: Authorization = %q", r.Header.Get("Authorization")) + } + var req cloud.RegisterDeviceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("register: decode body: %v", err) + } + if req.Name != "test-device" || req.PubKey == "" || req.JcodeVersion == "" { + t.Errorf("register: unexpected body %+v", req) + } + // M16: the register body carries the sha256 machine-fingerprint hash. + if len(req.Fingerprint) != 64 { + t.Errorf("register: fingerprint = %q, want 64 hex chars", req.Fingerprint) + } + *registered = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + mux.HandleFunc("POST /internal/v1/device/revoke", func(w http.ResponseWriter, r *http.Request) { + *revoked++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + return httptest.NewServer(mux) +} + +// stubOpenBrowser swaps the browser opener for a no-op while the test runs. +func stubOpenBrowser(t *testing.T) { + t.Helper() + orig := openBrowser + openBrowser = func(string) error { return nil } + t.Cleanup(func() { openBrowser = orig }) +} + +func TestRunLoginEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + stubOpenBrowser(t) + + var registered bool + var revoked int + srv := loginTestServer(t, ®istered, &revoked) + defer srv.Close() + + if err := runLogin(context.Background(), srv.URL, "test-device"); err != nil { + t.Fatalf("runLogin() error = %v", err) + } + if !registered { + t.Fatal("device register endpoint was not called") + } + + creds, err := cloud.LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials() error = %v", err) + } + if creds == nil { + t.Fatal("LoadCredentials() = nil after login") + } + if creds.CloudURL != srv.URL || creds.DeviceID != "device-7" || creds.DeviceToken != "dev-token-1" || creds.DeviceName != "test-device" { + t.Fatalf("credentials = %+v", creds) + } + if creds.PublicKey == "" || creds.PrivateKey == "" || creds.KeyGen != 1 { + t.Fatalf("credentials keys = %+v", creds) + } + // M16: the fingerprint SOURCE is persisted (never sent over the wire) and + // a second login attempt reuses it. + if creds.Fingerprint == "" { + t.Fatalf("credentials missing fingerprint: %+v", creds) + } + fpSource := creds.Fingerprint + + // The config block must be enabled and point at the cloud URL. Read the + // file raw: LoadConfig requires providers, which login does not. + raw, err := readRawConfig(t) + if err != nil { + t.Fatalf("read config: %v", err) + } + cloudBlock, ok := raw["cloud"].(map[string]any) + if !ok { + t.Fatalf("config has no cloud block: %v", raw) + } + if cloudBlock["enabled"] != true || cloudBlock["url"] != srv.URL { + t.Fatalf("config cloud block = %v", cloudBlock) + } + + // Logging in again without logout is a no-op. + if err := runLogin(context.Background(), srv.URL, "test-device"); err != nil { + t.Fatalf("runLogin() again error = %v", err) + } + // The no-op re-login must not touch the persisted fingerprint. + creds, err = cloud.LoadCredentials() + if err != nil || creds == nil || creds.Fingerprint != fpSource { + t.Fatalf("fingerprint changed after no-op re-login: %+v, %v", creds, err) + } + + if err := runLogout(context.Background()); err != nil { + t.Fatalf("runLogout() error = %v", err) + } + if revoked != 1 { + t.Fatalf("revoke called %d times, want 1", revoked) + } + creds, err = cloud.LoadCredentials() + if err != nil || creds != nil { + t.Fatalf("LoadCredentials() after logout = %+v, %v; want nil, nil", creds, err) + } + raw, err = readRawConfig(t) + if err != nil { + t.Fatalf("read config after logout: %v", err) + } + // enabled=false is omitted from the JSON by `omitempty`; absent means false. + if block, ok := raw["cloud"].(map[string]any); !ok || block["enabled"] == true { + t.Fatalf("cloud block after logout = %v", raw["cloud"]) + } +} + +func TestRunLoginStatusNotLoggedIn(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := runLoginStatus(); err != nil { + t.Fatalf("runLoginStatus() error = %v", err) + } +} + +func TestRunLogoutNotLoggedIn(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := runLogout(context.Background()); err != nil { + t.Fatalf("runLogout() error = %v", err) + } +} + +func TestRunLoginRejectsInsecureURL(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := runLogin(context.Background(), "http://cloud.example.com", ""); err == nil { + t.Fatal("runLogin() with http URL succeeded, want validation error") + } +} + +// readRawConfig parses ~/.jcode/config.json without LoadConfig's provider +// validation. +func readRawConfig(t *testing.T) (map[string]any, error) { + t.Helper() + data, err := os.ReadFile(config.ConfigPath()) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return raw, nil +} diff --git a/internal/command/web.go b/internal/command/web.go index 6e5d2f57..30a49632 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -884,6 +884,11 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo config.Logger().Printf("[web] token auth enabled for non-loopback bind %q", host) } + // The cloud relay supervisor is constructed before the server so the cloud + // status/config API can reach it; it is started below, once the rest of the + // server is wired. + cloudSup := newCloudSupervisor(cfg, port, webToken) + // Bootstrap engine for the initial task. bootEC, err := buildWebTask("", pwd, startupMode.String(), nil, false) if err != nil { @@ -950,7 +955,8 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo }, }) }, - BLEController: bleProxy, + BLEController: bleProxy, + CloudSupervisor: cloudSup, }) // Start the periodic automation scheduler. A single process owns periodic @@ -998,6 +1004,13 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo // use is enabled). Lets the extension connect with zero manual steps. srv.SetupNativeMessaging() + // Start the jcloud relay connector in the background (best-effort): it runs + // only when logged in with cloud.auto_connect enabled, and any failure is a + // logged warning — the local web server is never affected. Its context is + // the server's shutdown context, so Ctrl+C tears it down with everything + // else. + cloudSup.Start(ctx) + if err := srv.Start(ctx); err != nil { return fmt.Errorf("server error: %w", err) } diff --git a/internal/config/cloud_settings_test.go b/internal/config/cloud_settings_test.go new file mode 100644 index 00000000..8f7643d7 --- /dev/null +++ b/internal/config/cloud_settings_test.go @@ -0,0 +1,146 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestCloudSettingsDefaults(t *testing.T) { + c := &Config{} + if got := c.CloudSettings(); got.Enabled || got.URL != "" || got.AutoConnect != nil || got.E2EE != nil { + t.Fatalf("CloudSettings() on absent block = %+v, want zero value", got) + } + if !CloudAutoConnect(c) { + t.Fatal("CloudAutoConnect() on absent block = false, want default true") + } + if !CloudAutoConnect(nil) { + t.Fatal("CloudAutoConnect(nil) = false, want default true") + } + if !CloudE2EE(c) { + t.Fatal("CloudE2EE() on absent block = false, want default true") + } + if !CloudE2EE(nil) { + t.Fatal("CloudE2EE(nil) = false, want default true") + } +} + +func TestSetCloudSnapshotAndRoundTrip(t *testing.T) { + auto := false + c := &Config{} + c.SetCloud(&CloudConfig{Enabled: true, URL: "https://cloud.j-code.net", AutoConnect: &auto}) + + got := c.CloudSettings() + if !got.Enabled || got.URL != "https://cloud.j-code.net" || got.AutoConnect == nil || *got.AutoConnect { + t.Fatalf("CloudSettings() = %+v", got) + } + if CloudAutoConnect(c) { + t.Fatal("CloudAutoConnect() = true, want explicit false") + } + + // Mutating the returned snapshot must not leak into the live config. + got.URL = "https://evil.example.com" + *got.AutoConnect = true + if again := c.CloudSettings(); again.URL != "https://cloud.j-code.net" || *again.AutoConnect { + t.Fatalf("snapshot mutation leaked into live config: %+v", again) + } + + // JSON round-trip uses the documented snake_case keys. + data, err := json.Marshal(c.CloudSettings()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if raw["enabled"] != true || raw["url"] != "https://cloud.j-code.net" || raw["auto_connect"] != false { + t.Fatalf("marshaled cloud block = %v", raw) + } +} + +func TestSetCloudNilRemovesBlock(t *testing.T) { + c := &Config{} + c.SetCloud(&CloudConfig{Enabled: true, URL: "https://cloud.j-code.net"}) + c.SetCloud(nil) + if c.Cloud != nil { + t.Fatalf("SetCloud(nil) left block: %+v", c.Cloud) + } + if got := c.CloudSettings(); got.Enabled || got.URL != "" { + t.Fatalf("CloudSettings() after removal = %+v", got) + } +} + +func TestCloudE2EEDisableRoundTrip(t *testing.T) { + off := false + c := &Config{} + c.SetCloud(&CloudConfig{Enabled: true, URL: "https://cloud.j-code.net", E2EE: &off}) + + if CloudE2EE(c) { + t.Fatal("CloudE2EE() = true, want explicit false") + } + got := c.CloudSettings() + if got.E2EE == nil || *got.E2EE { + t.Fatalf("CloudSettings().E2EE = %v, want explicit false", got.E2EE) + } + + // Mutating the snapshot must not leak into the live config. + *got.E2EE = true + if CloudE2EE(c) { + t.Fatal("snapshot mutation leaked into live config") + } + + // JSON round-trip keeps the documented `e2ee` key and the explicit false. + data, err := json.Marshal(c.CloudSettings()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if v, ok := raw["e2ee"]; !ok || v != false { + t.Fatalf("marshaled cloud block missing e2ee=false: %v", raw) + } + + // An explicit true survives too (distinguishable from absent in the JSON, + // equivalent for CloudE2EE). + on := true + c.SetCloud(&CloudConfig{Enabled: true, E2EE: &on}) + if !CloudE2EE(c) { + t.Fatal("CloudE2EE() = false, want explicit true") + } +} + +func TestCloudSyncDefaultRoundTrip(t *testing.T) { + c := &Config{} + if CloudSyncDefault(c) { + t.Fatal("CloudSyncDefault() on absent block = true, want default false") + } + if CloudSyncDefault(nil) { + t.Fatal("CloudSyncDefault(nil) = true, want default false") + } + + c.SetCloud(&CloudConfig{Enabled: true, SyncDefault: true}) + if !CloudSyncDefault(c) { + t.Fatal("CloudSyncDefault() = false, want explicit true") + } + // Snapshot independence + JSON round-trip. + data, err := json.Marshal(c.CloudSettings()) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if v, ok := raw["sync_default"]; !ok || v != true { + t.Fatalf("marshaled cloud block missing sync_default=true: %v", raw) + } + + // Explicit false is indistinguishable from absent (both mean default OFF) + // but must survive a read/modify/write of the block. + c.SetCloud(&CloudConfig{Enabled: true, SyncDefault: false}) + if CloudSyncDefault(c) { + t.Fatal("CloudSyncDefault() = true, want explicit false") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 79788606..dd2425ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -418,6 +418,101 @@ type Config struct { // explicitly-disabled value distinguishable from an absent one. Both take // effect on the next process start (like the BLE toggle). Developer *DeveloperConfig `json:"developer,omitempty"` + + // Cloud controls the jcloud device-relay connection (see + // cloud/docs/17-jcode-device-relay.md). It is written by `jcode login` / + // `jcode logout`; credentials themselves live in ~/.jcode/cloud.json. + Cloud *CloudConfig `json:"cloud,omitempty"` +} + +// CloudConfig controls the jcloud device-relay connection. AutoConnect is a +// pointer so an explicitly-disabled value survives config round-trips while an +// absent value keeps the platform default (connect automatically). +type CloudConfig struct { + // Enabled is true once this device completed `jcode login`. + Enabled bool `json:"enabled,omitempty"` + // URL is the orchestrator base URL (https required; localhost http is + // allowed for development). + URL string `json:"url,omitempty"` + // AutoConnect gates starting the relay connector on `jcode web` startup. + // nil ⇒ true. + AutoConnect *bool `json:"auto_connect,omitempty"` + // E2EE gates the M5 end-to-end encryption layer: nil/true ⇒ the connector + // lazily generates the account CEK and seals everything uplink; false ⇒ it + // stays on the plaintext grey path (CipherDisabled) for the gradual + // roll-out. Pointer so an explicit false survives config round-trips. + E2EE *bool `json:"e2ee,omitempty"` + // SyncDefault (M19) is the global default for NEW sessions: when true, the + // web layer stamps each newly created session as cloud-synced (an explicit + // entry in ~/.jcode/cloud-sessions.json). Existing sessions are never + // touched retroactively. Absent/false ⇒ new sessions do not sync. + SyncDefault bool `json:"sync_default,omitempty"` +} + +// cloudMu guards publication of Config.Cloud. As with Memory/ToolSearch, the +// block is swapped atomically and snapshots never expose the live pointer. The +// mutex is package-level because Config is copied by value in a few places. +var cloudMu sync.RWMutex + +func cloneCloudConfig(cc *CloudConfig) *CloudConfig { + if cc == nil { + return nil + } + copy := *cc + copy.AutoConnect = cloneBool(cc.AutoConnect) + copy.E2EE = cloneBool(cc.E2EE) + return © +} + +// CloudSettings returns a detached snapshot of the cloud settings. A zero +// value represents an absent block (logged out / never configured). +func (c *Config) CloudSettings() CloudConfig { + cloudMu.RLock() + defer cloudMu.RUnlock() + if c == nil || c.Cloud == nil { + return CloudConfig{} + } + return *cloneCloudConfig(c.Cloud) +} + +// SetCloud atomically publishes a detached copy of cc. Callers may safely +// reuse or mutate cc after this method returns. Passing nil removes the block. +func (c *Config) SetCloud(cc *CloudConfig) { + if c == nil { + return + } + cloudMu.Lock() + defer cloudMu.Unlock() + c.Cloud = cloneCloudConfig(cc) +} + +// CloudAutoConnect reports whether the relay connector should start +// automatically (default true for absent blocks and nil values). +func CloudAutoConnect(c *Config) bool { + cc := c.CloudSettings() + if cc.AutoConnect == nil { + return true + } + return *cc.AutoConnect +} + +// CloudE2EE reports whether the device relay seals uplink payloads with the +// account CEK (M5 E2E encryption). Default true for absent blocks and nil +// values; an explicit false keeps the connector on the plaintext grey path. +func CloudE2EE(c *Config) bool { + cc := c.CloudSettings() + if cc.E2EE == nil { + return true + } + return *cc.E2EE +} + +// CloudSyncDefault reports whether NEW sessions are stamped as cloud-synced +// at creation time (M19). Default false for absent blocks: nothing syncs +// unless the user opts a session in. Existing sessions are unaffected either +// way — the value is consulted only at session-creation stamping. +func CloudSyncDefault(c *Config) bool { + return c.CloudSettings().SyncDefault } // ApprovalReviewConfig holds tuning knobs for jcode's LLM approval reviewer. diff --git a/internal/prompts/memory_test.go b/internal/prompts/memory_test.go index 5cd9ca56..f2fca111 100644 --- a/internal/prompts/memory_test.go +++ b/internal/prompts/memory_test.go @@ -196,6 +196,10 @@ func TestMemoryLoader_TotalCharLimit(t *testing.T) { func TestMemoryLoader_EmptyDir(t *testing.T) { dir := t.TempDir() + // Isolate HOME so a real global ~/.jcode/AGENTS.md on the developer + // machine cannot leak into the loader (it always reads ConfigDir()). + t.Setenv("HOME", t.TempDir()) + loader := NewMemoryLoader(MemoryConfig{MaxTotalChars: 40000, MaxIncDepth: 5}) result, err := loader.Load(dir) if err != nil { diff --git a/internal/web/chat.go b/internal/web/chat.go index e5d55411..940499b0 100644 --- a/internal/web/chat.go +++ b/internal/web/chat.go @@ -28,6 +28,10 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { Images []chatImage `json:"images,omitempty"` // optional: base64-encoded images Mode string `json:"mode,omitempty"` // "build" or "plan" SessionID string `json:"session_id,omitempty"` // optional: the task (session) to run + // Source is an optional channel label (e.g. "console"/"mobile" from the + // cloud relay connector) propagated to the user_message event, exactly + // like SubmitMessage's source ("wechat"). Empty = web-originated. + Source string `json:"source,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 20<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) @@ -58,7 +62,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return } - sessionID := s.submitMessage(eng, req.Message, modeStr, "", req.SessionID, req.Images) + sessionID := s.submitMessage(eng, req.Message, modeStr, req.Source, req.SessionID, req.Images) writeJSON(w, http.StatusAccepted, map[string]string{"status": "processing", "session_id": sessionID}) } @@ -146,6 +150,16 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str // matching TUI/ACP. The mode arg is retained for the recorder/event context. _ = mode + // M19: stamp the receiving session's sync opt-in BEFORE the user_message + // Emit below — the connector's event pump gates uploads on the opt-in, so + // a cloud-originated first message would otherwise race uplink and be + // dropped (J8-S3: user_message missing from the durable log). Keyed on + // eng.taskID, the WS event task_id the gate checks; it coincides with the + // recorder UUID for local engines, and the recorder-based stamp below + // covers the resume/replace branch. SetIfAbsent makes repeat stamps + // (every message lands here) harmless. + s.stampCloudSync(eng.taskID, source, sessionID == "") + // Emit user_message event for external sources (e.g. WeChat) so web clients see it. // Web-originated messages are already added by the frontend's sendMessage(). if source != "" { @@ -158,6 +172,10 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str // Ensure a recorder exists (lazy creation on first message). // If the client provided a session_id and the current recorder differs, // resume the client's session to prevent creating a duplicate. + // stampNew captures whether THIS call minted the recording session (for + // the M19 sync stamp below; the store write happens after eng.emu is + // released). + var stampNew bool eng.emu.Lock() if eng.recorder == nil { rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) @@ -168,6 +186,8 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str eng.recorderInit(rec) } eng.recorder = rec + // sessionID == "" means the recorder just minted a new UUID. + stampNew = rec != nil && sessionID == "" } else if sessionID != "" && eng.recorder.UUID() != sessionID { // Client is continuing a session that doesn't match the current recorder. // Resume the client's session to keep all messages together. @@ -181,6 +201,16 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str } recorder := eng.recorder eng.emu.Unlock() + // M19: stamp the receiving session's initial cloud-sync state on EVERY + // submitted message, not only when this call created the recorder — the + // bootstrap engine already has a recorder at startup, and a cloud + // chat.send landing on it (the J8 regression) must still opt the session + // in. stampCloudSync is a no-op for local sources unless the session is + // brand-new (then cloud.sync_default applies), and never overwrites an + // explicit user toggle. + if recorder != nil { + s.stampCloudSync(recorder.UUID(), source, stampNew) + } // Record user message. if recorder != nil { diff --git a/internal/web/cloud.go b/internal/web/cloud.go new file mode 100644 index 00000000..a6c79478 --- /dev/null +++ b/internal/web/cloud.go @@ -0,0 +1,95 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// Cloud API: relay status + the auto_connect toggle, backed by the cloud +// supervisor wired in from the command layer. The endpoints mirror the +// */status + */config shape of tool_search.go. + +type cloudConfigPayload struct { + AutoConnect *bool `json:"auto_connect"` +} + +// handleCloudStatus serves GET /api/cloud/status. Without a supervisor (e.g. +// headless builds) the status is synthesized from the on-disk credentials and +// config with state "offline". +func (s *Server) handleCloudStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.cloudStatus()) +} + +// cloudStatus returns the live supervisor status, or — when no supervisor is +// wired — an offline snapshot derived from ~/.jcode/cloud.json + config. +func (s *Server) cloudStatus() cloud.Status { + if s.cloudSupervisor != nil { + return s.cloudSupervisor.Status() + } + // A never-started supervisor reports exactly the synthesized offline + // status: credentials re-read from disk, no connector, state "offline". + return cloud.NewSupervisor(s.cfg, 0, "").Status() +} + +// handleCloudConfig serves POST /api/cloud/config: persists cloud.auto_connect +// and hot-applies it (start/stop the relay connector without a restart). On a +// SaveConfig failure the in-memory config is rolled back (done inside +// SetAutoConnect) and a 500 returned. +func (s *Server) handleCloudConfig(w http.ResponseWriter, r *http.Request) { + var req cloudConfigPayload + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid cloud config: " + err.Error()}) + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "request body must contain one JSON object"}) + return + } + if req.AutoConnect == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "auto_connect is required"}) + return + } + + enabled := *req.AutoConnect + if s.cloudSupervisor != nil { + if err := s.cloudSupervisor.SetAutoConnect(enabled); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, s.cloudSupervisor.Status()) + return + } + + // Nil supervisor: persist only (there is no live connector to hot-apply), + // with the same read/modify/write + rollback pattern. + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + if s.cfg == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "config unavailable"}) + return + } + previous := s.cfg.CloudSettings() + s.cfg.SetCloud(&config.CloudConfig{ + Enabled: previous.Enabled, + URL: previous.URL, + AutoConnect: &enabled, + E2EE: previous.E2EE, + SyncDefault: previous.SyncDefault, + }) + if err := config.SaveConfig(s.cfg); err != nil { + if previous == (config.CloudConfig{}) { + s.cfg.SetCloud(nil) + } else { + s.cfg.SetCloud(&previous) + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, s.cloudStatus()) +} diff --git a/internal/web/cloud_login.go b/internal/web/cloud_login.go new file mode 100644 index 00000000..3c352379 --- /dev/null +++ b/internal/web/cloud_login.go @@ -0,0 +1,331 @@ +// cloud_login.go implements the in-app device-code login (M11-W1): the web UI +// starts the RFC 8628 flow with POST /api/cloud/login, renders the user_code +// + verification URI (QR), and polls GET /api/cloud/login/status while the +// flow's goroutine polls the orchestrator. On success the credentials are +// written, config.cloud is enabled, and the supervisor starts the relay +// connector — no CLI involved. Logout revokes and clears credentials via the +// shared cloud.Logout (same code path as `jcode logout`). +package web + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// Login flow states served by GET /api/cloud/login/status. +const ( + loginIdle = "idle" + loginPending = "pending" + loginSuccess = "success" + loginError = "error" + loginExpired = "expired" +) + +// cloudLoginStartResponse is the answer of POST /api/cloud/login. +type cloudLoginStartResponse struct { + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresAt string `json:"expires_at"` // RFC 3339 +} + +// cloudLoginStatusResponse is the answer of GET /api/cloud/login/status. +type cloudLoginStatusResponse struct { + State string `json:"state"` + UserCode string `json:"user_code,omitempty"` + VerificationURI string `json:"verification_uri,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` +} + +// cloudLoginFlow is the device-code login state machine. Exactly one flow may +// be pending at a time; a repeated Start while pending returns the in-flight +// flow's user_code/URI instead of starting a second one. +type cloudLoginFlow struct { + version string + // afterLogin runs once after a successful login persists credentials and + // config (wired to the supervisor's SyncCredentials). May be nil. + afterLogin func() + + // Test hooks (zero values in production): newClient overrides the + // orchestrator client factory, pollInterval shrinks the token poll cadence. + newClient func(baseURL string) *cloud.Client + pollInterval time.Duration + + mu sync.Mutex + state string + cloudURL string + userCode string + verificationURI string + expiresAt time.Time + err string +} + +func (f *cloudLoginFlow) client(baseURL string) *cloud.Client { + if f.newClient != nil { + return f.newClient(baseURL) + } + return cloud.NewClient(baseURL) +} + +// status snapshots the flow state (idle before any Start). +func (f *cloudLoginFlow) status() cloudLoginStatusResponse { + f.mu.Lock() + defer f.mu.Unlock() + return f.statusLocked() +} + +func (f *cloudLoginFlow) statusLocked() cloudLoginStatusResponse { + resp := cloudLoginStatusResponse{State: f.state} + if resp.State == "" { + resp.State = loginIdle + } + if f.state == loginPending { + resp.UserCode = f.userCode + resp.VerificationURI = f.verificationURI + resp.ExpiresAt = f.expiresAt.UTC().Format(time.RFC3339) + } + if f.state == loginError { + resp.Error = f.err + } + return resp +} + +// reset returns the flow to idle (called on logout). +func (f *cloudLoginFlow) reset() { + f.mu.Lock() + defer f.mu.Unlock() + f.state = loginIdle + f.cloudURL, f.userCode, f.verificationURI, f.err = "", "", "", "" + f.expiresAt = time.Time{} +} + +// start begins a device-code flow against rawURL (already validated by the +// caller or defaulted) and returns the user-facing code/URI. A pending flow +// is returned as-is; a finished one (success/error/expired) is replaced. +// parentCtx scopes the background polling goroutine (web server lifetime). +func (f *cloudLoginFlow) start(parentCtx context.Context, rawURL string) (cloudLoginStartResponse, error) { + baseURL, err := cloud.ValidateCloudURL(rawURL) + if err != nil { + return cloudLoginStartResponse{}, err + } + + f.mu.Lock() + if f.state == loginPending { + resp := cloudLoginStartResponse{ + UserCode: f.userCode, + VerificationURI: f.verificationURI, + ExpiresAt: f.expiresAt.UTC().Format(time.RFC3339), + } + f.mu.Unlock() + return resp, nil + } + f.mu.Unlock() + + client := f.client(baseURL) + dc, err := client.RequestDeviceCode(parentCtx, "jcode web "+f.version) + if err != nil { + return cloudLoginStartResponse{}, err + } + + expiresIn := time.Duration(dc.ExpiresIn) * time.Second + if expiresIn <= 0 { + expiresIn = 10 * time.Minute + } + expiresAt := time.Now().Add(expiresIn) + interval := time.Duration(dc.Interval) * time.Second + if f.pollInterval > 0 { + interval = f.pollInterval + } + + f.mu.Lock() + f.state = loginPending + f.cloudURL = baseURL + f.userCode = dc.UserCode + f.verificationURI = dc.VerificationURI + f.expiresAt = expiresAt + f.err = "" + f.mu.Unlock() + + go f.pollAndFinish(parentCtx, client, baseURL, dc.DeviceCode, interval, expiresIn) + return cloudLoginStartResponse{ + UserCode: dc.UserCode, + VerificationURI: dc.VerificationURI, + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), + }, nil +} + +// pollAndFinish polls the token endpoint until the user authorizes, then +// persists the device identity (same steps as `jcode login`): identity key +// pair → device register → credentials file → config.cloud. +func (f *cloudLoginFlow) pollAndFinish(ctx context.Context, client *cloud.Client, baseURL, deviceCode string, interval, expiresIn time.Duration) { + // M16: the machine fingerprint hash rides the poll (login dedup) and the + // register call; the source is persisted into cloud.json. + fpSource, err := cloud.ResolveFingerprintSource() + if err != nil { + f.fail(fmt.Errorf("failed to resolve the machine fingerprint: %w", err)) + return + } + fpHash := cloud.FingerprintHash(fpSource) + + tok, err := client.PollForToken(ctx, deviceCode, fpHash, interval, expiresIn) + if err != nil { + f.fail(err) + return + } + + pubKey, privKey, err := cloud.GenerateIdentityKeyPair() + if err != nil { + f.fail(err) + return + } + hostname, _ := os.Hostname() + name := hostname + if name == "" { + name = "jcode-device" + } + if err := client.RegisterDevice(ctx, tok.AccessToken, cloud.RegisterDeviceRequest{ + Name: name, + Hostname: hostname, + JcodeVersion: f.version, + PubKey: pubKey, + Fingerprint: fpHash, + }); err != nil { + f.fail(err) + return + } + if err := cloud.SaveCredentials(&cloud.Credentials{ + CloudURL: baseURL, + DeviceID: tok.DeviceID, + DeviceToken: tok.AccessToken, + DeviceName: name, + PublicKey: pubKey, + PrivateKey: privKey, + KeyGen: 1, + Fingerprint: fpSource, + }); err != nil { + f.fail(err) + return + } + if err := cloud.UpdateConfigCloud(baseURL, true); err != nil { + config.Logger().Printf("[cloud] login: failed to update %s: %v", config.ConfigPath(), err) + } + + f.mu.Lock() + f.state = loginSuccess + f.userCode, f.verificationURI = "", "" + f.expiresAt = time.Time{} + f.mu.Unlock() + if f.afterLogin != nil { + f.afterLogin() + } +} + +// fail lands the flow in its terminal error state: expired for an expired +// device code (UI offers retry), error otherwise (denied or any failure). +func (f *cloudLoginFlow) fail(err error) { + f.mu.Lock() + defer f.mu.Unlock() + if errors.Is(err, cloud.ErrDeviceCodeExpired) { + f.state = loginExpired + } else { + f.state = loginError + } + f.err = err.Error() + f.userCode, f.verificationURI = "", "" + f.expiresAt = time.Time{} +} + +// loginFlow returns the server's flow, constructing it on first use. The +// afterLogin hook is bound late so the supervisor starts the connector right +// after a successful web login. +func (s *Server) loginFlow() *cloudLoginFlow { + s.cloudLoginMu.Lock() + defer s.cloudLoginMu.Unlock() + if s.cloudLogin == nil { + s.cloudLogin = &cloudLoginFlow{ + version: s.version, + afterLogin: func() { + if s.cloudSupervisor != nil { + s.cloudSupervisor.SyncCredentials() + } + }, + } + } + return s.cloudLogin +} + +// handleCloudLogin serves POST /api/cloud/login: starts (or re-joins) the +// device-code flow. The optional body {"cloud_url": "..."} selects the +// orchestrator; empty falls back to config.cloud.url, then the public +// default. Already-logged-in devices get a 409. +func (s *Server) handleCloudLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + CloudURL string `json:"cloud_url"` + } + if r.Body != nil { + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16)) + if err := decoder.Decode(&req); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid login request: " + err.Error()}) + return + } + } + + if creds, err := cloud.LoadCredentials(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } else if creds != nil && creds.DeviceToken != "" { + writeJSON(w, http.StatusConflict, map[string]string{"error": "already logged in — log out first to sign in again"}) + return + } + + rawURL := req.CloudURL + if rawURL == "" && s.cfg != nil { + rawURL = s.cfg.CloudSettings().URL + } + if rawURL == "" { + rawURL = cloud.DefaultCloudURL + } + + parent := s.rootCtx() + if parent == nil { + parent = context.Background() + } + resp, err := s.loginFlow().start(parent, rawURL) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "failed to start login: " + err.Error()}) + return + } + writeJSON(w, http.StatusOK, resp) +} + +// handleCloudLoginStatus serves GET /api/cloud/login/status. +func (s *Server) handleCloudLoginStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.loginFlow().status()) +} + +// handleCloudLogout serves POST /api/cloud/logout: revoke + clear credentials +// (shared cloud.Logout), stop the connector, reset the login flow, and return +// the fresh status so the badge updates in one round trip. +func (s *Server) handleCloudLogout(w http.ResponseWriter, r *http.Request) { + if err := cloud.Logout(r.Context(), func(format string, args ...any) { + config.Logger().Printf("[cloud] "+format, args...) + }); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if s.cloudSupervisor != nil { + s.cloudSupervisor.SyncCredentials() + } + s.loginFlow().reset() + writeJSON(w, http.StatusOK, s.cloudStatus()) +} diff --git a/internal/web/cloud_login_test.go b/internal/web/cloud_login_test.go new file mode 100644 index 00000000..b8475d05 --- /dev/null +++ b/internal/web/cloud_login_test.go @@ -0,0 +1,324 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// loginMockState counts orchestrator calls made by the login flow. +type loginMockState struct { + codeCalls int32 + pollCalls int32 + registerCalls int32 + revokeCalls int32 +} + +// newLoginServer builds a mock orchestrator with the device-auth endpoints; +// onToken decides each token poll's HTTP status and body. +func newLoginServer(t *testing.T, onToken func(poll int32) (int, string)) (*httptest.Server, *loginMockState) { + t.Helper() + st := &loginMockState{} + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/device/code", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&st.codeCalls, 1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "dc-1", + "user_code": "ABCD-EFGH", + "verification_uri": "https://cloud.example.com/device", + "expires_in": 300, + "interval": 5, + }) + }) + mux.HandleFunc("POST /auth/device/token", func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&st.pollCalls, 1) + status, body := onToken(n) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + }) + mux.HandleFunc("POST /internal/v1/device/register", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&st.registerCalls, 1) + if r.Header.Get("Authorization") != "Bearer dev-token" { + t.Errorf("register: Authorization = %q, want the device token", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte(`{}`)) + }) + mux.HandleFunc("POST /internal/v1/device/revoke", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&st.revokeCalls, 1) + _, _ = w.Write([]byte(`{}`)) + }) + return httptest.NewServer(mux), st +} + +const ( + tokenPending = `{"error":{"code":"authorization_pending","message":"pending"}}` + tokenDenied = `{"error":{"code":"access_denied","message":"user denied the code"}}` + tokenExpired = `{"error":{"code":"expired_token","message":"code expired"}}` + tokenSuccess = `{"access_token":"dev-token","token_type":"bearer","device_id":"dev-1"}` +) + +// newCloudLoginTestServer returns a Server whose login flow polls fast. +func newCloudLoginTestServer(t *testing.T, sup CloudSupervisor) *Server { + t.Helper() + s := &Server{cfg: &config.Config{}, version: "test", cloudSupervisor: sup} + s.loginFlow().pollInterval = time.Millisecond + return s +} + +func postCloudLogin(t *testing.T, s *Server, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + s.handleCloudLogin(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/login", strings.NewReader(body))) + return rec +} + +func getCloudLoginStatus(t *testing.T, s *Server) cloudLoginStatusResponse { + t.Helper() + rec := httptest.NewRecorder() + s.handleCloudLoginStatus(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/login/status", nil)) + var st cloudLoginStatusResponse + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + return st +} + +func waitLoginState(t *testing.T, s *Server, want string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if got := s.loginFlow().status().State; got == want { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("login state = %q, want %q", s.loginFlow().status().State, want) +} + +// Full flow: code → pending (poll blocked at the mock) → success writes +// credentials + config and kicks the supervisor; repeat POST re-joins the +// pending flow; logout revokes and clears everything. +func TestCloudLoginFlowSuccessAndLogout(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + release := make(chan struct{}) + srv, st := newLoginServer(t, func(int32) (int, string) { + <-release // hold every poll until the test releases the gate + return http.StatusOK, tokenSuccess + }) + defer srv.Close() + fake := &fakeCloudSupervisor{} + s := newCloudLoginTestServer(t, fake) + + rec := postCloudLogin(t, s, `{"cloud_url":"`+srv.URL+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST login: status=%d body=%s", rec.Code, rec.Body.String()) + } + var start cloudLoginStartResponse + if err := json.Unmarshal(rec.Body.Bytes(), &start); err != nil { + t.Fatal(err) + } + if start.UserCode != "ABCD-EFGH" || start.VerificationURI == "" || start.ExpiresAt == "" { + t.Fatalf("start response = %+v", start) + } + + // The poll goroutine is blocked at the mock: status is deterministically + // pending and carries the user-facing code. + if got := getCloudLoginStatus(t, s); got.State != loginPending || got.UserCode != "ABCD-EFGH" { + t.Fatalf("status = %+v, want pending with the user_code", got) + } + // A repeated POST re-joins the in-flight flow instead of starting over. + rec = postCloudLogin(t, s, `{"cloud_url":"`+srv.URL+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("repeat POST login: status=%d body=%s", rec.Code, rec.Body.String()) + } + var again cloudLoginStartResponse + if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil { + t.Fatal(err) + } + if again.UserCode != start.UserCode || atomic.LoadInt32(&st.codeCalls) != 1 { + t.Fatalf("repeat POST started a new flow: %+v, code calls=%d", again, st.codeCalls) + } + + close(release) + waitLoginState(t, s, loginSuccess) + + creds, err := cloud.LoadCredentials() + if err != nil || creds == nil { + t.Fatalf("credentials after login: %v, %v", creds, err) + } + if creds.DeviceID != "dev-1" || creds.DeviceToken != "dev-token" || creds.CloudURL != srv.URL || + creds.PublicKey == "" || creds.PrivateKey == "" || creds.KeyGen != 1 { + t.Fatalf("credentials = %+v", creds) + } + if atomic.LoadInt32(&st.registerCalls) != 1 { + t.Fatalf("register calls = %d, want 1", st.registerCalls) + } + persisted := readPersistedCloud(t) + if persisted == nil || !persisted.Enabled || persisted.URL != srv.URL { + t.Fatalf("persisted config.cloud = %+v, want enabled with url %s", persisted, srv.URL) + } + if fake.syncCalls != 1 { + t.Fatalf("supervisor sync calls = %d, want 1 (connector kick after login)", fake.syncCalls) + } + if got := getCloudLoginStatus(t, s); got.State != loginSuccess || got.UserCode != "" { + t.Fatalf("status = %+v, want success without user_code", got) + } + + // Logged in now: a fresh login POST conflicts. + if rec := postCloudLogin(t, s, `{}`); rec.Code != http.StatusConflict { + t.Fatalf("POST login while logged in: status=%d, want 409", rec.Code) + } + + // Logout: revoke + clear + connector stop + flow reset, fresh status back. + rec = httptest.NewRecorder() + s.handleCloudLogout(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/logout", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("POST logout: status=%d body=%s", rec.Code, rec.Body.String()) + } + var st2 cloudStatusJSON + if err := json.Unmarshal(rec.Body.Bytes(), &st2); err != nil { + t.Fatal(err) + } + if st2.LoggedIn { + t.Fatalf("status after logout = %+v, want logged_in=false", st2) + } + if creds, _ := cloud.LoadCredentials(); creds != nil { + t.Fatalf("credentials after logout = %+v, want nil", creds) + } + if atomic.LoadInt32(&st.revokeCalls) != 1 { + t.Fatalf("revoke calls = %d, want 1", st.revokeCalls) + } + if fake.syncCalls != 2 { + t.Fatalf("supervisor sync calls = %d, want 2 (login + logout)", fake.syncCalls) + } + if persisted := readPersistedCloud(t); persisted == nil || persisted.Enabled { + t.Fatalf("persisted config.cloud after logout = %+v, want enabled=false", persisted) + } + if got := getCloudLoginStatus(t, s); got.State != loginIdle { + t.Fatalf("login state after logout = %q, want idle", got.State) + } +} + +// Denial lands the flow in the error state; a later POST retries cleanly. +func TestCloudLoginDeniedThenRetry(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var mode int32 // 0 = deny, 1 = succeed + srv, _ := newLoginServer(t, func(int32) (int, string) { + if atomic.LoadInt32(&mode) == 0 { + return http.StatusBadRequest, tokenDenied + } + return http.StatusOK, tokenSuccess + }) + defer srv.Close() + s := newCloudLoginTestServer(t, nil) + + if rec := postCloudLogin(t, s, `{"cloud_url":"`+srv.URL+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST login: status=%d body=%s", rec.Code, rec.Body.String()) + } + waitLoginState(t, s, loginError) + if got := getCloudLoginStatus(t, s); !strings.Contains(got.Error, "denied") { + t.Fatalf("status = %+v, want the denial message", got) + } + if creds, _ := cloud.LoadCredentials(); creds != nil { + t.Fatalf("credentials = %+v, want nil after denial", creds) + } + + atomic.StoreInt32(&mode, 1) + if rec := postCloudLogin(t, s, `{"cloud_url":"`+srv.URL+`"}`); rec.Code != http.StatusOK { + t.Fatalf("retry POST login: status=%d body=%s", rec.Code, rec.Body.String()) + } + waitLoginState(t, s, loginSuccess) +} + +// An expired device code lands in the expired state (UI offers retry). +func TestCloudLoginExpired(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + srv, _ := newLoginServer(t, func(int32) (int, string) { + return http.StatusBadRequest, tokenExpired + }) + defer srv.Close() + s := newCloudLoginTestServer(t, nil) + + if rec := postCloudLogin(t, s, `{"cloud_url":"`+srv.URL+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST login: status=%d body=%s", rec.Code, rec.Body.String()) + } + waitLoginState(t, s, loginExpired) + if creds, _ := cloud.LoadCredentials(); creds != nil { + t.Fatalf("credentials = %+v, want nil after expiry", creds) + } +} + +// Validation: bad JSON → 400, an unreachable/invalid cloud URL → 502, and +// polling stays pending while the user has not authorized. +func TestCloudLoginValidation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := newCloudLoginTestServer(t, nil) + + if rec := postCloudLogin(t, s, `{`); rec.Code != http.StatusBadRequest { + t.Fatalf("bad json: status=%d, want 400", rec.Code) + } + // Plain http on a non-localhost host violates the scheme policy. + if rec := postCloudLogin(t, s, `{"cloud_url":"http://example.com"}`); rec.Code != http.StatusBadGateway { + t.Fatalf("invalid cloud url: status=%d, want 502", rec.Code) + } + // Never-authorized flow: status stays pending (the poll goroutine exits + // with an error once the mock server closes at test end). + srv, _ := newLoginServer(t, func(int32) (int, string) { + return http.StatusBadRequest, tokenPending + }) + defer srv.Close() + if rec := postCloudLogin(t, s, `{"cloud_url":"`+srv.URL+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST login: status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := getCloudLoginStatus(t, s); got.State != loginPending { + t.Fatalf("status = %+v, want pending", got) + } +} + +// Logout without credentials is a 200 no-op. +func TestCloudLogoutNotLoggedIn(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := newCloudLoginTestServer(t, nil) + rec := httptest.NewRecorder() + s.handleCloudLogout(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/logout", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("POST logout: status=%d body=%s", rec.Code, rec.Body.String()) + } + var st cloudStatusJSON + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + if st.LoggedIn { + t.Fatalf("status = %+v, want logged_in=false", st) + } +} + +type persistedCloud struct { + Enabled bool `json:"enabled"` + URL string `json:"url"` +} + +func readPersistedCloud(t *testing.T) *persistedCloud { + t.Helper() + data, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".jcode", "config.json")) + if err != nil { + t.Fatal(err) + } + var disk struct { + Cloud *persistedCloud `json:"cloud"` + } + if err := json.Unmarshal(data, &disk); err != nil { + t.Fatal(err) + } + return disk.Cloud +} diff --git a/internal/web/cloud_pairings.go b/internal/web/cloud_pairings.go new file mode 100644 index 00000000..a18e3923 --- /dev/null +++ b/internal/web/cloud_pairings.go @@ -0,0 +1,116 @@ +// cloud_pairings.go implements the web pairing-approval endpoints (M11-W1): +// pending pairing requests arrive at the relay connector as pairing.request +// commands and are parked in its inbox; these endpoints list and resolve +// them (approve wraps the CEK for the requester via the M5 ECIES path, deny +// refuses). POST /api/cloud/pairing-offer mints a QR pairing offer (W3) and +// returns the jcode://pair URL the UI renders as a QR code. +package web + +import ( + "errors" + "net/http" + "net/url" + + "github.com/cnjack/jcode/internal/cloud" +) + +// cloudPairingsResponse is the answer of GET /api/cloud/pairings. LastPaired +// lets the 5s-poller notice approvals (manual or QR auto-approve) and toast. +type cloudPairingsResponse struct { + Pairings []cloud.PendingPairing `json:"pairings"` + LastPaired *cloud.PairedInfo `json:"last_paired,omitempty"` +} + +func (s *Server) pairingsSnapshot() cloudPairingsResponse { + resp := cloudPairingsResponse{Pairings: []cloud.PendingPairing{}} + if s.cloudSupervisor == nil { + return resp + } + if list := s.cloudSupervisor.PendingPairings(); len(list) > 0 { + resp.Pairings = list + } + if lp, ok := s.cloudSupervisor.LastPaired(); ok { + resp.LastPaired = &lp + } + return resp +} + +// handleCloudPairings serves GET /api/cloud/pairings. +func (s *Server) handleCloudPairings(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.pairingsSnapshot()) +} + +// resolvePairing runs the supervisor's approve/deny for the {id} path value +// and maps the outcome to an HTTP status: 404 for an unknown/expired inbox +// entry, 503 when the relay is not connected, 500 for upstream failures. +func (s *Server) resolvePairing(w http.ResponseWriter, r *http.Request, approve bool) { + if s.cloudSupervisor == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "cloud relay is not available"}) + return + } + id := r.PathValue("id") + var err error + if approve { + err = s.cloudSupervisor.ApprovePairing(r.Context(), id) + } else { + err = s.cloudSupervisor.DenyPairing(r.Context(), id) + } + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, cloud.ErrUnknownPairing) { + status = http.StatusNotFound + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, s.pairingsSnapshot()) +} + +// handleCloudPairingApprove serves POST /api/cloud/pairings/{id}/approve. +func (s *Server) handleCloudPairingApprove(w http.ResponseWriter, r *http.Request) { + s.resolvePairing(w, r, true) +} + +// handleCloudPairingDeny serves POST /api/cloud/pairings/{id}/deny. +func (s *Server) handleCloudPairingDeny(w http.ResponseWriter, r *http.Request) { + s.resolvePairing(w, r, false) +} + +// cloudPairingOfferResponse is the answer of POST /api/cloud/pairing-offer: +// the jcode://pair URL to render as a QR code, plus its expiry for the +// countdown display. +type cloudPairingOfferResponse struct { + QR string `json:"qr"` + OfferID string `json:"offer_id"` + ExpiresAt string `json:"expires_at"` +} + +// handleCloudPairingOffer serves POST /api/cloud/pairing-offer: mints an +// offer at the orchestrator and builds the jcode://pair?cloud=…&device=…&offer=…&secret=… +// URL the mobile app scans to pair (W3 scan-to-pair). +func (s *Server) handleCloudPairingOffer(w http.ResponseWriter, r *http.Request) { + creds, err := cloud.LoadCredentials() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if creds == nil || creds.DeviceToken == "" { + writeJSON(w, http.StatusConflict, map[string]string{"error": "not logged in"}) + return + } + offer, err := cloud.NewClient(creds.CloudURL).CreatePairingOffer(r.Context(), creds.DeviceToken) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "failed to create pairing offer: " + err.Error()}) + return + } + q := url.Values{} + q.Set("cloud", creds.CloudURL) + q.Set("device", creds.DeviceID) + q.Set("offer", offer.OfferID) + q.Set("secret", offer.Secret) + writeJSON(w, http.StatusOK, cloudPairingOfferResponse{ + QR: "jcode://pair?" + q.Encode(), + OfferID: offer.OfferID, + ExpiresAt: offer.ExpiresAt, + }) +} diff --git a/internal/web/cloud_pairings_test.go b/internal/web/cloud_pairings_test.go new file mode 100644 index 00000000..8b2ceec2 --- /dev/null +++ b/internal/web/cloud_pairings_test.go @@ -0,0 +1,159 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// GET /api/cloud/pairings mirrors the supervisor inbox (plus the last-paired +// notification); a nil supervisor yields an empty list. +func TestCloudPairingsList(t *testing.T) { + received := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + fake := &fakeCloudSupervisor{ + pairings: []cloud.PendingPairing{ + {PairingID: "p1", Label: "jcode mobile", ReceivedAt: received, PubKey: "secret-key"}, + }, + lastPaired: &cloud.PairedInfo{PairingID: "p0", Label: "jcode android", Auto: true, PairedAt: received}, + } + s := &Server{cfg: &config.Config{}, cloudSupervisor: fake} + + rec := httptest.NewRecorder() + s.handleCloudPairings(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/pairings", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp cloudPairingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Pairings) != 1 || resp.Pairings[0].PairingID != "p1" || resp.Pairings[0].Label != "jcode mobile" { + t.Fatalf("pairings = %+v", resp.Pairings) + } + if resp.LastPaired == nil || resp.LastPaired.PairingID != "p0" || !resp.LastPaired.Auto { + t.Fatalf("last_paired = %+v", resp.LastPaired) + } + // The requester pubkey must never leave the process. + if strings.Contains(rec.Body.String(), "secret-key") { + t.Fatalf("response leaks the requester pubkey: %s", rec.Body.String()) + } + + s = &Server{cfg: &config.Config{}} + rec = httptest.NewRecorder() + s.handleCloudPairings(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/pairings", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pairings":[]`) { + t.Fatalf("nil supervisor: status=%d body=%s, want empty list", rec.Code, rec.Body.String()) + } +} + +// Approve/deny delegate to the supervisor; unknown ids map to 404 and a +// missing relay to 503. +func TestCloudPairingApproveDeny(t *testing.T) { + post := func(s *Server, path, id string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, nil) + req.SetPathValue("id", id) + rec := httptest.NewRecorder() + if strings.HasSuffix(path, "/approve") { + s.handleCloudPairingApprove(rec, req) + } else { + s.handleCloudPairingDeny(rec, req) + } + return rec + } + + fake := &fakeCloudSupervisor{ + pairings: []cloud.PendingPairing{{PairingID: "p1", Label: "x", ReceivedAt: time.Now().UTC()}}, + } + s := &Server{cfg: &config.Config{}, cloudSupervisor: fake} + + if rec := post(s, "/api/cloud/pairings/p1/approve", "p1"); rec.Code != http.StatusOK { + t.Fatalf("approve: status=%d body=%s", rec.Code, rec.Body.String()) + } + if rec := post(s, "/api/cloud/pairings/p2/deny", "p2"); rec.Code != http.StatusOK { + t.Fatalf("deny: status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(fake.approved) != 1 || fake.approved[0] != "p1" || len(fake.denied) != 1 || fake.denied[0] != "p2" { + t.Fatalf("supervisor calls: approved=%v denied=%v", fake.approved, fake.denied) + } + + fake.pairingErr = cloud.ErrUnknownPairing + if rec := post(s, "/api/cloud/pairings/gone/approve", "gone"); rec.Code != http.StatusNotFound { + t.Fatalf("unknown pairing: status=%d, want 404", rec.Code) + } + + s = &Server{cfg: &config.Config{}} + if rec := post(s, "/api/cloud/pairings/p1/approve", "p1"); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("nil supervisor approve: status=%d, want 503", rec.Code) + } +} + +// POST /api/cloud/pairing-offer mints an offer at the orchestrator and +// returns the jcode://pair URL for the QR code. +func TestCloudPairingOffer(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var gotAuth string + mux := http.NewServeMux() + mux.HandleFunc("POST /internal/v1/device/pairing-offers", func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]string{ + "offer_id": "of-1", + "secret": "s3cret", + "expires_at": "2026-07-21T04:00:00Z", + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + if err := cloud.SaveCredentials(&cloud.Credentials{ + CloudURL: srv.URL, DeviceID: "dev-9", DeviceToken: "tok", DeviceName: "box", + }); err != nil { + t.Fatal(err) + } + + s := &Server{cfg: &config.Config{}} + rec := httptest.NewRecorder() + s.handleCloudPairingOffer(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/pairing-offer", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if gotAuth != "Bearer tok" { + t.Errorf("orchestrator Authorization = %q, want Bearer tok", gotAuth) + } + var resp cloudPairingOfferResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.OfferID != "of-1" || resp.ExpiresAt != "2026-07-21T04:00:00Z" { + t.Fatalf("offer response = %+v", resp) + } + u, err := url.Parse(resp.QR) + if err != nil { + t.Fatalf("qr URL: %v", err) + } + if u.Scheme != "jcode" || u.Host != "pair" { + t.Fatalf("qr URL = %q, want jcode://pair?…", resp.QR) + } + q := u.Query() + if q.Get("cloud") != srv.URL || q.Get("device") != "dev-9" || q.Get("offer") != "of-1" || q.Get("secret") != "s3cret" { + t.Fatalf("qr query = %v", q) + } +} + +// The offer endpoint requires a logged-in device. +func TestCloudPairingOfferNotLoggedIn(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{cfg: &config.Config{}} + rec := httptest.NewRecorder() + s.handleCloudPairingOffer(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/pairing-offer", nil)) + if rec.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/cloud_sync.go b/internal/web/cloud_sync.go new file mode 100644 index 00000000..891e981a --- /dev/null +++ b/internal/web/cloud_sync.go @@ -0,0 +1,176 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +// Cloud sync API (M19): the per-session sync switches and the global +// new-session default. +// +// GET /api/cloud/sync → {sync_default, sessions:{id:bool}} +// POST /api/cloud/sync/default {enabled} → persists cloud.sync_default +// POST /api/cloud/sync/{session_id} {enabled} → writes ~/.jcode/cloud-sessions.json +// +// The connector holds its own SyncStore instance on the same file and picks +// writes up via mtime (event filter immediately, metadata upsert on the next +// session-sync tick), so no supervisor round-trip is needed here. + +// cloudSyncPayload is the GET /api/cloud/sync response (and the POST +// /sync/default response — it returns the fresh state). +type cloudSyncPayload struct { + SyncDefault bool `json:"sync_default"` + Sessions map[string]bool `json:"sessions"` +} + +// cloudSyncStoreLoad returns the server's lazy SyncStore (~/.jcode/ +// cloud-sessions.json). Lazy because tests construct Server literals; a load +// failure is returned (POST → 500, GET → empty sessions map). +func (s *Server) cloudSyncStoreLoad() (*cloud.SyncStore, error) { + s.cloudSyncMu.Lock() + defer s.cloudSyncMu.Unlock() + if s.cloudSyncStore != nil { + return s.cloudSyncStore, nil + } + if s.cloudSyncErr != nil { + return nil, s.cloudSyncErr + } + path, err := cloud.SyncStorePath() + if err != nil { + s.cloudSyncErr = err + return nil, err + } + store, err := cloud.LoadSyncStore(path) + if err != nil { + s.cloudSyncErr = err + return nil, err + } + s.cloudSyncStore = store + return store, nil +} + +// cloudSyncState builds the GET payload. A store load failure reports an +// empty sessions map (sync_default still reflects the config) — the settings +// UI must not break on an unreadable store file. +func (s *Server) cloudSyncState() cloudSyncPayload { + payload := cloudSyncPayload{ + SyncDefault: config.CloudSyncDefault(s.cfg), + Sessions: map[string]bool{}, + } + if store, err := s.cloudSyncStoreLoad(); err == nil { + payload.Sessions = store.Snapshot() + } + return payload +} + +// handleCloudSync serves GET /api/cloud/sync. +func (s *Server) handleCloudSync(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, s.cloudSyncState()) +} + +// handleCloudSyncDefault serves POST /api/cloud/sync/default: persists +// cloud.sync_default (read/modify/write preserving the other cloud fields, +// same rollback pattern as handleCloudConfig). Only sessions created AFTER +// this change are affected; existing sessions keep their current state. +func (s *Server) handleCloudSyncDefault(w http.ResponseWriter, r *http.Request) { + var req struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil || req.Enabled == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enabled is required"}) + return + } + enabled := *req.Enabled + + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + if s.cfg == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "config unavailable"}) + return + } + previous := s.cfg.CloudSettings() + s.cfg.SetCloud(&config.CloudConfig{ + Enabled: previous.Enabled, + URL: previous.URL, + AutoConnect: previous.AutoConnect, + E2EE: previous.E2EE, + SyncDefault: enabled, + }) + if err := config.SaveConfig(s.cfg); err != nil { + if previous == (config.CloudConfig{}) { + s.cfg.SetCloud(nil) + } else { + s.cfg.SetCloud(&previous) + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, s.cloudSyncState()) +} + +// handleCloudSyncSession serves POST /api/cloud/sync/{session_id}: writes the +// session's explicit sync opt-in/out. The connector picks the change up via +// the store file's mtime: event filtering applies to the very next event and +// a newly enabled session's metadata is upserted on the next session-sync +// tick. Disabling never deletes already-uploaded cloud history. +func (s *Server) handleCloudSyncSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("session_id") + if sessionID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session id is required"}) + return + } + var req struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil || req.Enabled == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enabled is required"}) + return + } + store, err := s.cloudSyncStoreLoad() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if err := store.Set(sessionID, *req.Enabled); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"session_id": sessionID, "enabled": *req.Enabled}) +} + +// stampCloudSync records the initial per-session sync state for a session the +// web layer is creating (M19). Semantics: +// +// - cloud-originated sessions (the console/mobile relay channels) ALWAYS +// opt in — a session created from the cloud must be visible there; +// - otherwise new sessions follow cloud.sync_default; +// - resumed local sessions (isNew=false, local source) are never stamped: +// pre-M19 sessions stay exactly as they are (no retroactive changes); +// - an existing explicit entry (a user toggle) is never overwritten. +// +// Best-effort: a store failure is logged, never fails session creation. +func (s *Server) stampCloudSync(sessionID, source string, isNew bool) { + if sessionID == "" { + return + } + cloudOriginated := source == "console" || source == "mobile" + enabled := cloudOriginated || (isNew && config.CloudSyncDefault(s.cfg)) + if !enabled { + // Nothing to record: an unset entry already means "not synced". This + // also leaves resumed pre-M19 local sessions untouched (no retroactive + // changes, whatever the default is). + return + } + store, err := s.cloudSyncStoreLoad() + if err != nil { + config.Logger().Printf("[cloud] sync store unavailable, session %s sync state not stamped: %v", sessionID, err) + return + } + if _, err := store.SetIfAbsent(sessionID, enabled); err != nil { + config.Logger().Printf("[cloud] failed to stamp session %s sync state: %v", sessionID, err) + } +} diff --git a/internal/web/cloud_sync_test.go b/internal/web/cloud_sync_test.go new file mode 100644 index 00000000..8b949274 --- /dev/null +++ b/internal/web/cloud_sync_test.go @@ -0,0 +1,243 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +type cloudSyncJSON struct { + SyncDefault bool `json:"sync_default"` + Sessions map[string]bool `json:"sessions"` +} + +// GET /api/cloud/sync on a fresh install: default OFF, no per-session entries. +func TestCloudSyncGetDefaults(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{cfg: &config.Config{}} + + rec := httptest.NewRecorder() + s.handleCloudSync(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/sync", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var got cloudSyncJSON + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SyncDefault { + t.Error("sync_default must default to false (nothing syncs by default)") + } + if len(got.Sessions) != 0 { + t.Errorf("sessions = %v, want empty", got.Sessions) + } +} + +// POST /api/cloud/sync/{id} persists the per-session switch to +// ~/.jcode/cloud-sessions.json (0600) and GET reflects it. +func TestCloudSyncSessionSetPersists(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{cfg: &config.Config{}} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/cloud/sync/s1", strings.NewReader(`{"enabled":true}`)) + req.SetPathValue("session_id", "s1") + s.handleCloudSyncSession(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + // On-disk state (a fresh store instance sees it — the connector's view). + path, err := cloud.SyncStorePath() + if err != nil { + t.Fatal(err) + } + store, err := cloud.LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if !store.Enabled("s1") { + t.Error("s1 must persist as enabled") + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Errorf("store perm = %o, want 600", perm) + } + + // GET reflects the entry. + rec = httptest.NewRecorder() + s.handleCloudSync(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/sync", nil)) + var got cloudSyncJSON + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.Sessions["s1"] { + t.Errorf("GET sessions = %v, want s1=true", got.Sessions) + } + + // Explicit disable persists too (an explicit false survives restart, + // winning over any global default). + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/cloud/sync/s1", strings.NewReader(`{"enabled":false}`)) + req.SetPathValue("session_id", "s1") + s.handleCloudSyncSession(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + store, err = cloud.LoadSyncStore(path) + if err != nil { + t.Fatal(err) + } + if store.Enabled("s1") || !store.Has("s1") { + t.Error("s1 must persist as explicit-disabled") + } +} + +// POST /api/cloud/sync/default persists cloud.sync_default without touching +// the other cloud config fields; existing per-session entries are unchanged +// (no retroactive effect). +func TestCloudSyncDefaultToggle(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + auto := false + cfg := &config.Config{} + cfg.SetCloud(&config.CloudConfig{Enabled: true, URL: "https://cloud.example.com", AutoConnect: &auto}) + s := &Server{cfg: cfg} + + // Pre-existing per-session entry. + store, err := s.cloudSyncStoreLoad() + if err != nil { + t.Fatal(err) + } + if err := store.Set("s1", true); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + s.handleCloudSyncDefault(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/sync/default", strings.NewReader(`{"enabled":true}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var got cloudSyncJSON + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.SyncDefault { + t.Error("response must report sync_default=true") + } + if !got.Sessions["s1"] { + t.Error("existing per-session entries must be untouched") + } + + // The config block kept its other fields, and the value survives a + // reload from disk (read the file directly: LoadConfig validates + // providers, which a bare test config does not have). + data, err := os.ReadFile(config.ConfigPath()) + if err != nil { + t.Fatal(err) + } + var onDisk config.Config + if err := json.Unmarshal(data, &onDisk); err != nil { + t.Fatal(err) + } + cc := onDisk.CloudSettings() + if !cc.SyncDefault { + t.Error("sync_default must persist in config.json") + } + if !cc.Enabled || cc.URL != "https://cloud.example.com" || cc.AutoConnect == nil || *cc.AutoConnect { + t.Errorf("other cloud fields clobbered: %+v", cc) + } +} + +// Validation: enabled is required on both POSTs. +func TestCloudSyncValidation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{cfg: &config.Config{}} + + rec := httptest.NewRecorder() + s.handleCloudSyncDefault(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/sync/default", strings.NewReader(`{}`))) + if rec.Code != http.StatusBadRequest { + t.Errorf("default with missing enabled: status=%d, want 400", rec.Code) + } + + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/cloud/sync/s1", strings.NewReader(`{}`)) + req.SetPathValue("session_id", "s1") + s.handleCloudSyncSession(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("session with missing enabled: status=%d, want 400", rec.Code) + } +} + +// Session-creation stamping: cloud-originated sessions always opt in; local +// sessions follow cloud.sync_default; existing entries are never overwritten. +func TestStampCloudSync(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + local := &Server{cfg: &config.Config{}} + local.stampCloudSync("new-local", "", true) + local.stampCloudSync("resumed-local", "", false) + if store, _ := local.cloudSyncStoreLoad(); store.Has("new-local") || store.Has("resumed-local") { + t.Fatal("default OFF: local sessions must stay unstamped") + } + + // Cloud channels stamp even with the default OFF. + local.stampCloudSync("from-console", "console", true) + local.stampCloudSync("from-mobile-resume", "mobile", false) + store, _ := local.cloudSyncStoreLoad() + if !store.Enabled("from-console") || !store.Enabled("from-mobile-resume") { + t.Error("cloud-originated sessions must always be stamped enabled") + } + + // Global default ON: new local sessions opt in; resumed ones don't + // (no retroactive effect on pre-existing sessions). + withDefault := &Server{cfg: &config.Config{}} + withDefault.cfg.SetCloud(&config.CloudConfig{SyncDefault: true}) + withDefault.stampCloudSync("new-with-default", "", true) + withDefault.stampCloudSync("resumed-with-default", "", false) + store, _ = withDefault.cloudSyncStoreLoad() + if !store.Enabled("new-with-default") { + t.Error("sync_default=true must stamp new sessions enabled") + } + if store.Has("resumed-with-default") { + t.Error("resumed sessions must not be retroactively stamped") + } + + // An explicit user toggle wins over stamping. + if err := store.Set("user-disabled", false); err != nil { + t.Fatal(err) + } + withDefault.stampCloudSync("user-disabled", "console", true) + if store.Enabled("user-disabled") { + t.Error("stamping must never overwrite an explicit per-session entry") + } +} + +// Stamping lands in the same file the API reads (no split state). +func TestStampVisibleViaAPI(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{cfg: &config.Config{}} + s.stampCloudSync("s9", "console", true) + + rec := httptest.NewRecorder() + s.handleCloudSync(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/sync", nil)) + var got cloudSyncJSON + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.Sessions["s9"] { + t.Errorf("GET sessions = %v, want s9 stamped", got.Sessions) + } + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".jcode", "cloud-sessions.json")); err != nil { + t.Errorf("store file missing: %v", err) + } +} diff --git a/internal/web/cloud_test.go b/internal/web/cloud_test.go new file mode 100644 index 00000000..95725870 --- /dev/null +++ b/internal/web/cloud_test.go @@ -0,0 +1,244 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/cloud" + "github.com/cnjack/jcode/internal/config" +) + +type cloudStatusJSON struct { + LoggedIn bool `json:"logged_in"` + AutoConnect bool `json:"auto_connect"` + State string `json:"state"` + DeviceName string `json:"device_name"` + CloudURL string `json:"cloud_url"` + Error string `json:"error"` +} + +func postCloudConfig(t *testing.T, s *Server, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + s.handleCloudConfig(rec, httptest.NewRequest(http.MethodPost, "/api/cloud/config", strings.NewReader(body))) + return rec +} + +// Nil supervisor (headless/test wiring): status is synthesized from the +// on-disk credentials + config with state "offline". +func TestCloudStatusNilSupervisor(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s := &Server{cfg: &config.Config{}} + + rec := httptest.NewRecorder() + s.handleCloudStatus(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var st cloudStatusJSON + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + if st.LoggedIn || st.State != "offline" || !st.AutoConnect { + t.Fatalf("status = %+v, want logged_in=false auto_connect=true state=offline", st) + } + if st.CloudURL != cloud.DefaultCloudURL { + t.Errorf("cloud_url = %q, want %q", st.CloudURL, cloud.DefaultCloudURL) + } + if st.DeviceName == "" { + t.Error("device_name must fall back to the OS hostname") + } + if strings.Contains(rec.Body.String(), `"error"`) { + t.Errorf("empty error must be omitted: %s", rec.Body.String()) + } +} + +// A wired supervisor drives both the status payload and the toggle. +func TestCloudStatusFromSupervisor(t *testing.T) { + fake := &fakeCloudSupervisor{status: cloud.Status{ + LoggedIn: true, + AutoConnect: true, + State: "error", + DeviceName: "testbox", + CloudURL: "https://cloud.example.com", + Error: "dial tcp: connection refused", + }} + s := &Server{cfg: &config.Config{}, cloudSupervisor: fake} + + rec := httptest.NewRecorder() + s.handleCloudStatus(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/status", nil)) + var st cloudStatusJSON + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + if !st.LoggedIn || st.State != "error" || st.Error != "dial tcp: connection refused" || + st.DeviceName != "testbox" || st.CloudURL != "https://cloud.example.com" { + t.Fatalf("status = %+v, want the supervisor's snapshot", st) + } +} + +func TestCloudConfigRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := &config.Config{} + sup := cloud.NewSupervisor(cfg, 8080, "") + s := &Server{cfg: cfg, cloudSupervisor: sup} + + // Validation: missing field, unknown field. + for _, body := range []string{`{}`, `{"auto_connect":true,"bogus":1}`, `not json`} { + if rec := postCloudConfig(t, s, body); rec.Code != http.StatusBadRequest { + t.Fatalf("body %q: status=%d, want 400", body, rec.Code) + } + } + + // POST false persists and is reflected in the returned status. + rec := postCloudConfig(t, s, `{"auto_connect":false}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST status=%d body=%s", rec.Code, rec.Body.String()) + } + var st cloudStatusJSON + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + if st.AutoConnect || st.State != "offline" { + t.Fatalf("returned status = %+v, want auto_connect=false state=offline", st) + } + if config.CloudAutoConnect(cfg) { + t.Fatal("live config not updated") + } + if got := readPersistedCloudAutoConnect(t, home); got == nil || *got { + t.Fatalf("persisted auto_connect = %v, want false", got) + } + + // POST true again: round-trips back. Still offline (no credentials → the + // hot start stays idle). + rec = postCloudConfig(t, s, `{"auto_connect":true}`) + if rec.Code != http.StatusOK { + t.Fatalf("POST status=%d body=%s", rec.Code, rec.Body.String()) + } + if !config.CloudAutoConnect(cfg) { + t.Fatal("live config not re-enabled") + } + if got := readPersistedCloudAutoConnect(t, home); got == nil || !*got { + t.Fatalf("persisted auto_connect = %v, want true", got) + } +} + +// A SaveConfig failure returns 500 and leaves the in-memory config untouched +// (rollback happens inside SetAutoConnect). +func TestCloudConfigSaveFailureRollsBack(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".jcode"), []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + s := &Server{cfg: cfg, cloudSupervisor: cloud.NewSupervisor(cfg, 8080, "")} + + rec := postCloudConfig(t, s, `{"auto_connect":false}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s, want 500", rec.Code, rec.Body.String()) + } + if !config.CloudAutoConnect(cfg) { + t.Fatal("failed save changed the live auto_connect value") + } + if got := cfg.CloudSettings(); got != (config.CloudConfig{}) { + t.Fatalf("failed save replaced an absent Cloud block: %+v", got) + } +} + +// Nil-supervisor POST persists directly (no live connector to hot-apply). +func TestCloudConfigNilSupervisorPersists(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := &config.Config{} + s := &Server{cfg: cfg} + + rec := postCloudConfig(t, s, `{"auto_connect":false}`) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if config.CloudAutoConnect(cfg) { + t.Fatal("live config not updated") + } + if got := readPersistedCloudAutoConnect(t, home); got == nil || *got { + t.Fatalf("persisted auto_connect = %v, want false", got) + } +} + +// A supervisor error (e.g. SaveConfig failure) surfaces as 500. +func TestCloudConfigSupervisorError(t *testing.T) { + fake := &fakeCloudSupervisor{err: errors.New("save failed")} + s := &Server{cfg: &config.Config{}, cloudSupervisor: fake} + rec := postCloudConfig(t, s, `{"auto_connect":true}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s, want 500", rec.Code, rec.Body.String()) + } + if len(fake.setCalls) != 1 || !fake.setCalls[0] { + t.Fatalf("SetAutoConnect calls = %v, want [true]", fake.setCalls) + } +} + +type fakeCloudSupervisor struct { + status cloud.Status + err error + setCalls []bool + + syncCalls int + pairings []cloud.PendingPairing + lastPaired *cloud.PairedInfo + pairingErr error + approved []string + denied []string +} + +func (f *fakeCloudSupervisor) Status() cloud.Status { return f.status } +func (f *fakeCloudSupervisor) SetAutoConnect(enabled bool) error { + f.setCalls = append(f.setCalls, enabled) + return f.err +} +func (f *fakeCloudSupervisor) SyncCredentials() { f.syncCalls++ } +func (f *fakeCloudSupervisor) PendingPairings() []cloud.PendingPairing { + return f.pairings +} +func (f *fakeCloudSupervisor) ApprovePairing(_ context.Context, id string) error { + f.approved = append(f.approved, id) + return f.pairingErr +} +func (f *fakeCloudSupervisor) DenyPairing(_ context.Context, id string) error { + f.denied = append(f.denied, id) + return f.pairingErr +} +func (f *fakeCloudSupervisor) LastPaired() (cloud.PairedInfo, bool) { + if f.lastPaired == nil { + return cloud.PairedInfo{}, false + } + return *f.lastPaired, true +} + +func readPersistedCloudAutoConnect(t *testing.T, home string) *bool { + t.Helper() + data, err := os.ReadFile(filepath.Join(home, ".jcode", "config.json")) + if err != nil { + t.Fatal(err) + } + var disk struct { + Cloud *struct { + AutoConnect *bool `json:"auto_connect"` + } `json:"cloud"` + } + if err := json.Unmarshal(data, &disk); err != nil { + t.Fatal(err) + } + if disk.Cloud == nil { + return nil + } + return disk.Cloud.AutoConnect +} diff --git a/internal/web/server.go b/internal/web/server.go index 06a7d80e..13ed11d1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -17,6 +17,7 @@ import ( "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/flow" @@ -162,6 +163,22 @@ type Server struct { // bleController toggles the BLE status channel live (from the settings // endpoint) without an app restart. nil when BLE is not compiled in. bleController BLEController + + // cloudSupervisor backs the cloud relay status/config endpoints. nil + // (headless builds, tests) synthesizes an offline status from the on-disk + // credentials. + cloudSupervisor CloudSupervisor + + // cloudLogin is the lazy-initialized device-code login flow behind + // /api/cloud/login* (lazy because tests construct Server literals). + cloudLoginMu sync.Mutex + cloudLogin *cloudLoginFlow + + // cloudSyncStore is the lazy per-session sync gate store behind + // /api/cloud/sync* (M19; lazy for the same reason as cloudLogin). + cloudSyncMu sync.Mutex + cloudSyncStore *cloud.SyncStore + cloudSyncErr error // sticky load failure } // BLEController lets the settings endpoint start/stop the BLE status channel at @@ -172,6 +189,23 @@ type BLEController interface { Disable() } +// CloudSupervisor exposes the cloud relay status, the live auto_connect +// toggle, the credential-change hook (web login/logout) and the pairing inbox +// to the cloud API endpoints. Implemented by *cloud.Supervisor; kept as an +// interface so this package does not depend on the connector lifecycle. +type CloudSupervisor interface { + Status() cloud.Status + SetAutoConnect(bool) error + // SyncCredentials reconciles the connector with the on-disk credentials + // after a web login/logout. + SyncCredentials() + // Pairing inbox (pending requests arrive via the connector's poll loop). + PendingPairings() []cloud.PendingPairing + ApprovePairing(ctx context.Context, id string) error + DenyPairing(ctx context.Context, id string) error + LastPaired() (cloud.PairedInfo, bool) +} + // ServerConfig holds the configuration for creating a new Server. type ServerConfig struct { Port int @@ -213,6 +247,7 @@ type ServerConfig struct { ComputerManager *computer.Manager // optional: process-wide computer-use manager shared with per-task Envs 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 } // NewServer creates a new web server. @@ -285,6 +320,7 @@ func NewServer(cfg *ServerConfig) *Server { memoryRuns: make(map[string]bool), memoryWarnings: make(map[string]string), bleController: cfg.BLEController, + cloudSupervisor: cfg.CloudSupervisor, } // The bootstrap engine is registered (and its pump started) in Start, once // the root context exists. @@ -388,6 +424,18 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/computer/shots/{id}", s.handleComputerShot) mux.HandleFunc("GET /api/tool-search/status", s.handleToolSearchStatus) mux.HandleFunc("POST /api/tool-search/config", s.handleToolSearchConfig) + mux.HandleFunc("GET /api/cloud/status", s.handleCloudStatus) + mux.HandleFunc("POST /api/cloud/config", s.handleCloudConfig) + mux.HandleFunc("POST /api/cloud/login", s.handleCloudLogin) + mux.HandleFunc("GET /api/cloud/login/status", s.handleCloudLoginStatus) + mux.HandleFunc("POST /api/cloud/logout", s.handleCloudLogout) + mux.HandleFunc("GET /api/cloud/pairings", s.handleCloudPairings) + mux.HandleFunc("POST /api/cloud/pairings/{id}/approve", s.handleCloudPairingApprove) + mux.HandleFunc("POST /api/cloud/pairings/{id}/deny", s.handleCloudPairingDeny) + mux.HandleFunc("POST /api/cloud/pairing-offer", s.handleCloudPairingOffer) + mux.HandleFunc("GET /api/cloud/sync", s.handleCloudSync) + mux.HandleFunc("POST /api/cloud/sync/default", s.handleCloudSyncDefault) + mux.HandleFunc("POST /api/cloud/sync/{session_id}", s.handleCloudSyncSession) mux.HandleFunc("GET /api/dev-options/status", s.handleDevOptionsStatus) mux.HandleFunc("POST /api/dev-options/config", s.handleDevOptionsConfig) mux.HandleFunc("GET /api/memory/status", s.handleMemoryStatus) diff --git a/internal/web/sessions.go b/internal/web/sessions.go index caa93535..d0846b4d 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -358,6 +358,10 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { var req struct { SessionID string `json:"session_id,omitempty"` Pwd string `json:"pwd,omitempty"` + // Source is the optional channel label ("console"/"mobile") the cloud + // relay passes through when the session is created from the cloud — + // such sessions are always stamped as cloud-synced (M19). + Source string `json:"source,omitempty"` } // The body is optional (empty = brand-new task → EOF), but a non-empty // malformed body should be rejected rather than creating a zero-value task. @@ -433,9 +437,12 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { s.setActiveEngine(eng) - // Brand-new task: tell its view to start clean. + // Brand-new task: tell its view to start clean, and stamp its initial + // cloud-sync state (M19): cloud-originated sessions always sync, local + // ones follow cloud.sync_default. Resume never stamps. if req.SessionID == "" { s.wsBroker.Broadcast(WSEvent{TaskID: eng.taskID, Type: "session_reset", Data: map[string]string{}}) + s.stampCloudSync(eng.taskID, req.Source, true) writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) return } diff --git a/packages/jcode-ui/README.md b/packages/jcode-ui/README.md index 0ecc5562..e6ae471a 100644 --- a/packages/jcode-ui/README.md +++ b/packages/jcode-ui/README.md @@ -86,6 +86,7 @@ Plus `ThreadWelcome` + `Suggestions` (empty state & follow-ups), `ConnectionBann | `jcode-ui/canvas` (+`canvas.css`) | Agent workflow canvas on `@xyflow/react` — status-aware nodes, animated edges, `toolTreeToGraph` | | `jcode-ui/voice` (+`voice.css`) | SpeechInput, Transcription, AudioPlayer, VoiceVisualizer — browser APIs only | | `jcode-ui/plugins/mermaid` / `plugins/katex` | Diagram/math rendering via dynamic-import peers (zero cost unused) | +| `jcode-ui/product` | The jcode **product composer** (`ChatInput`, `WorkspacePicker`, `BranchPicker`, `GoalBanner`) — the exact desktop input experience, driven by a `ProductComposerHost` prop (state + actions + i18n strings + provider icons). Uses the product theme tokens (`--color-*`), not the scoped `--jcode-*` library tokens | | `createAGUIRuntime` (core) | Drive everything from any AG-UI backend — LangGraph, CrewAI, Mastra, … | ## Custom tool renderers diff --git a/packages/jcode-ui/package.json b/packages/jcode-ui/package.json index 86c22a02..203db545 100644 --- a/packages/jcode-ui/package.json +++ b/packages/jcode-ui/package.json @@ -42,6 +42,10 @@ "types": "./dist/canvas/index.d.ts", "import": "./dist/canvas/index.js" }, + "./product": { + "types": "./dist/product/index.d.ts", + "import": "./dist/product/index.js" + }, "./canvas.css": "./dist/canvas/canvas.css", "./voice": { "types": "./dist/voice/index.d.ts", @@ -76,6 +80,7 @@ "dev:css": "tailwindcss -i src/styles/entry.css -o dist/styles.css --watch", "dev": "pnpm dev:css & tsc -p tsconfig.build.json --watch --preserveWatchOutput", "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", "generate:api-docs": "node ../../script/generate_jcode_ui_api_docs.mjs", "clean": "rm -rf dist *.tsbuildinfo" }, @@ -108,12 +113,15 @@ }, "devDependencies": { "@tailwindcss/cli": "^4.1.0", + "@testing-library/react": "^16.3.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@xyflow/react": "^12.11.2", + "jsdom": "^26.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwindcss": "^4.1.0", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^3.2.4" } } diff --git a/web/src/components/BranchPicker.tsx b/packages/jcode-ui/src/product/BranchPicker.tsx similarity index 86% rename from web/src/components/BranchPicker.tsx rename to packages/jcode-ui/src/product/BranchPicker.tsx index 1bdd4cec..c517c3b9 100644 --- a/web/src/components/BranchPicker.tsx +++ b/packages/jcode-ui/src/product/BranchPicker.tsx @@ -1,3 +1,10 @@ +/** + * BranchPicker — git branch switcher pill for the composer header. + * + * Ported from the jcode web product UI; the backend calls go through + * `ProductComposerHost.fetchBranches` / `checkoutBranch`. + */ + import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CheckIcon, @@ -6,9 +13,8 @@ import { MagnifyingGlassIcon, PlusIcon, } from '@heroicons/react/24/outline' -import { useTranslation } from 'react-i18next' -import { api } from '../lib/api' -import { useAppSelector } from '../app/hooks' +import type { ProductComposerHost } from './host.js' +import { useComposerStrings } from './useComposerStrings.js' interface PendingSwitch { branch: string @@ -18,9 +24,9 @@ interface PendingSwitch { const MAX_FILES = 8 -export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'bottom' }) { - const { t } = useTranslation() - const projectPath = useAppSelector((s) => s.session.projectPath) +export function BranchPicker({ host, placement = 'top' }: { host: ProductComposerHost; placement?: 'top' | 'bottom' }) { + const strings = useComposerStrings(host) + const projectPath = host.projectPath const [current, setCurrent] = useState('') const [branches, setBranches] = useState([]) const [open, setOpen] = useState(false) @@ -35,14 +41,14 @@ export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'botto const refresh = useCallback(async () => { try { - const res = await api.gitBranches() + const res = await host.fetchBranches() setCurrent(res.current || '') setBranches(res.branches || []) } catch { setCurrent('') setBranches([]) } - }, []) + }, [host]) useEffect(() => { void refresh() @@ -105,7 +111,7 @@ export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'botto setSwitching(true) setError('') try { - const res = await api.gitCheckout(branch.trim(), create, strategy) + const res = await host.checkoutBranch(branch.trim(), create, strategy) if (res.blocked) { setPending({ branch: branch.trim(), create, files: res.files || [] }) return @@ -115,7 +121,7 @@ export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'botto await refresh() reset() } catch (e) { - setError(e instanceof Error ? e.message : t('errors.branchSwitch')) + setError(e instanceof Error ? e.message : strings.branchSwitchError) } finally { setSwitching(false) } @@ -132,7 +138,7 @@ export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'botto -
{t('branches.confirmHint')}
+
{strings.branchConfirmHint}
) : ( <> @@ -187,16 +193,16 @@ export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'botto setQuery(e.target.value)} - placeholder={t('branches.search')} + placeholder={strings.branchSearch} className="min-w-0 flex-1 border-none bg-transparent text-[12.5px] text-[var(--color-foreground)] outline-none" />
- {t('branches.title')} + {strings.branchesTitle}
{filtered.length === 0 ? ( -
{t('branches.none')}
+
{strings.branchesNone}
) : ( filtered.map((branch) => ( ) : (
@@ -232,11 +238,11 @@ export function BranchPicker({ placement = 'top' }: { placement?: 'top' | 'botto if (e.key === 'Enter') void checkout(newName, true) if (e.key === 'Escape') setCreating(false) }} - placeholder={t('branches.newName')} + placeholder={strings.branchNewName} className="min-w-0 flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] px-2 py-1.5 text-xs text-[var(--color-foreground)] outline-none" />
)} diff --git a/packages/jcode-ui/src/product/ChatInput.test.tsx b/packages/jcode-ui/src/product/ChatInput.test.tsx new file mode 100644 index 00000000..4522dded --- /dev/null +++ b/packages/jcode-ui/src/product/ChatInput.test.tsx @@ -0,0 +1,248 @@ +/** + * Product composer (ChatInput) interaction tests. + * + * Covers the key interactions called out for the M14 extraction: + * - slash-menu trigger + keyboard apply + * - goal armed toggle (via the "+" menu and the armed chip) + * - model picker filtering (search + enabled gating) + * - image attachment limits (10MB cap, image/* only) + */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RuntimeProvider, createMockRuntime } from 'jcode-ui-core/runtime' +import type { ChatRuntime } from 'jcode-ui-core/runtime' +import { ChatInput } from './ChatInput.js' +import type { ProductComposerHost } from './host.js' +import type { ProviderInfo } from './types.js' + +const PROVIDERS: ProviderInfo[] = [ + { + id: 'openai', + name: 'OpenAI', + models: [ + { id: 'gpt-4o', name: 'GPT-4o', tool_call: true, image_support: true, context_limit: 128000 }, + { id: 'gpt-4o-mini', name: 'GPT-4o mini', tool_call: true, context_limit: 128000 }, + ], + }, + { + id: 'anthropic', + name: 'Anthropic', + models: [ + { id: 'claude-sonnet', name: 'Claude Sonnet', tool_call: true, reasoning: true }, + { id: 'claude-hidden', name: 'Claude Hidden', tool_call: true, enabled: false }, + ], + }, +] + +function makeHost(overrides: Partial = {}): ProductComposerHost { + return { + providerName: 'openai', + modelName: 'gpt-4o', + mode: 'approval', + providers: PROVIDERS, + favoriteModels: [], + recentModels: [], + imageSupport: true, + effortOverrides: {}, + slashCommands: [ + { slash: '/clear', description: 'Clear the conversation', type: 'builtin' }, + { slash: '/compact', description: 'Compact context', type: 'builtin' }, + ], + hasMessages: false, + goalArmed: false, + sessionId: 's1', + projectPath: '/tmp/project', + tasks: [], + selectModel: vi.fn(), + selectMode: vi.fn(), + setEffort: vi.fn(), + toggleFavorite: vi.fn(), + setModelEnabled: vi.fn(), + refreshModels: vi.fn(), + setGoalArmed: vi.fn(), + fetchTaskStats: vi.fn(async () => null), + validateWorkspacePaths: vi.fn(async () => []), + browseFolders: vi.fn(async () => ({ current: '/', folders: [] })), + switchWorkspace: vi.fn(async () => {}), + fetchBranches: vi.fn(async () => ({ current: '', branches: [] })), + checkoutBranch: vi.fn(async () => ({ branch: '' })), + setGoal: vi.fn(async () => ({ objective: '', status: 'active' as const })), + clearGoal: vi.fn(async () => {}), + ...overrides, + } +} + +function renderComposer(host: ProductComposerHost, runtime?: ChatRuntime) { + const rt = runtime ?? createMockRuntime() + return { + runtime: rt, + ...render( + + + , + ), + } +} + +function textarea(container: HTMLElement): HTMLTextAreaElement { + const el = container.querySelector('textarea') + if (!el) throw new Error('composer textarea not found') + return el +} + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + cleanup() +}) + +describe('slash menu', () => { + it('opens on "/" and applies a command with Enter', async () => { + const host = makeHost() + const { container } = renderComposer(host) + const ta = textarea(container) + + // Type "/" — the slash menu opens with the local /goal pseudo-command first. + fireEvent.change(ta, { target: { value: '/' } }) + await waitFor(() => expect(screen.getByText('/goal')).toBeTruthy()) + expect(screen.getByText('/clear')).toBeTruthy() + + // Filter to /clear: ArrowDown past /goal and /clear... navigate then apply. + fireEvent.change(ta, { target: { value: '/cle' } }) + await waitFor(() => expect(screen.queryByText('/compact')).toBeNull()) + fireEvent.keyDown(ta, { key: 'ArrowDown' }) // /goal → /clear + fireEvent.keyDown(ta, { key: 'Enter' }) + expect(ta.value).toBe('/clear ') + }) + + it('does not open for path-like text', () => { + const host = makeHost() + const { container } = renderComposer(host) + const ta = textarea(container) + fireEvent.change(ta, { target: { value: '/usr/bin' } }) + expect(screen.queryByText('/clear')).toBeNull() + }) +}) + +describe('goal armed', () => { + it('arms via the "+" menu and disarms via the chip', async () => { + const setGoalArmed = vi.fn() + const host = makeHost({ setGoalArmed }) + const view = renderComposer(host) + + // Open the "+" menu and pick "Goal". + fireEvent.click(screen.getByTitle('Add')) + fireEvent.click(screen.getByText('Goal')) + expect(setGoalArmed).toHaveBeenCalledWith(true) + + // Armed state: placeholder switches and the chip renders; its X disarms. + const armedHost = makeHost({ setGoalArmed, goalArmed: true }) + view.rerender( + + + , + ) + const ta = textarea(view.container) + expect(ta.placeholder).toContain('goal') + fireEvent.click(screen.getByTitle('Remove goal')) + expect(setGoalArmed).toHaveBeenCalledWith(false) + }) + + it('"/goal" slash entry arms goal mode instead of inserting text', async () => { + const setGoalArmed = vi.fn() + const host = makeHost({ setGoalArmed }) + const { container } = renderComposer(host) + const ta = textarea(container) + + fireEvent.change(ta, { target: { value: '/goal' } }) + await waitFor(() => expect(screen.getByText('/goal', { selector: 'span' })).toBeTruthy()) + fireEvent.keyDown(ta, { key: 'Enter' }) // first entry is the local /goal + expect(setGoalArmed).toHaveBeenCalledWith(true) + expect(ta.value).toBe('') // token stripped, nothing inserted + }) +}) + +describe('model picker', () => { + it('filters by search text and hides disabled models', async () => { + const host = makeHost() + renderComposer(host) + + // Open the model picker (button shows the current display name). + fireEvent.click(screen.getByText('GPT-4o')) + await waitFor(() => expect(screen.getByPlaceholderText('Filter models…')).toBeTruthy()) + + // Enabled gating: claude-hidden never shows in the picker. + expect(screen.queryByText('Claude Hidden')).toBeNull() + expect(screen.getByText('Claude Sonnet')).toBeTruthy() + expect(screen.getByText('GPT-4o mini')).toBeTruthy() + + // Search filter narrows the list. + fireEvent.change(screen.getByPlaceholderText('Filter models…'), { target: { value: 'mini' } }) + await waitFor(() => expect(screen.getByText('GPT-4o mini')).toBeTruthy()) + expect(screen.queryByText('Claude Sonnet')).toBeNull() + }) + + it('selecting a model calls host.selectModel', async () => { + const selectModel = vi.fn() + const host = makeHost({ selectModel }) + renderComposer(host) + fireEvent.click(screen.getByText('GPT-4o')) + await waitFor(() => expect(screen.getByText('Claude Sonnet')).toBeTruthy()) + fireEvent.click(screen.getByText('Claude Sonnet')) + expect(selectModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet') + }) +}) + +describe('image attachments', () => { + function fileInput(container: HTMLElement): HTMLInputElement { + const el = container.querySelector('input[type="file"]') + if (!el) throw new Error('file input not found') + return el as HTMLInputElement + } + + it('accepts images under 10MB and skips larger/non-image files', async () => { + const host = makeHost() + const { container } = renderComposer(host) + const input = fileInput(container) + + const small = new File([new Uint8Array(1024)], 'small.png', { type: 'image/png' }) + const big = new File([new Uint8Array(11 * 1024 * 1024)], 'big.png', { type: 'image/png' }) + const doc = new File([new Uint8Array(128)], 'notes.txt', { type: 'text/plain' }) + fireEvent.change(input, { target: { files: [small, big, doc] } }) + + // Only the small image makes it into the pending list. + await waitFor(() => { + const list = container.querySelector('.jcode-attachment-list') + expect(list?.getAttribute('data-count')).toBe('1') + }) + }) + + it('sends images through the runtime sendMessage action', async () => { + const host = makeHost() + const runtime = createMockRuntime() + const { container } = render( + + + , + ) + fireEvent.change(fileInput(container), { + target: { files: [new File([new Uint8Array(8)], 'a.png', { type: 'image/png' })] }, + }) + await waitFor(() => expect(container.querySelector('.jcode-attachment-list')).toBeTruthy()) + + fireEvent.click(screen.getByLabelText('Send')) + await waitFor(() => { + const send = (runtime as ReturnType).calls.find((c) => c.action === 'sendMessage') + expect(send).toBeTruthy() + // Image-only body falls back to the attachedImages label, with the image payload. + expect(send!.args[0]).toBe('(see attached images)') + const images = send!.args[1] as { data: string; media_type: string; name?: string }[] + expect(images).toHaveLength(1) + expect(images[0].media_type).toBe('image/png') + expect(images[0].name).toBe('a.png') + }) + }) +}) diff --git a/web/src/components/ChatInput.tsx b/packages/jcode-ui/src/product/ChatInput.tsx similarity index 86% rename from web/src/components/ChatInput.tsx rename to packages/jcode-ui/src/product/ChatInput.tsx index 3c2dc048..726296e4 100644 --- a/web/src/components/ChatInput.tsx +++ b/packages/jcode-ui/src/product/ChatInput.tsx @@ -1,10 +1,10 @@ /** - * ChatInput — the product composer (ported from web/src/components/ChatInput.vue). + * ChatInput — the jcode product composer. * - * Layers jcode product UI on top of the runtime actions: + * Layers the jcode product UI on top of the ChatRuntime actions: * - autosizing textarea, send / queue / stop, IME-safe Enter * - slash-command menu (keyboard-navigable) - * - MODE picker (approval / plan / full_access) + * - MODE picker (approval / plan / auto / full_access) * - MODEL picker (current / favorites / all-providers with capability dots, * context-limit subline, and a Manage Models dialog) * - EFFORT picker (per-model reasoning effort) @@ -14,9 +14,10 @@ * - keyboard shortcuts (⌘L focus, Esc to close dialogs) * - click-outside handling * - * Send/stop/queue come from the jcode-ui runtime actions; provider/model/mode - * state + the model catalog come from the Redux model slice; the slash command - * list comes from the chat slice (fetched at boot in App.tsx). + * Send/stop/queue + token snapshot come from the jcode-ui ChatRuntime + * (RuntimeProvider); provider/model/mode state, slash commands, workspace and + * goal actions arrive through the `ProductComposerHost` prop, so the component + * carries no Redux / fetch / Tauri / i18next imports of its own. */ import { @@ -29,7 +30,6 @@ import { } from 'react' import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react' import { createPortal } from 'react-dom' -import { useTranslation } from 'react-i18next' import { HandRaisedIcon, ShieldExclamationIcon, @@ -50,27 +50,29 @@ import { SparklesIcon, } from '@heroicons/react/24/outline' import { StarIcon as StarIconSolid, CheckCircleIcon } from '@heroicons/react/24/solid' -import { AttachmentList, useRuntimeActions, useRuntimeState } from 'jcode-ui' +import { useRuntimeActions, useRuntimeState } from 'jcode-ui-core/runtime' import type { ChatImage as RuntimeChatImage } from 'jcode-ui-core' -import { useAppDispatch, useAppSelector } from '../app/hooks' -import { chatActions, modelActions } from '../app/store' -import { api } from '../lib/api' -import { readDraft, writeDraft } from '../lib/drafts' -import { BranchPicker } from './BranchPicker' -import { ProviderIcon } from './ProviderIcon' -import { WorkspacePicker } from './WorkspacePicker' +import { AttachmentList } from '../components/Attachment.js' +import type { ProductComposerHost } from './host.js' +import type { ProductComposerStrings } from './strings.js' +import { useComposerStrings } from './useComposerStrings.js' +import { readDraft, writeDraft } from './drafts.js' +import { BranchPicker } from './BranchPicker.js' +import { ProviderIcon } from './ProviderIcon.js' +import { WorkspacePicker } from './WorkspacePicker.js' import type { AgentMode, - ChatImage, ModelInfo, ProviderInfo, SlashCommandInfo, TaskStats, -} from '../lib/types' +} from './types.js' // ─── Props ────────────────────────────────────────────────────────────────── -export interface ChatInputProps { +export interface ProductChatInputProps { + /** Host state + actions projection (see host.ts). */ + host: ProductComposerHost /** Fired when the user dispatches a message (sent now or queued mid-turn). */ onSent?: () => void /** Direction for workspace/branch panels. Welcome opens downward. */ @@ -78,7 +80,6 @@ export interface ChatInputProps { /** * Elevate the whole composer into a bordered, shadowed card (welcome / * new-task screen). Docked conversation composers stay recessed. - * Mirrors Vue's `.composer-elevated` on `.chat-input-card`. */ elevated?: boolean } @@ -88,25 +89,25 @@ export interface ChatInputProps { type ModeValue = AgentMode interface ModeDef { value: ModeValue - /** i18n keys under chat.modes (resolved with t() at render). */ - labelKey: string - subKey: string + /** Keys into ProductComposerStrings (resolved at render). */ + labelKey: 'modeApproval' | 'modePlan' | 'modeAuto' | 'modeFullAccess' + subKey: 'modeApprovalSub' | 'modePlanSub' | 'modeAutoSub' | 'modeFullAccessSub' risk: 'neutral' | 'plan' | 'info' | 'danger' Icon: typeof HandRaisedIcon } const MODE_DEFS: ModeDef[] = [ - { value: 'approval', labelKey: 'chat.modes.approval', subKey: 'chat.modes.approvalSub', risk: 'neutral', Icon: HandRaisedIcon }, - { value: 'plan', labelKey: 'chat.modes.plan', subKey: 'chat.modes.planSub', risk: 'plan', Icon: ClipboardDocumentListIcon }, - { value: 'auto', labelKey: 'chat.modes.auto', subKey: 'chat.modes.autoSub', risk: 'info', Icon: SparklesIcon }, - { value: 'full_access', labelKey: 'chat.modes.fullAccess', subKey: 'chat.modes.fullAccessSub', risk: 'danger', Icon: ShieldExclamationIcon }, + { value: 'approval', labelKey: 'modeApproval', subKey: 'modeApprovalSub', risk: 'neutral', Icon: HandRaisedIcon }, + { value: 'plan', labelKey: 'modePlan', subKey: 'modePlanSub', risk: 'plan', Icon: ClipboardDocumentListIcon }, + { value: 'auto', labelKey: 'modeAuto', subKey: 'modeAutoSub', risk: 'info', Icon: SparklesIcon }, + { value: 'full_access', labelKey: 'modeFullAccess', subKey: 'modeFullAccessSub', risk: 'danger', Icon: ShieldExclamationIcon }, ] const STANDARD_EFFORT_OPTIONS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] -function modeLabel(t: (key: string) => string, m: string): string { +function modeLabel(strings: ProductComposerStrings, m: string): string { const def = MODE_DEFS.find((d) => d.value === m) - return t(def ? def.labelKey : 'chat.modes.approval') + return strings[def ? def.labelKey : 'modeApproval'] } // ─── Format helpers ───────────────────────────────────────────────────────── @@ -154,33 +155,35 @@ function slashTokenAt(text: string, cursor: number): { start: number; filter: st // ─── Component ────────────────────────────────────────────────────────────── -export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: ChatInputProps) { - const { t } = useTranslation() - const dispatch = useAppDispatch() +export function ChatInput({ host, onSent, pickerPlacement = 'top', elevated = false }: ProductChatInputProps) { + const strings = useComposerStrings(host) // Runtime (timeline/queue + send/stop actions). const actions = useRuntimeActions() const { isRunning, queued, tokenSnapshot } = useRuntimeState() - // Model slice state. - const providerName = useAppSelector((s) => s.model.providerName) - const modelName = useAppSelector((s) => s.model.modelName) - const mode = useAppSelector((s) => s.model.mode) - const providers = useAppSelector((s) => s.model.providers) - const favoriteModels = useAppSelector((s) => s.model.favoriteModels) - const recentModels = useAppSelector((s) => s.model.recentModels) - const imageSupport = useAppSelector((s) => s.model.imageSupport) - const effortOverrides = useAppSelector((s) => s.model.effortOverrides) - const slashCommands = useAppSelector((s) => s.chat.slashCommands) - const hasMessages = useAppSelector((s) => s.chat.timeline.length > 0) - const goalArmed = useAppSelector((s) => s.chat.goalArmed) - const currentSessionId = useAppSelector((s) => s.session.currentSessionId) + // Host (model/chat/workspace) state. + const { + providerName, + modelName, + mode, + allowedModes, + providers, + favoriteModels, + recentModels, + imageSupport, + effortOverrides, + slashCommands, + hasMessages, + goalArmed, + sessionId: currentSessionId, + } = host // Composer-local state. The input text initializes from the saved draft for // this conversation (covers remounts on welcome ↔ conversation crossing). const [input, setInput] = useState(() => readDraft(currentSessionId)) - /** Pending vision images — same shape as jcode-ui `ChatImage` / AttachmentList. */ - const [pendingImages, setPendingImages] = useState([]) + /** Pending vision images — same shape as the ChatRuntime `ChatImage` / AttachmentList. */ + const [pendingImages, setPendingImages] = useState([]) const [showSlashMenu, setShowSlashMenu] = useState(false) const [slashFilter, setSlashFilter] = useState('') const [selectedSlashIdx, setSelectedSlashIdx] = useState(0) @@ -286,8 +289,8 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: // Local pseudo-command: "/goal" arms Goal mode (same as the "+" menu entry) // instead of inserting text. Compared by object identity in applySlashCommand. const goalSlashCmd = useMemo( - () => ({ slash: '/goal', description: t('chat.goalSlashDesc'), type: 'builtin' }), - [t], + () => ({ slash: '/goal', description: strings.goalSlashDesc, type: 'builtin' }), + [strings], ) const filteredSlashCommands = useMemo(() => { @@ -313,14 +316,9 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: setShowContextPopup((v) => !v) if (!showContextPopup && currentSessionId) { setTaskStatsLoading(true) - try { - const stats = await api.taskStats(currentSessionId) - setTaskStats(stats) - } catch { - setTaskStats(null) - } finally { - setTaskStatsLoading(false) - } + const stats = await host.fetchTaskStats(currentSessionId) + setTaskStats(stats) + setTaskStatsLoading(false) } } @@ -369,7 +367,7 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: pendingImages.length > 0 ? pendingImages.map((i) => ({ data: i.data, media_type: i.media_type, name: i.name })) : undefined - const body = text || t('chat.attachedImages') + const body = text || strings.attachedImages if (isRunning) { actions.enqueueMessage(body, images) } else { @@ -380,7 +378,7 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: setPendingImages([]) setShowSlashMenu(false) onSent?.() - }, [actions, input, isRunning, onSent, pendingImages, currentSessionId, t]) + }, [actions, input, isRunning, onSent, pendingImages, currentSessionId, strings]) // ─── Model / mode selection ─────────────────────────────────────────────── @@ -388,57 +386,19 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: setShowModelPicker(false) setShowEffortPicker(false) setModelFilter('') - try { - await api.switchModel(provider, model) - dispatch(modelActions.setProvider(provider)) - dispatch(modelActions.setModel(model)) - } catch { - /* ignore — the next status poll reconciles */ - } + // The host persists + updates its store; failures are reconciled by the + // next status poll. + await host.selectModel(provider, model) } async function selectMode(next: ModeValue) { setShowModePicker(false) - try { - await api.switchMode(next) - } catch { - /* ignore */ - } - dispatch(modelActions.setMode(next)) + await host.selectMode(next) } async function pickEffort(effort: string) { setShowEffortPicker(false) - dispatch(modelActions.setEffortOverride({ provider: providerName, model: modelName, effort })) - try { - await api.setModelEffort(providerName, modelName, effort) - } catch { - dispatch(modelActions.setEffortOverride({ provider: providerName, model: modelName, effort: currentEffort })) - } - } - - async function toggleFavorite(provider: string, model: string) { - try { - const result = await api.toggleFavorite(provider, model) - dispatch(modelActions.setFavorite({ provider, model, favorite: result.favorite })) - } catch { - /* ignore */ - } - } - - async function toggleModelEnabled(provider: string, model: string, enabled: boolean) { - try { - await api.toggleModelEnabled(provider, model, enabled) - // Reflect locally so the Manage dialog toggles immediately. - const nextProviders = providers.map((p) => - p.id === provider - ? { ...p, models: p.models.map((m) => (m.id === model ? { ...m, enabled } : m)) } - : p, - ) - dispatch(modelActions.setProviders(nextProviders)) - } catch { - /* ignore */ - } + await host.setEffort(providerName, modelName, effort) } // ─── Slash commands ─────────────────────────────────────────────────────── @@ -462,7 +422,7 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: if (cmd === goalSlashCmd) { // "/goal" is a mode toggle, not message text: arm Goal mode and strip the // typed token so the surrounding message stays intact. - dispatch(chatActions.setGoalArmed(true)) + host.setGoalArmed(true) if (tok) { const next = input.slice(0, tok.start) + input.slice(cursor) setInput(next) @@ -715,14 +675,17 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: const canSend = input.trim().length > 0 || pendingImages.length > 0 const showSend = !isRunning || input.trim().length > 0 || pendingImages.length > 0 const currentModeDef = MODE_DEFS.find((m) => m.value === mode) ?? MODE_DEFS[0] + // Host-restricted mode list (M20 cloud ceiling): absent ⇒ all four modes. + const modeDefs = allowedModes ? MODE_DEFS.filter((d) => allowedModes.includes(d.value)) : MODE_DEFS + const modeRestricted = modeDefs.length < MODE_DEFS.length // ─── Render ─────────────────────────────────────────────────────────────── // Welcome/new-task elevates the whole card; docked conversation stays flat. const isElevated = elevated || !hasMessages - // Horizontal inset comes from the parent (`.chat-col` or welcome `px-5`) so - // the composer width matches the message column exactly. + // Horizontal inset comes from the parent so the composer width matches the + // message column exactly. return (
{/* Type-ahead queue */} @@ -747,8 +710,8 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: )}
)} @@ -822,10 +785,10 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: onPaste={handlePaste} placeholder={ isRunning - ? t('chat.queuePlaceholder') + ? strings.queuePlaceholder : goalArmed - ? t('chat.goalPlaceholder') - : t('chat.placeholder') + ? strings.goalPlaceholder + : strings.placeholder } className="block w-full resize-none border-none bg-transparent text-sm leading-relaxed text-[var(--color-foreground)] outline-none" style={{ minHeight: 28, maxHeight: 200, fontFamily: 'var(--font-sans)' }} @@ -858,7 +821,7 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }:
)} @@ -913,6 +876,7 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: ) })} + {modeRestricted && ( +
+ {strings.modeCeilingHint} +
+ )}
)} @@ -994,20 +963,20 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }: <>