diff --git a/internal/agent/reminder_test.go b/internal/agent/reminder_test.go index 1780a7cf..35282d3f 100644 --- a/internal/agent/reminder_test.go +++ b/internal/agent/reminder_test.go @@ -288,10 +288,11 @@ func (fakeRemoteExecutor) Stat(context.Context, string) (*tools.FileInfo, error) func (fakeRemoteExecutor) Exec(context.Context, string, string, time.Duration) (string, string, error) { return "", "", nil } -func (fakeRemoteExecutor) Platform() string { return "linux/amd64" } -func (fakeRemoteExecutor) Label() string { return "fake-remote" } -func (fakeRemoteExecutor) Close() error { return nil } -func (fakeRemoteExecutor) ProjectLabel(string) string { return "fake" } +func (fakeRemoteExecutor) Platform() string { return "linux/amd64" } +func (fakeRemoteExecutor) Label() string { return "fake-remote" } +func (fakeRemoteExecutor) Probe(context.Context) error { return nil } +func (fakeRemoteExecutor) Close() error { return nil } +func (fakeRemoteExecutor) ProjectLabel(string) string { return "fake" } // switch_env mutates the shared *tools.Env in place without rebuilding the // agent (ACP/web). While the live env is remote, the local-filesystem sweeps diff --git a/internal/cloud/compose_test.go b/internal/cloud/compose_test.go index f53bee28..b9bc1e38 100644 --- a/internal/cloud/compose_test.go +++ b/internal/cloud/compose_test.go @@ -176,8 +176,12 @@ func newFakeComposeLocal(t *testing.T) (*fakeComposeLocal, *httptest.Server) { return } switch path { - case "/api/sessions": - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "session_id": f.sessionID}) + case "/api/sessions/activate": + sessionID, _ := body["session_id"].(string) + if sessionID == "" { + sessionID = f.sessionID + } + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ready", "session_id": sessionID}) case "/api/chat": w.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(w).Encode(map[string]string{"status": "processing", "session_id": f.sessionID}) @@ -187,12 +191,12 @@ func newFakeComposeLocal(t *testing.T) (*fakeComposeLocal, *httptest.Server) { } } mux := http.NewServeMux() - for _, p := range []string{"/api/sessions", "/api/chat", "/api/model", "/api/mode", "/api/model-state/effort", "/api/goal"} { + for _, p := range []string{"/api/sessions/activate", "/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) { + mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() - f.calls = append(f.calls, "GET /api/health") + f.calls = append(f.calls, "GET /api/status") f.mu.Unlock() _ = json.NewEncoder(w).Encode(map[string]string{"provider": f.healthProvider, "model": f.healthModel}) }) @@ -243,14 +247,14 @@ func TestChatSendComposeOrder(t *testing.T) { } calls, bodies := local.snapshot() - wantOrder := []string{"/api/sessions", "/api/model", "/api/model-state/effort", "/api/mode", "/api/goal", "/api/chat"} + wantOrder := []string{"/api/sessions/activate", "/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]) + if bodies["/api/sessions/activate"][0]["project_path"] != "/tmp/proj-a" { + t.Errorf("activation body = %v, want project_path /tmp/proj-a", bodies["/api/sessions/activate"][0]) } // Model + effort (effort keyed by the command's model). if bodies["/api/model"][0]["provider"] != "anthropic" || bodies["/api/model"][0]["model"] != "claude-x" { @@ -302,18 +306,18 @@ func TestChatSendComposeExistingSessionAndEffortFromHealth(t *testing.T) { } calls, bodies := local.snapshot() - wantOrder := []string{"/api/sessions", "GET /api/health", "/api/model-state/effort", "/api/chat"} + wantOrder := []string{"/api/sessions/activate", "GET /api/status", "/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]) + if bodies["/api/sessions/activate"][0]["session_id"] != "sess-77" { + t.Errorf("activation body = %v, want session_id sess-77", bodies["/api/sessions/activate"][0]) } - // Effort without an explicit model resolves the current one via /api/health. + // Effort without an explicit model resolves the task via /api/status. 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) + t.Errorf("effort body = %v, want current model from /api/status", eff) } } @@ -623,8 +627,8 @@ func TestChatSendGoalArmed(t *testing.T) { } 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) + if strings.Join(calls, ",") != "/api/sessions/activate,/api/goal" { + t.Fatalf("local calls = %v, want activation + /api/goal (no /api/chat, no compose steps)", calls) } goal := bodies["/api/goal"][0] if goal["objective"] != "交付 M14" || goal["start"] != true { diff --git a/internal/cloud/connector.go b/internal/cloud/connector.go index 7a0597a9..64c4aff8 100644 --- a/internal/cloud/connector.go +++ b/internal/cloud/connector.go @@ -46,6 +46,11 @@ type ConnectorConfig struct { // override them to point at a temp dir. IndexPathFn func() (string, error) ListSessionsFn func() (map[string][]session.SessionMeta, error) + // LoadSessionFn / HistorySyncPath override the local JSONL transcript and + // crash-recovery ledger in tests. Production uses the strict JSONL history + // loader and ~/.jcode/cloud-history.json. + LoadSessionFn func(string) ([]session.Entry, error) + HistorySyncPath string // InboxDir is the root under which chat attachments land // (//). Empty → ~/.jcode/inbox. @@ -107,6 +112,13 @@ type Connector struct { // providerSyncMu serializes ASK enrollment, provider vault reconciliation // and approval actions so one Desktop process never races its own CAS state. providerSyncMu sync.Mutex + // historySyncMu orders transcript backfill before live event sequence + // allocation. batcherMu exposes the currently running durable batcher so a + // session-index sync can flush already allocated events before inspecting + // the cloud high-water mark. + historySyncMu sync.RWMutex + batcherMu sync.Mutex + eventBatcher *eventBatcher // statusMu guards state/lastError, the live connection snapshot exposed // via Status. Written by Run's loops, read by the web status endpoint. @@ -677,11 +689,19 @@ func (c *Connector) execChatSend(ctx context.Context, cmd DeviceCommand) (string 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)} } + // The relay itself is the trust boundary. Channel is optional presentation + // metadata supplied by clients, so it must never decide whether the local + // activation endpoint applies Cloud's remote-workspace allowlist or safe + // default mode. It also prevents a Cloud-originated user turn from being + // mislabeled as a Desktop local_echo when older clients omit channel. + if strings.TrimSpace(p.Channel) == "" { + p.Channel = "cloud" + } // 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) + return c.execChatSendGoalArmed(ctx, cmd, &p) } // Attachments alone are a valid message (their reference list becomes the // text); truly empty input is not. @@ -694,16 +714,19 @@ func (c *Connector) execChatSend(ctx context.Context, cmd DeviceCommand) (string 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) { +// execChatSendGoalArmed activates its exact task without foregrounding it, then +// arms that task's goal. No /api/chat call follows. +func (c *Connector) execChatSendGoalArmed(ctx context.Context, cmd DeviceCommand, p *chatSendPayload) (string, any) { objective := strings.TrimSpace(p.Text) if objective == "" { return "error", map[string]string{"error": "chat.send: goal_armed with empty objective"} } + sessionID, err := c.activateSession(ctx, cmd.SessionID, p.ProjectPath, p.Channel) + if err != nil { + return "error", map[string]string{"error": err.Error()} + } status, body, err := c.local.postJSON(ctx, "/api/goal", map[string]any{ - "objective": objective, "start": true, + "objective": objective, "start": true, "task_id": sessionID, "source": p.Channel, }) if err != nil { return "error", map[string]string{"error": err.Error()} @@ -721,12 +744,19 @@ func (c *Connector) execChatSendLegacy(ctx context.Context, cmd DeviceCommand, p // an empty id to /api/chat targets the local active engine, which could be a // different conversation. The created id is therefore the sole target for // both the message and the successful command acknowledgment. - sessionID := cmd.SessionID - if sessionID == "" { - var err error - sessionID, err = c.createOrFocusSession(ctx, "", "", p.Channel) - if err != nil { - return "error", map[string]string{"error": err.Error()} + sessionID, err := c.activateSession(ctx, cmd.SessionID, "", p.Channel) + if err != nil { + return "error", map[string]string{"error": err.Error()} + } + if p.Mode != "" { + modeName := p.Mode + if modeName == "build" { + modeName = "approval" + } + if err := c.postLocalOK(ctx, "/api/mode", map[string]string{ + "mode": modeName, "task_id": sessionID, + }); err != nil { + return "error", map[string]string{"error": fmt.Sprintf("mode: %v", err)} } } @@ -765,7 +795,7 @@ func (c *Connector) execChatSendLegacy(ctx context.Context, cmd DeviceCommand, p } // execChatSendCompose runs the M12 ordered compose pipeline against the local -// control plane: create/focus the session (project_path) → land attachments → +// control plane: activate 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. @@ -781,10 +811,9 @@ func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, return errResult(err) } - // 1. Create/focus the session. This makes the task active before the - // task-scoped model/mode/goal endpoints below and returns the id required by - // attachments and chat. - sid, err := c.createOrFocusSession(ctx, cmd.SessionID, p.ProjectPath, p.Channel) + // 1. Activate the session without changing Desktop's foreground and return + // the id required by every task-scoped compose facet below. + sid, err := c.activateSession(ctx, cmd.SessionID, p.ProjectPath, p.Channel) if err != nil { return errResult(err) } @@ -804,7 +833,7 @@ func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, 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, + "provider": p.Model.Provider, "model": p.Model.ID, "task_id": sid, }); err != nil { return errResult(fmt.Errorf("model: %w", err)) } @@ -814,7 +843,7 @@ func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, if p.Model != nil { provider, modelID = p.Model.Provider, p.Model.ID } else { - provider, modelID, err = c.currentModel(ctx) + provider, modelID, err = c.currentModel(ctx, sid) if err != nil { return errResult(fmt.Errorf("effort: cannot resolve current model: %w", err)) } @@ -835,7 +864,7 @@ func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, 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 { + if err := c.postLocalOK(ctx, "/api/mode", map[string]string{"mode": m, "task_id": sid}); err != nil { return errResult(fmt.Errorf("mode: %w", err)) } } @@ -843,7 +872,7 @@ func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, // 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, + "objective": p.Goal, "start": false, "task_id": sid, "source": p.Channel, }); err != nil { return errResult(fmt.Errorf("goal: %w", err)) } @@ -870,32 +899,33 @@ func (c *Connector) execChatSendCompose(ctx context.Context, cmd DeviceCommand, return "ok", map[string]string{"session_id": sid} } -// createOrFocusSession centralizes the local session contract for cloud -// commands. Source is sent at creation so handleNewSession can apply the -// cloud-sync policy before the first message is emitted. -func (c *Connector) createOrFocusSession(ctx context.Context, sessionID, projectPath, channel string) (string, error) { +// activateSession centralizes the non-foreground local session contract for +// Cloud commands. Source is always non-empty so the server applies the Cloud +// remote allowlist and safe-mode policy before the first event is emitted. +func (c *Connector) activateSession(ctx context.Context, sessionID, projectPath, source string) (string, error) { req := map[string]string{} if sessionID != "" { req["session_id"] = sessionID } if projectPath != "" { - req["pwd"] = projectPath + req["project_path"] = projectPath } - if channel != "" { - req["source"] = channel + if source == "" { + source = "cloud" } - status, body, err := c.local.postJSON(ctx, "/api/sessions", req) + req["source"] = source + status, body, err := c.local.postJSON(ctx, "/api/sessions/activate", req) if err != nil { return "", err } if status != http.StatusOK { - return "", errUnexpectedStatus("/api/sessions", status, string(body)) + return "", errUnexpectedStatus("/api/sessions/activate", status, string(body)) } var resp struct { SessionID string `json:"session_id"` } if err := json.Unmarshal(body, &resp); err != nil || resp.SessionID == "" { - return "", fmt.Errorf("/api/sessions: no session_id in response: %s", body) + return "", fmt.Errorf("/api/sessions/activate: no session_id in response: %s", body) } return resp.SessionID, nil } @@ -912,23 +942,23 @@ func (c *Connector) postLocalOK(ctx context.Context, path string, body any) erro 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") +// currentModel resolves the activated task's provider/model via task-scoped +// status (used to key an effort override when the command did not name one). +func (c *Connector) currentModel(ctx context.Context, sessionID string) (provider, modelID string, err error) { + path := "/api/status?task_id=" + url.QueryEscape(sessionID) + status, body, err := c.local.getJSON(ctx, path) if err != nil { return "", "", err } if status != http.StatusOK { - return "", "", errUnexpectedStatus("/api/health", status, string(body)) + return "", "", errUnexpectedStatus("/api/status", 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 "", "", fmt.Errorf("/api/status: invalid response: %w", err) } return health.Provider, health.Model, nil } diff --git a/internal/cloud/connector_test.go b/internal/cloud/connector_test.go index e93cfa10..4fe8bddb 100644 --- a/internal/cloud/connector_test.go +++ b/internal/cloud/connector_test.go @@ -189,7 +189,7 @@ func newFakeLocal(t *testing.T) (*fakeLocal, *httptest.Server) { t.Helper() f := &fakeLocal{activeSessionID: "sess-active-1", createdSessionID: "sess-new-1"} mux := http.NewServeMux() - mux.HandleFunc("POST /api/sessions", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("POST /api/sessions/activate", func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() @@ -200,7 +200,14 @@ func newFakeLocal(t *testing.T) (*fakeLocal, *httptest.Server) { http.Error(w, "session create failed", status) return } - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "session_id": f.createdSessionID}) + sessionID, _ := body["session_id"].(string) + if sessionID == "" { + sessionID = f.createdSessionID + } + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ready", "session_id": sessionID}) + }) + mux.HandleFunc("POST /api/mode", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) mux.HandleFunc("POST /api/chat", func(w http.ResponseWriter, r *http.Request) { var body map[string]any @@ -274,6 +281,7 @@ func (f *fakeLocal) sessionBody() map[string]any { func newTestConnector(t *testing.T, cloudURL, localBase string) *Connector { t.Helper() + historyPath := filepath.Join(t.TempDir(), historySyncFile) return NewConnector(ConnectorConfig{ CloudURL: cloudURL, Credentials: &Credentials{DeviceID: "dev-1", DeviceToken: "tok", DeviceName: "test"}, @@ -288,7 +296,11 @@ func newTestConnector(t *testing.T, cloudURL, localBase string) *Connector { // 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"), + SyncStore: newTestSyncStore(t, "s1", "s2"), + HistorySyncPath: historyPath, + LoadSessionFn: func(string) ([]session.Entry, error) { + return nil, os.ErrNotExist + }, }) } @@ -622,10 +634,16 @@ func TestEventPumpDurableAndEphemeral(t *testing.T) { // 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})) + // Local-only: private SSH retry state must never be uploaded, even via the + // ephemeral relay, and must not consume the next durable sequence number. + conn.handleWSEvent(ctx, batcher, wsMsg(t, "remote_connection_status", "s1", map[string]any{ + "kind": "ssh", "status": "reconnecting", "attempt": 1, + })) + conn.handleWSEvent(ctx, batcher, wsMsg(t, "tool_result", "s1", map[string]string{"name": "read"})) // 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 len(cloud.allEvents()) == 4 }, "4 durable events uploaded") waitFor(t, func() bool { return cloud.ephemeralCount() == 2 }, "2 ephemeral events forwarded") events := cloud.allEvents() @@ -634,7 +652,7 @@ func TestEventPumpDurableAndEphemeral(t *testing.T) { 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"} + wantKinds := []string{"user_message", "tool_call", "task_status", "tool_result"} for i, ev := range events { if ev.Kind != wantKinds[i] { t.Errorf("events[%d].Kind = %q, want %q", i, ev.Kind, wantKinds[i]) diff --git a/internal/cloud/crypto_wiring_test.go b/internal/cloud/crypto_wiring_test.go index 22eb4f21..1dbc4735 100644 --- a/internal/cloud/crypto_wiring_test.go +++ b/internal/cloud/crypto_wiring_test.go @@ -137,7 +137,7 @@ func TestSessionsMetaSealedWhenCipherActive(t *testing.T) { if err := json.Unmarshal(plain, &meta); err != nil { t.Fatal(err) } - if meta.UUID != "s1" || meta.Title != "secret title" { + if meta.UUID != "s1" || meta.Title != "secret title" || meta.Project != "proj" { t.Fatalf("decrypted meta = %+v", meta) } } diff --git a/internal/cloud/events.go b/internal/cloud/events.go index 2651e27a..5847f083 100644 --- a/internal/cloud/events.go +++ b/internal/cloud/events.go @@ -52,6 +52,15 @@ var eventDurability = map[string]bool{ "subagent_progress": false, // intermediate subagent tool progress lines } +// localOnlyEvents are WebSocket control-plane signals intended only for a +// client attached directly to this JCode process. They must not consume a +// Cloud sequence number, enter durable history, or ride the ephemeral relay: +// remote connection retry details describe the device's private transport and +// can include host/error diagnostics that Cloud neither needs nor should store. +var localOnlyEvents = map[string]bool{ + "remote_connection_status": true, +} + // isDurableEvent reports whether a WS event type is uploaded as a durable // event. Unknown types default to durable. func isDurableEvent(eventType string) bool { @@ -241,6 +250,15 @@ func (b *eventBatcher) run(ctx context.Context) { } func (b *eventBatcher) flushAll(ctx context.Context) { + b.c.historySyncMu.RLock() + defer b.c.historySyncMu.RUnlock() + b.flushAllLocked(ctx) +} + +// flushAllLocked drains pending events while the caller already holds either +// side of historySyncMu. Session sync uses the write side to establish a +// stable server high-water mark before projecting older JSONL entries. +func (b *eventBatcher) flushAllLocked(ctx context.Context) { b.mu.Lock() batches := b.pending b.pending = make(map[string][]EventUpload) @@ -252,6 +270,12 @@ func (b *eventBatcher) flushAll(ctx context.Context) { } } +func (b *eventBatcher) hasPending(sid string) bool { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.pending[sid]) > 0 +} + // 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. @@ -284,6 +308,16 @@ func (b *eventBatcher) upload(ctx context.Context, sid string, batch []EventUplo func (c *Connector) eventPumpLoop(ctx context.Context) { bo := c.backoff() batcher := newEventBatcher(c) + c.batcherMu.Lock() + c.eventBatcher = batcher + c.batcherMu.Unlock() + defer func() { + c.batcherMu.Lock() + if c.eventBatcher == batcher { + c.eventBatcher = nil + } + c.batcherMu.Unlock() + }() go batcher.run(ctx) for { err := c.pumpEvents(ctx, batcher) @@ -341,6 +375,9 @@ func (c *Connector) handleWSEvent(ctx context.Context, batcher *eventBatcher, ms if err := json.Unmarshal(msg, &ev); err != nil || ev.Type == "" { return } + if localOnlyEvents[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 @@ -357,15 +394,19 @@ func (c *Connector) handleWSEvent(ctx context.Context, batcher *eventBatcher, ms if sid == "" { return } + // Backfill takes the write side while it seeds seq 1..N. Keeping this read + // lock through live allocation prevents a just-enabled session from racing + // an older transcript into the same sequence numbers. + c.historySyncMu.RLock() + defer c.historySyncMu.RUnlock() // 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 // next replacement session snapshot removes the mirror and its durable - // cloud events. Turning sync back on recreates the mirror and resumes uploads - // from that moment forward — earlier local history is NOT backfilled (the - // pump is a live stream and has no replay path). + // cloud events. Turning sync back on recreates the mirror; session sync now + // projects the local JSONL transcript before this live path resumes. 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 diff --git a/internal/cloud/events_unit_test.go b/internal/cloud/events_unit_test.go index 9ecf4047..b7d60e4d 100644 --- a/internal/cloud/events_unit_test.go +++ b/internal/cloud/events_unit_test.go @@ -39,6 +39,12 @@ func TestEventDurabilityClassification(t *testing.T) { } } +func TestRemoteConnectionStatusIsLocalOnly(t *testing.T) { + if !localOnlyEvents["remote_connection_status"] { + t.Fatal("remote_connection_status must be dropped before Cloud event routing") + } +} + func TestSeqAllocatorMonotonicFromOne(t *testing.T) { a := newSeqAllocator() for want := int64(1); want <= 3; want++ { diff --git a/internal/cloud/history_sync.go b/internal/cloud/history_sync.go new file mode 100644 index 00000000..7528e6e9 --- /dev/null +++ b/internal/cloud/history_sync.go @@ -0,0 +1,413 @@ +package cloud + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/session" +) + +const ( + historyProjectionVersion = 1 + historyUploadBatchSize = 50 + // The current cloud clients fetch at most 1000 durable events when a + // conversation is opened. Do not claim a successful full backfill when the + // existing client could only render a prefix of it. + historyProjectionLimit = 1000 + historySyncFile = "cloud-history.json" +) + +type projectedHistoryEvent struct { + Kind string + Payload json.RawMessage +} + +// historySyncRecord is the local crash-recovery cursor for the one-time +// transcript projection. The cloud protocol is idempotent by (session, seq), +// so a restarted connector may safely continue at the server high-water mark +// only when the exact same projection is still on disk. +type historySyncRecord struct { + ProjectionVersion int `json:"projection_version"` + ProjectionHash string `json:"projection_hash"` + EventCount int64 `json:"event_count"` + NextSeq int64 `json:"next_seq"` + KeyGen int `json:"key_gen,omitempty"` + Complete bool `json:"complete,omitempty"` +} + +type historySyncLedger struct { + Sessions map[string]historySyncRecord `json:"sessions"` +} + +func historySyncPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cloud history path: %w", err) + } + return filepath.Join(home, ".jcode", historySyncFile), nil +} + +func loadHistorySyncLedger(path string) (*historySyncLedger, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &historySyncLedger{Sessions: make(map[string]historySyncRecord)}, nil + } + return nil, fmt.Errorf("read cloud history ledger: %w", err) + } + var ledger historySyncLedger + if err := json.Unmarshal(data, &ledger); err != nil { + return nil, fmt.Errorf("parse cloud history ledger: %w", err) + } + if ledger.Sessions == nil { + ledger.Sessions = make(map[string]historySyncRecord) + } + return &ledger, nil +} + +func saveHistorySyncLedger(path string, ledger *historySyncLedger) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create cloud history directory: %w", err) + } + data, err := json.MarshalIndent(ledger, "", " ") + if err != nil { + return fmt.Errorf("marshal cloud history ledger: %w", err) + } + tmp, err := os.CreateTemp(dir, ".cloud-history.tmp-*") + if err != nil { + return fmt.Errorf("create cloud history ledger temp file: %w", err) + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("secure cloud history ledger: %w", err) + } + if n, err := tmp.Write(data); err != nil { + return fmt.Errorf("write cloud history ledger: %w", err) + } else if n != len(data) { + return fmt.Errorf("write cloud history ledger: %w", io.ErrShortWrite) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("sync cloud history ledger: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close cloud history ledger: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replace cloud history ledger: %w", err) + } + return nil +} + +// projectSessionHistory translates the local JSONL source of truth into the +// durable event vocabulary already understood by the current cloud clients. +// It deliberately excludes streaming deltas and opaque/non-renderable records. +func projectSessionHistory(sessionID string, entries []session.Entry) ([]projectedHistoryEvent, error) { + events := make([]projectedHistoryEvent, 0, len(entries)) + appendEvent := func(kind string, data map[string]any) error { + payload, err := json.Marshal(map[string]any{ + "type": kind, + "task_id": sessionID, + "data": data, + }) + if err != nil { + return fmt.Errorf("marshal %s history event: %w", kind, err) + } + events = append(events, projectedHistoryEvent{Kind: kind, Payload: payload}) + if len(events) > historyProjectionLimit { + return fmt.Errorf("projected history has more than %d renderable events", historyProjectionLimit) + } + return nil + } + + for _, entry := range entries { + var kind string + var data map[string]any + switch entry.Type { + case session.EntryUser: + if entry.Content == "" { + continue + } + kind = "user_message" + data = map[string]any{"content": entry.Content} + case session.EntryAssistant: + if entry.Content == "" { + continue + } + kind = "agent_message" + data = map[string]any{"text": entry.Content} + case session.EntryToolCall: + data = map[string]any{ + "name": entry.Name, + "args": entry.Args, + "tool_call_id": entry.ToolCallID, + } + if entry.BatchID != "" { + data["batch_id"] = entry.BatchID + data["batch_index"] = entry.BatchIndex + data["batch_size"] = entry.BatchSize + } + kind = "tool_call" + case session.EntryToolResult: + data = map[string]any{ + "name": entry.Name, + "output": entry.Output, + "tool_call_id": entry.ToolCallID, + } + if entry.Error != "" { + data["error"] = entry.Error + } + if entry.Denied { + data["denied"] = true + } + if entry.DurationMs > 0 { + data["duration_ms"] = entry.DurationMs + } + kind = "tool_result" + case session.EntryModeChange: + if entry.Mode == "" { + continue + } + kind = "mode_changed" + data = map[string]any{"mode": entry.Mode} + case session.EntryGoalUpdate: + kind = "goal_update" + data = map[string]any{ + "objective": entry.GoalObjective, + "status": entry.GoalStatus, + "tokens_used": entry.GoalTokensUsed, + "created_at": entry.GoalCreatedAt, + "updated_at": entry.GoalUpdatedAt, + } + case session.EntrySubagentStart, session.EntrySubagentAsync: + kind = "subagent_event" + data = map[string]any{ + "name": entry.SubagentName, + "agent_type": entry.SubagentType, + "done": false, + } + case session.EntrySubagentResult: + kind = "subagent_event" + data = map[string]any{ + "name": entry.SubagentName, + "done": true, + "result": entry.Output, + "error": entry.Error, + } + default: + continue + } + if err := appendEvent(kind, data); err != nil { + return nil, err + } + } + return events, nil +} + +func historyProjectionHash(events []projectedHistoryEvent) string { + h := sha256.New() + for _, event := range events { + _, _ = h.Write([]byte(event.Kind)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(event.Payload) + _, _ = h.Write([]byte{'\n'}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// loadSessionHistoryStrict differs intentionally from the interactive replay +// loader: a malformed JSONL line must stop a cloud backfill instead of silently +// publishing a transcript with a permanent hole and marking its ledger done. +func loadSessionHistoryStrict(sessionID string) ([]session.Entry, error) { + if err := session.ValidateSessionID(sessionID); err != nil { + return nil, err + } + dir, err := config.SessionsDir() + if err != nil { + return nil, err + } + path := filepath.Join(dir, sessionID+".json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read session history %s: %w", sessionID, err) + } + entries := make([]session.Entry, 0) + for lineNo, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + var entry session.Entry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + return nil, fmt.Errorf("parse session history %s line %d: %w", sessionID, lineNo+1, err) + } + entries = append(entries, entry) + } + return entries, nil +} + +func (c *Connector) strictSealHistory(payload json.RawMessage) (json.RawMessage, int, error) { + cipher := c.cipherSnapshot() + if cipher == nil || c.cfg.CipherDisabled { + return payload, 0, nil + } + sealed, err := cipher.Seal(payload) + if err != nil { + return nil, cipher.KeyGen(), fmt.Errorf("seal history event: %w", err) + } + return sealed, cipher.KeyGen(), nil +} + +// backfillSessionHistory uploads a stable prefix projection when the cloud has +// no history yet. A matching in-progress ledger is the only case in which a +// non-zero server cursor may resume; unrelated legacy live events are left +// untouched because the current protocol cannot prepend history safely. +func (c *Connector) backfillSessionHistory(ctx context.Context, sessionID string, serverLastSeq int64) (int64, error) { + loadFn := c.cfg.LoadSessionFn + if loadFn == nil { + loadFn = loadSessionHistoryStrict + } + entries, err := loadFn(sessionID) + if err != nil { + // Metadata can legitimately exist before the first recorded message. + // Keep the ordinary session sync successful and try again later. + return serverLastSeq, nil + } + events, err := projectSessionHistory(sessionID, entries) + if err != nil { + return serverLastSeq, err + } + hash := historyProjectionHash(events) + path := c.cfg.HistorySyncPath + if path == "" { + path, err = historySyncPath() + if err != nil { + return serverLastSeq, err + } + } + ledger, err := loadHistorySyncLedger(path) + if err != nil { + return serverLastSeq, err + } + + keyGen := 0 + if cipher := c.cipherSnapshot(); cipher != nil && !c.cfg.CipherDisabled { + keyGen = cipher.KeyGen() + } + record, recorded := ledger.Sessions[sessionID] + matches := recorded && + record.ProjectionVersion == historyProjectionVersion && + record.ProjectionHash == hash && + record.EventCount == int64(len(events)) && + record.KeyGen == keyGen + + if serverLastSeq > 0 && !matches { + // Existing live-only history cannot be safely reordered with the current + // cloud contract. Preserve it instead of appending older messages after it. + return serverLastSeq, nil + } + if matches && record.Complete && serverLastSeq >= record.EventCount { + return serverLastSeq, nil + } + if len(events) == 0 { + ledger.Sessions[sessionID] = historySyncRecord{ + ProjectionVersion: historyProjectionVersion, + ProjectionHash: hash, + EventCount: 0, + NextSeq: 1, + KeyGen: keyGen, + Complete: true, + } + return serverLastSeq, saveHistorySyncLedger(path, ledger) + } + + if !matches || serverLastSeq == 0 { + record = historySyncRecord{ + ProjectionVersion: historyProjectionVersion, + ProjectionHash: hash, + EventCount: int64(len(events)), + NextSeq: 1, + KeyGen: keyGen, + } + ledger.Sessions[sessionID] = record + if err := saveHistorySyncLedger(path, ledger); err != nil { + return serverLastSeq, err + } + } + + startSeq := serverLastSeq + 1 + if record.NextSeq > startSeq { + startSeq = record.NextSeq + } + if startSeq > int64(len(events)) { + record.Complete = true + record.NextSeq = int64(len(events)) + 1 + ledger.Sessions[sessionID] = record + return maxInt64(serverLastSeq, int64(len(events))), saveHistorySyncLedger(path, ledger) + } + + for start := startSeq; start <= int64(len(events)); { + end := start + historyUploadBatchSize - 1 + if end > int64(len(events)) { + end = int64(len(events)) + } + batch := make([]EventUpload, 0, end-start+1) + for seq := start; seq <= end; seq++ { + event := events[seq-1] + payload, sealedKeyGen, sealErr := c.strictSealHistory(event.Payload) + if sealErr != nil { + return serverLastSeq, sealErr + } + if sealedKeyGen != record.KeyGen { + return serverLastSeq, fmt.Errorf("cloud history encryption key changed during backfill") + } + batch = append(batch, EventUpload{Seq: seq, Kind: event.Kind, Payload: payload}) + } + resp, uploadErr := c.client.UploadEvents(ctx, c.token, sessionID, batch) + if uploadErr != nil { + return serverLastSeq, uploadErr + } + // This writer starts strictly after the upsert's server high-water mark, + // so a conflict means another connector allocated the same seq while the + // backfill was in flight. Do not mark an unknown payload as our history. + if len(resp.Conflicted) > 0 { + return serverLastSeq, fmt.Errorf("cloud history upload conflicted at seq %v; another writer is active", resp.Conflicted) + } + if resp.MaxSeq < end || len(resp.Accepted) != len(batch) { + return serverLastSeq, fmt.Errorf("cloud history upload acknowledged an incomplete batch through seq %d", end) + } + record.NextSeq = end + 1 + ledger.Sessions[sessionID] = record + if err := saveHistorySyncLedger(path, ledger); err != nil { + return serverLastSeq, err + } + serverLastSeq = maxInt64(serverLastSeq, resp.MaxSeq) + start = end + 1 + } + record.Complete = true + ledger.Sessions[sessionID] = record + if err := saveHistorySyncLedger(path, ledger); err != nil { + return serverLastSeq, err + } + return maxInt64(serverLastSeq, int64(len(events))), nil +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/cloud/history_sync_test.go b/internal/cloud/history_sync_test.go new file mode 100644 index 00000000..f1c64110 --- /dev/null +++ b/internal/cloud/history_sync_test.go @@ -0,0 +1,223 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +func TestProjectSessionHistoryUsesCloudDurableVocabulary(t *testing.T) { + entries := []session.Entry{ + {Type: session.EntrySessionStart}, + {Type: session.EntryUser, Content: "hello"}, + {Type: session.EntryAssistant, Content: "hi"}, + {Type: session.EntryToolCall, Name: "read", Args: `{"path":"a"}`, ToolCallID: "call-1", BatchID: "b", BatchIndex: 0, BatchSize: 1}, + {Type: session.EntryToolResult, Name: "read", Output: "body", ToolCallID: "call-1", DurationMs: 12}, + {Type: session.EntryModeChange, Mode: "plan"}, + {Type: session.EntryGoalUpdate, GoalObjective: "ship", GoalStatus: "active", GoalTokensUsed: 9}, + {Type: session.EntrySubagentStart, SubagentName: "research", SubagentType: "explore"}, + {Type: session.EntrySubagentResult, SubagentName: "research", Output: "done"}, + {Type: session.EntryArtifact, ArtifactID: "not-rendered"}, + } + events, err := projectSessionHistory("s1", entries) + if err != nil { + t.Fatal(err) + } + wantKinds := []string{"user_message", "agent_message", "tool_call", "tool_result", "mode_changed", "goal_update", "subagent_event", "subagent_event"} + if len(events) != len(wantKinds) { + t.Fatalf("projected events = %d, want %d", len(events), len(wantKinds)) + } + for i, want := range wantKinds { + if events[i].Kind != want { + t.Fatalf("events[%d].Kind = %q, want %q", i, events[i].Kind, want) + } + var envelope struct { + Type string `json:"type"` + TaskID string `json:"task_id"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(events[i].Payload, &envelope); err != nil { + t.Fatalf("events[%d] payload: %v", i, err) + } + if envelope.Type != want || envelope.TaskID != "s1" { + t.Fatalf("events[%d] envelope = %+v", i, envelope) + } + } +} + +func TestLoadSessionHistoryStrictRejectsCorruptLine(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".jcode", "sessions") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + data := []byte("{\"type\":\"user\",\"content\":\"ok\"}\nnot-json\n") + if err := os.WriteFile(filepath.Join(dir, "s1.json"), data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadSessionHistoryStrict("s1"); err == nil { + t.Fatal("corrupt JSONL was accepted for cloud backfill") + } +} + +func TestSyncSessionsBackfillsEmptyCloudHistoryBeforeLiveSeq(t *testing.T) { + mock := newMockCloud() + server := httptest.NewServer(mock.handler()) + t.Cleanup(server.Close) + + conn := newTestConnector(t, server.URL, "http://127.0.0.1:1") + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{ + "ssh://host/work": {{UUID: "s1", Project: "ssh://host/work", Status: "idle"}}, + }, nil + } + conn.cfg.LoadSessionFn = func(id string) ([]session.Entry, error) { + return []session.Entry{ + {Type: session.EntryUser, Content: "old question"}, + {Type: session.EntryAssistant, Content: "old answer"}, + {Type: session.EntryToolCall, Name: "read", Args: `{}`, ToolCallID: "c1"}, + {Type: session.EntryToolResult, Name: "read", Output: "ok", ToolCallID: "c1"}, + }, nil + } + + if err := conn.syncSessions(context.Background()); err != nil { + t.Fatal(err) + } + events := mock.allEvents() + if len(events) != 4 { + t.Fatalf("uploaded history = %d events, want 4", len(events)) + } + for i, event := range events { + if event.Seq != int64(i+1) { + t.Fatalf("events[%d].Seq = %d, want %d", i, event.Seq, i+1) + } + } + if got := conn.seq.Next("s1"); got != 5 { + t.Fatalf("first live seq = %d, want 5", got) + } + ledger, err := loadHistorySyncLedger(conn.cfg.HistorySyncPath) + if err != nil { + t.Fatal(err) + } + if record := ledger.Sessions["s1"]; !record.Complete || record.EventCount != 4 || record.NextSeq != 5 { + t.Fatalf("history ledger = %+v", record) + } +} + +func TestSyncSessionsPreservesUnrelatedPartialCloudHistory(t *testing.T) { + mock := newMockCloud() + mock.lastSeq["s1"] = 3 + server := httptest.NewServer(mock.handler()) + t.Cleanup(server.Close) + + conn := newTestConnector(t, server.URL, "http://127.0.0.1:1") + conn.cfg.HistorySyncPath = filepath.Join(t.TempDir(), historySyncFile) + conn.cfg.ListSessionsFn = func() (map[string][]session.SessionMeta, error) { + return map[string][]session.SessionMeta{"/p": {{UUID: "s1", Status: "idle"}}}, nil + } + conn.cfg.LoadSessionFn = func(string) ([]session.Entry, error) { + return []session.Entry{{Type: session.EntryUser, Content: "older"}}, nil + } + + if err := conn.syncSessions(context.Background()); err != nil { + t.Fatal(err) + } + if got := len(mock.allEvents()); got != 0 { + t.Fatalf("legacy partial history was rewritten with %d events", got) + } + if got := conn.seq.Next("s1"); got != 4 { + t.Fatalf("first live seq = %d, want 4", got) + } +} + +func TestBackfillResumesMatchingCrashLedger(t *testing.T) { + mock := newMockCloud() + mock.lastSeq["s1"] = 2 + server := httptest.NewServer(mock.handler()) + t.Cleanup(server.Close) + + conn := newTestConnector(t, server.URL, "http://127.0.0.1:1") + entries := []session.Entry{ + {Type: session.EntryUser, Content: "one"}, + {Type: session.EntryAssistant, Content: "two"}, + {Type: session.EntryUser, Content: "three"}, + } + conn.cfg.LoadSessionFn = func(string) ([]session.Entry, error) { return entries, nil } + events, err := projectSessionHistory("s1", entries) + if err != nil { + t.Fatal(err) + } + ledger := &historySyncLedger{Sessions: map[string]historySyncRecord{ + "s1": { + ProjectionVersion: historyProjectionVersion, + ProjectionHash: historyProjectionHash(events), + EventCount: 3, + NextSeq: 3, + }, + }} + if err := saveHistorySyncLedger(conn.cfg.HistorySyncPath, ledger); err != nil { + t.Fatal(err) + } + + last, err := conn.backfillSessionHistory(context.Background(), "s1", 2) + if err != nil { + t.Fatal(err) + } + if last != 3 { + t.Fatalf("last seq = %d, want 3", last) + } + uploaded := mock.allEvents() + if len(uploaded) != 1 || uploaded[0].Seq != 3 { + t.Fatalf("resumed uploads = %+v, want only seq 3", uploaded) + } +} + +func TestBackfillStrictlySealsHistoryWhenE2EEIsActive(t *testing.T) { + mock := newMockCloud() + server := httptest.NewServer(mock.handler()) + t.Cleanup(server.Close) + + conn := newTestConnector(t, server.URL, "http://127.0.0.1:1") + cipher, err := NewEnvelopeCipher(bytes.Repeat([]byte{0x5a}, cekSize), 7) + if err != nil { + t.Fatal(err) + } + conn.setCipher(cipher) + conn.cfg.LoadSessionFn = func(string) ([]session.Entry, error) { + return []session.Entry{{Type: session.EntryUser, Content: "secret history"}}, nil + } + + last, err := conn.backfillSessionHistory(context.Background(), "s1", 0) + if err != nil { + t.Fatal(err) + } + if last != 1 { + t.Fatalf("last seq = %d, want 1", last) + } + uploaded := mock.allEvents() + if len(uploaded) != 1 || !IsEnvelope(uploaded[0].Payload) { + t.Fatalf("history payload was not E2EE sealed: %+v", uploaded) + } + plain, err := cipher.Open(uploaded[0].Payload) + if err != nil { + t.Fatal(err) + } + var envelope struct { + Data struct { + Content string `json:"content"` + } `json:"data"` + } + if err := json.Unmarshal(plain, &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.Content != "secret history" { + t.Fatalf("decrypted content = %q", envelope.Data.Content) + } +} diff --git a/internal/cloud/sessions.go b/internal/cloud/sessions.go index 9f1b71ba..cfc2dec9 100644 --- a/internal/cloud/sessions.go +++ b/internal/cloud/sessions.go @@ -31,7 +31,7 @@ func (c *Connector) collectSessions() ([]SessionUpsert, error) { return nil, err } upserts := make([]SessionUpsert, 0, len(all)) - for _, metas := range all { + for project, metas := range all { for _, m := range metas { if m.UUID == "" { continue @@ -46,6 +46,10 @@ func (c *Connector) collectSessions() ([]SessionUpsert, error) { if m.Status == "running" { status = "running" } + // The index key is authoritative. Older index rows may predate the + // redundant Project field; populate it so the unchanged Cloud client can + // still distinguish ssh:// and docker:// conversations from local ones. + m.Project = project metaJSON, err := json.Marshal(m) if err != nil { continue @@ -88,6 +92,18 @@ func parseActivityTime(value string) (time.Time, bool) { // 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 { + // Sequence allocation and history projection must observe one stable cloud + // cursor. Live events hold the read side; the sync round takes the write + // side and first flushes anything that was already allocated. + c.historySyncMu.Lock() + defer c.historySyncMu.Unlock() + c.batcherMu.Lock() + batcher := c.eventBatcher + c.batcherMu.Unlock() + if batcher != nil { + batcher.flushAllLocked(ctx) + } + upserts, err := c.collectSessions() if err != nil { return err @@ -102,8 +118,32 @@ func (c *Connector) syncSessions(ctx context.Context) error { if err != nil { return err } + byID := make(map[string]SessionUpsert, len(upserts)) + for _, upsert := range upserts { + byID[upsert.SessionID] = upsert + } for _, s := range resp.Sessions { - c.seq.Seed(s.SessionID, s.LastSeq) + lastSeq := s.LastSeq + upsert, known := byID[s.SessionID] + // Never snapshot a conversation while a run is writing it, or while a + // failed live upload remains queued. The next index/sync-store tick will + // retry once the transcript is stable. + // Before the event pump starts there is no concurrent local stream, so a + // stale persisted "running" bit from an earlier crash must not suppress + // recovery forever. During normal operation only an idle session is safe. + canBackfill := known && (batcher == nil || upsert.Status == "idle") + if batcher != nil && batcher.hasPending(s.SessionID) { + canBackfill = false + } + if canBackfill { + projectedLastSeq, backfillErr := c.backfillSessionHistory(ctx, s.SessionID, lastSeq) + if backfillErr != nil { + c.logf("history backfill for session %s skipped: %v", s.SessionID, backfillErr) + } else { + lastSeq = projectedLastSeq + } + } + c.seq.Seed(s.SessionID, lastSeq) } c.logf("session index synced (%d sessions)", len(upserts)) return nil diff --git a/internal/command/acp.go b/internal/command/acp.go index eca364ee..860772ee 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -694,7 +694,7 @@ func (a *acpAgent) buildAgentSession( } if rec != nil { rec.SetAgent(agentRoleName) - rec.SetModel(modelName) + rec.SetProviderModel(providerName, modelName) } approvalState := runner.NewApprovalStateWithMode(pwd, startupMode) approvalState.SetComputerPermFunc(func(bundleID, class string) bool { @@ -1112,7 +1112,7 @@ func (a *acpAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Pr // ACPHandler.OnAgentDone. Tell the user what happened, in words they can act // on, and end the turn with a reason that is not "success". if turnErr := sess.h.TakeTurnError(); turnErr != nil { - friendly := internalmodel.FriendlyAPIError(turnErr, sess.providerName, sess.modelName) + friendly := runner.FormatRunError(turnErr, sess.providerName, sess.modelName) config.Logger().Printf("[acp] turn failed: %v", turnErr) sess.h.OnAgentText("\n" + friendly) return acp.PromptResponse{StopReason: acp.StopReasonRefusal}, nil diff --git a/internal/command/interactive.go b/internal/command/interactive.go index e35a1303..85127c0a 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -1085,7 +1085,7 @@ func (s *interactiveState) handleConfig(cfgMsg *config.Config) { s.chatModel = newChatModel // Attribute subsequent usage to the newly selected model. if s.rec != nil { - s.rec.SetModel(newModelName) + s.rec.SetProviderModel(newProvName, newModelName) } // Rebuild system prompt and tools to reflect config changes (e.g., SSH aliases) @@ -1169,7 +1169,7 @@ func (s *interactiveState) handleAddModel() { } s.chatModel = newChatModel if s.rec != nil { - s.rec.SetModel(newModelName) + s.rec.SetProviderModel(newProvName, newModelName) } if newAg, agErr := s.createAgent(); agErr == nil { s.ag = newAg @@ -1188,7 +1188,8 @@ func (s *interactiveState) handleSSH(connMsg interface{}) { switch msg := connMsg.(type) { case tui.SSHConnectMsg: HandleSSHConnect(s.ctx, s.env, msg.Addr, msg.Path, s.p, &s.systemPrompt, - &s.ag, s.chatModel, s.createAgent, s.skillLoader.Descriptions()) + &s.ag, s.chatModel, s.createAgent, s.skillLoader.Descriptions(), + msg.AcceptHostKey, msg.HostKeyFingerprint) case tui.SSHListDirReqMsg: HandleSSHListDir(s.ctx, s.env, msg.Path, s.p) case tui.SSHCancelMsg: diff --git a/internal/command/ssh.go b/internal/command/ssh.go index dd2dec76..1e5fd685 100644 --- a/internal/command/ssh.go +++ b/internal/command/ssh.go @@ -2,6 +2,7 @@ package command import ( "context" + "errors" "fmt" "strings" @@ -29,6 +30,8 @@ func HandleSSHConnect( chatModel einomodel.ToolCallingChatModel, createAgent func() (*adk.ChatModelAgent, error), skillDescriptions string, + acceptHostKey bool, + hostKeyFingerprint string, ) { user := "root" host := addr @@ -37,8 +40,25 @@ func HandleSSHConnect( host = parts[1] } - executor, err := remote.Connect(remote.SSHOptions{Host: host, User: user}) + executor, err := remote.ConnectContext(ctx, remote.SSHOptions{ + Host: host, + User: user, + AcceptHostKey: acceptHostKey, + HostKeyFingerprint: hostKeyFingerprint, + }) if err != nil { + var hostKeyErr *remote.SSHHostKeyError + if errors.As(err, &hostKeyErr) { + p.Send(tui.SSHStatusMsg{ + Success: false, + Err: err, + HostKeyCode: hostKeyErr.Code, + Host: hostKeyErr.Host, + Fingerprint: hostKeyErr.Fingerprint, + KeyType: hostKeyErr.KeyType, + }) + return + } p.Send(tui.SSHStatusMsg{Success: false, Err: err}) return } diff --git a/internal/handler/web.go b/internal/handler/web.go index 666fb80e..9212a757 100644 --- a/internal/handler/web.go +++ b/internal/handler/web.go @@ -491,9 +491,14 @@ type WebSubagentProgressData struct { // Detail carries the full raw error text for a collapsible "details" view. // Stopped marks a user-initiated stop — the UI shows a calm notice, not an error. type WebDoneData struct { - Error string `json:"error,omitempty"` - Detail string `json:"detail,omitempty"` - Stopped bool `json:"stopped,omitempty"` + Error string `json:"error,omitempty"` + Detail string `json:"detail,omitempty"` + Stopped bool `json:"stopped,omitempty"` + Code string `json:"code,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Kind string `json:"kind,omitempty"` + Phase string `json:"phase,omitempty"` + Retryable *bool `json:"retryable,omitempty"` } // WebApprovalRequestData carries an approval request. ToolCallID (when known) @@ -716,6 +721,31 @@ func (h *WebHandler) OnAgentDone(err error) { h.emit("agent_done", WebDoneData{Stopped: true}) return } + var remoteErr *tools.RemoteTransportError + if errors.As(err, &remoteErr) { + summary, detail := internalmodel.SummarizeRunError(err) + code := remoteErr.Code + if code == "" { + code = "remote_connection_failed" + if remoteErr.Kind != "" { + code = remoteErr.Kind + "_connection_failed" + } + } + retryable := remoteErr.Retryable + // A recovered transport does not make a dispatched mutation safe to run + // again. In agent_done, Retryable describes the interrupted operation, + // not whether a fresh SSH dial could succeed. + if remoteErr.Phase == tools.RemoteTransportOutcomeUnknown { + retryable = false + } + h.emit("agent_done", WebDoneData{ + Error: summary, Detail: detail, + Code: code, ErrorKind: "remote_connection", + Kind: remoteErr.Kind, Phase: string(remoteErr.Phase), + Retryable: &retryable, + }) + return + } // Raw run errors (eino NodeRunError wrapping go-openai API errors) are too // noisy for the timeline — send a one-line summary plus the raw detail. summary, detail := internalmodel.SummarizeRunError(err) diff --git a/internal/handler/web_test.go b/internal/handler/web_test.go index ba370219..14306203 100644 --- a/internal/handler/web_test.go +++ b/internal/handler/web_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "strings" "testing" "time" @@ -60,6 +61,46 @@ func TestWebHandler_ResolveApprovalOnceVsAll(t *testing.T) { } } +func TestWebHandler_AgentDoneCarriesRemoteErrorCode(t *testing.T) { + h := NewWebHandler() + h.OnAgentDone(tools.Fatal(&tools.RemoteTransportError{ + Kind: "ssh", Code: "ssh_connection_failed", + Phase: tools.RemoteTransportOutcomeUnknown, + Retryable: true, // The transport is retryable; the dispatched operation is not. + Err: context.DeadlineExceeded, + })) + + select { + case event := <-h.Events(): + if event.Event != "agent_done" { + t.Fatalf("event = %q", event.Event) + } + data, ok := event.Data.(WebDoneData) + if !ok { + t.Fatalf("data = %T, want WebDoneData", event.Data) + } + if data.Code != "ssh_connection_failed" || data.ErrorKind != "remote_connection" || + data.Kind != "ssh" || data.Phase != string(tools.RemoteTransportOutcomeUnknown) { + t.Fatalf("remote agent_done = %+v", data) + } + if data.Retryable == nil || *data.Retryable { + t.Fatalf("retryable = %v, want explicit false", data.Retryable) + } + wire, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal remote agent_done: %v", err) + } + if !strings.Contains(string(wire), `"retryable":false`) { + t.Fatalf("wire data omitted explicit non-retryable state: %s", wire) + } + if strings.Contains(strings.ToLower(data.Error), "model") { + t.Fatalf("remote error mislabelled as model error: %q", data.Error) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for agent_done") + } +} + func TestWebHandlerBillableApprovalRequiresOpaqueOneTimeOption(t *testing.T) { h := NewWebHandler() issuedOptions := []ApprovalOption{ diff --git a/internal/remote/ssh.go b/internal/remote/ssh.go index 233ae612..591ce363 100644 --- a/internal/remote/ssh.go +++ b/internal/remote/ssh.go @@ -7,6 +7,7 @@ package remote import ( "context" "fmt" + "net" "os" "path/filepath" "strings" @@ -24,14 +25,24 @@ import ( // authentication falls back to the SSH agent + the default ~/.ssh keys (the same // behavior as tools.BuildSSHAuthMethods, used by the TUI). type SSHOptions struct { - Host string - Port int - User string - Password string // password auth - KeyPath string // explicit private key file (~ is expanded) - Passphrase string // passphrase for an encrypted private key + Host string + Port int + User string + Password string // password auth + KeyPath string // explicit private key file (~ is expanded) + Passphrase string // passphrase for an encrypted private key + AcceptHostKey bool // explicit TOFU confirmation + HostKeyFingerprint string // SHA256 fingerprint shown by the previous attempt } +type SSHHostKeyError = tools.SSHHostKeyError + +const ( + SSHHostKeyUnknown = tools.SSHHostKeyUnknown + SSHHostKeyChanged = tools.SSHHostKeyChanged + SSHHostKeyConfirmationMismatch = tools.SSHHostKeyConfirmationMismatch +) + // resolveTarget splits Host into a dial address ("host:port") and a username, // honoring an embedded "user@" prefix and an explicit Port. func resolveTarget(opts SSHOptions) (addr, user string) { @@ -47,8 +58,10 @@ func resolveTarget(opts SSHOptions) (addr, user string) { user = "root" } // Apply an explicit port only when the host doesn't already carry one. - if opts.Port > 0 && !strings.Contains(host, ":") { - host = fmt.Sprintf("%s:%d", host, opts.Port) + if opts.Port > 0 { + if _, _, err := net.SplitHostPort(host); err != nil { + host = net.JoinHostPort(strings.Trim(host, "[]"), fmt.Sprintf("%d", opts.Port)) + } } return host, user } @@ -92,12 +105,25 @@ func BuildAuthMethods(opts SSHOptions) ([]ssh.AuthMethod, error) { // Connect dials the remote host described by opts and returns a live executor. func Connect(opts SSHOptions) (*tools.SSHExecutor, error) { + return ConnectContext(context.Background(), opts) +} + +// ConnectContext dials the remote host with bounded cancellation and JCode's +// strict known_hosts/TOFU policy. +func ConnectContext(ctx context.Context, opts SSHOptions) (*tools.SSHExecutor, error) { addr, user := resolveTarget(opts) methods, err := BuildAuthMethods(opts) if err != nil { return nil, err } - return tools.NewSSHExecutor(addr, user, methods) + hostKeyCallback, err := tools.NewSSHHostKeyCallback(tools.SSHHostKeyPolicy{ + AcceptUnknown: opts.AcceptHostKey, + ExpectedFingerprint: opts.HostKeyFingerprint, + }) + if err != nil { + return nil, err + } + return tools.NewSSHExecutorContext(ctx, addr, user, methods, hostKeyCallback) } // DiscoverPwd returns the remote default working directory (best effort), @@ -106,7 +132,7 @@ func DiscoverPwd(ctx context.Context, exec tools.Executor, fallback string) stri if fallback == "" { fallback = "/root" } - if stdout, _, err := exec.Exec(ctx, "pwd", "", 5*time.Second); err == nil { + if stdout, _, err := tools.ExecReadOnly(ctx, exec, "pwd", "", 5*time.Second); err == nil { if trimmed := strings.TrimSpace(stdout); trimmed != "" { return trimmed } @@ -119,7 +145,7 @@ func DiscoverPwd(ctx context.Context, exec tools.Executor, fallback string) stri // can render an "up" entry in a directory picker. func ListDirs(ctx context.Context, exec tools.Executor, path string) ([]string, error) { cmd := fmt.Sprintf("ls -F -1 %s", tools.ShellQuote(path)) - stdout, stderr, err := exec.Exec(ctx, cmd, "", 10*time.Second) + stdout, stderr, err := tools.ExecReadOnly(ctx, exec, cmd, "", 10*time.Second) if err != nil { return nil, fmt.Errorf("ls %s failed: %v: %s", path, err, truncate(stderr, 100)) } diff --git a/internal/runner/remote_recovery_test.go b/internal/runner/remote_recovery_test.go new file mode 100644 index 00000000..c6fe93bd --- /dev/null +++ b/internal/runner/remote_recovery_test.go @@ -0,0 +1,288 @@ +package runner + +import ( + "context" + "errors" + "io" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cloudwego/eino/adk" + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + + internalhandler "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/session" + "github.com/cnjack/jcode/internal/tools" +) + +type oneRemoteToolModel struct { + modelCalls atomic.Int32 + toolName string +} + +func (m *oneRemoteToolModel) WithTools([]*schema.ToolInfo) (einomodel.ToolCallingChatModel, error) { + return m, nil +} + +func (*oneRemoteToolModel) Generate(context.Context, []*schema.Message, ...einomodel.Option) (*schema.Message, error) { + return nil, errors.New("Generate is not used: streaming is enabled") +} + +func (m *oneRemoteToolModel) Stream( + _ context.Context, + input []*schema.Message, + _ ...einomodel.Option, +) (*schema.StreamReader[*schema.Message], error) { + m.modelCalls.Add(1) + if len(input) > 0 && input[len(input)-1].Role == schema.Tool { + return schema.StreamReaderFromArray([]*schema.Message{{ + Role: schema.Assistant, Content: "continued after reconnect", + }}), nil + } + toolName := m.toolName + if toolName == "" { + toolName = "remote_operation" + } + arguments := "{}" + if toolName == "execute" { + arguments = `{"command":"mutate-once"}` + } + return schema.StreamReaderFromArray([]*schema.Message{{ + Role: schema.Assistant, + ToolCalls: []schema.ToolCall{{ + ID: "remote-call-1", + Function: schema.FunctionCall{ + Name: toolName, Arguments: arguments, + }, + }}, + }}), nil +} + +type outcomeUnknownTool struct { + invocations atomic.Int32 +} + +func (*outcomeUnknownTool) Info(context.Context) (*schema.ToolInfo, error) { + return remoteOperationInfo(), nil +} + +func (t *outcomeUnknownTool) InvokableRun(context.Context, string, ...tool.Option) (string, error) { + t.invocations.Add(1) + return "", tools.Fatal(&tools.RemoteTransportError{ + Kind: "ssh", Code: "ssh_transport_lost", + Phase: tools.RemoteTransportOutcomeUnknown, + Retryable: true, + Err: io.EOF, + }) +} + +type internallyRetriedReadTool struct { + invocations atomic.Int32 + transportAttempts atomic.Int32 +} + +type fatalExecuteExecutor struct { + tools.Executor + calls atomic.Int32 +} + +func (e *fatalExecuteExecutor) Exec( + context.Context, + string, + string, + time.Duration, +) (string, string, error) { + e.calls.Add(1) + return "possibly applied", "connection dropped", tools.Fatal(&tools.RemoteTransportError{ + Kind: "ssh", Code: "ssh_connection_failed", + Phase: tools.RemoteTransportOutcomeUnknown, + Retryable: true, + Err: io.EOF, + }) +} + +func (*internallyRetriedReadTool) Info(context.Context) (*schema.ToolInfo, error) { + return remoteOperationInfo(), nil +} + +func (t *internallyRetriedReadTool) InvokableRun(context.Context, string, ...tool.Option) (string, error) { + // This models the executor's bounded singleflight repair: the model-issued + // tool invocation remains one while the transport implementation makes a + // second safe read attempt after reconnecting. + t.invocations.Add(1) + t.transportAttempts.Add(2) + return "remote read succeeded", nil +} + +func remoteOperationInfo() *schema.ToolInfo { + return &schema.ToolInfo{ + Name: "remote_operation", + Desc: "test remote operation", + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{}), + } +} + +func remoteRecoveryAgent( + t *testing.T, + model einomodel.ToolCallingChatModel, + remoteTool tool.BaseTool, +) *adk.ChatModelAgent { + t.Helper() + ag, err := adk.NewChatModelAgent(context.Background(), &adk.ChatModelAgentConfig{ + Name: "remote-recovery-test", Description: "remote-recovery-test", + Instruction: "test", Model: model, + ToolsConfig: adk.ToolsConfig{ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{remoteTool}, + }}, + MaxIterations: 5, + }) + if err != nil { + t.Fatalf("create agent: %v", err) + } + return ag +} + +func TestRunRemoteOutcomeUnknownIsNotModelErrorOrReplayed(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + model := &oneRemoteToolModel{} + remoteTool := &outcomeUnknownTool{} + h := &resultCaptureHandler{} + + result := Run( + context.Background(), remoteRecoveryAgent(t, model, remoteTool), + []adk.Message{schema.UserMessage("run once")}, h, + nil, nil, nil, nil, nil, + ) + if result.Err == nil { + t.Fatal("RunResult.Err = nil, want remote transport failure") + } + var remoteErr *tools.RemoteTransportError + if !errors.As(result.Err, &remoteErr) { + t.Fatalf("RunResult.Err = %T %v, want RemoteTransportError in chain", result.Err, result.Err) + } + if remoteErr.Phase != tools.RemoteTransportOutcomeUnknown { + t.Fatalf("remote phase = %q", remoteErr.Phase) + } + if got := remoteTool.invocations.Load(); got != 1 { + t.Fatalf("tool invocations = %d, want exactly one", got) + } + if got := model.modelCalls.Load(); got != 1 { + t.Fatalf("model calls = %d, want no graph replay after uncertain mutation", got) + } + if strings.Contains(strings.ToLower(result.Err.Error()), "model") || + strings.Contains(result.Err.Error(), "Could not reach") { + t.Fatalf("remote failure was mislabelled as model failure: %v", result.Err) + } + if !strings.Contains(result.Err.Error(), "did not replay") { + t.Fatalf("remote failure lacks no-replay guidance: %v", result.Err) + } + if len(result.Messages) != 2 || result.Messages[1].Role != schema.Tool || + result.Messages[1].Content != session.InterruptedToolOutput { + t.Fatalf("result messages = %#v, want paired interrupted tool result", result.Messages) + } +} + +func TestRunContinuesAfterExecutorInternalSafeRetry(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + model := &oneRemoteToolModel{} + remoteTool := &internallyRetriedReadTool{} + h := &resultCaptureHandler{} + + result := Run( + context.Background(), remoteRecoveryAgent(t, model, remoteTool), + []adk.Message{schema.UserMessage("read")}, h, + nil, nil, nil, nil, nil, + ) + if result.Err != nil { + t.Fatalf("RunResult.Err = %v", result.Err) + } + if got := remoteTool.invocations.Load(); got != 1 { + t.Fatalf("model-issued tool invocations = %d, want one", got) + } + if got := remoteTool.transportAttempts.Load(); got != 2 { + t.Fatalf("transport attempts = %d, want executor-local retry", got) + } + if got := model.modelCalls.Load(); got != 2 { + t.Fatalf("model calls = %d, want initial tool call then continuation", got) + } + if !strings.Contains(result.Response, "continued after reconnect") { + t.Fatalf("response = %q", result.Response) + } +} + +func TestRunRemoteOutcomeUnknownEmitsStructuredWebDone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + model := &oneRemoteToolModel{} + remoteTool := &outcomeUnknownTool{} + h := internalhandler.NewWebHandler() + + result := Run( + context.Background(), remoteRecoveryAgent(t, model, remoteTool), + []adk.Message{schema.UserMessage("run once")}, h, + nil, nil, nil, nil, nil, + ) + if result.Err == nil { + t.Fatal("RunResult.Err = nil, want remote transport failure") + } + + timeout := time.NewTimer(time.Second) + defer timeout.Stop() + for { + select { + case event := <-h.Events(): + if event.Event != "agent_done" { + continue + } + data, ok := event.Data.(internalhandler.WebDoneData) + if !ok { + t.Fatalf("agent_done data = %T", event.Data) + } + if data.ErrorKind != "remote_connection" || data.Code != "ssh_transport_lost" || + data.Kind != "ssh" || data.Phase != string(tools.RemoteTransportOutcomeUnknown) { + t.Fatalf("agent_done = %+v", data) + } + if data.Retryable == nil || *data.Retryable { + t.Fatalf("agent_done retryable = %v, want false for dispatched operation", data.Retryable) + } + if strings.Contains(strings.ToLower(data.Error), "model") { + t.Fatalf("structured remote error was relabelled as a model failure: %q", data.Error) + } + return + case <-timeout.C: + t.Fatal("timed out waiting for structured agent_done") + } + } +} + +func TestRunExecuteToolPropagatesRemoteFatalWithoutReplay(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + model := &oneRemoteToolModel{toolName: "execute"} + env := tools.NewEnv(t.TempDir(), "linux/amd64") + executor := &fatalExecuteExecutor{Executor: env.Exec} + env.Exec = executor + h := &resultCaptureHandler{} + + result := Run( + context.Background(), remoteRecoveryAgent(t, model, env.NewExecuteTool(nil)), + []adk.Message{schema.UserMessage("run once")}, h, + nil, nil, nil, nil, nil, + ) + var remoteErr *tools.RemoteTransportError + if !errors.As(result.Err, &remoteErr) { + t.Fatalf("RunResult.Err = %T %v, want RemoteTransportError", result.Err, result.Err) + } + if got := executor.calls.Load(); got != 1 { + t.Fatalf("execute calls = %d, want exactly one", got) + } + if got := model.modelCalls.Load(); got != 1 { + t.Fatalf("model calls = %d, want no model retry after uncertain execute", got) + } + if len(result.Messages) != 2 || result.Messages[1].Content != session.InterruptedToolOutput { + t.Fatalf("result messages = %#v, want interrupted result instead of partial execute output", result.Messages) + } +} diff --git a/internal/runner/run_error.go b/internal/runner/run_error.go new file mode 100644 index 00000000..c82ac43b --- /dev/null +++ b/internal/runner/run_error.go @@ -0,0 +1,78 @@ +package runner + +import ( + "errors" + "fmt" + "strings" + + internalmodel "github.com/cnjack/jcode/internal/model" + "github.com/cnjack/jcode/internal/tools" +) + +// runDisplayError replaces infrastructure/framework prose with a sentence for +// the user while retaining the complete typed error chain for policy and +// diagnostics. It is deliberately separate from model.FriendlyError: a remote +// executor failure is not a failed request to the model. +type runDisplayError struct { + err error + message string +} + +func (e *runDisplayError) Error() string { return e.message } +func (e *runDisplayError) Unwrap() error { return e.err } + +func wrapRunError(err error, provider, modelName string) error { + if err == nil { + return nil + } + var displayed *runDisplayError + if errors.As(err, &displayed) { + return err + } + var remoteErr *tools.RemoteTransportError + if errors.As(err, &remoteErr) { + return &runDisplayError{err: err, message: remoteTransportMessage(remoteErr)} + } + return internalmodel.WrapFriendly(err, provider, modelName) +} + +// FormatRunError returns the already-classified run error text when runner.Run +// handled it, and applies the model formatter only to a raw error from a legacy +// caller. ACP uses this instead of unconditionally labelling every turn error +// as an API/model failure. +func FormatRunError(err error, provider, modelName string) string { + if err == nil { + return "" + } + var displayed *runDisplayError + var modelDisplay *internalmodel.FriendlyError + if errors.As(err, &displayed) || errors.As(err, &modelDisplay) { + return err.Error() + } + var remoteErr *tools.RemoteTransportError + if errors.As(err, &remoteErr) { + return remoteTransportMessage(remoteErr) + } + return internalmodel.FriendlyAPIError(err, provider, modelName) +} + +func remoteTransportMessage(err *tools.RemoteTransportError) string { + kind := "Remote" + if err != nil && strings.TrimSpace(err.Kind) != "" { + kind = strings.ToUpper(strings.TrimSpace(err.Kind)) + } + if err != nil && err.Phase == tools.RemoteTransportOutcomeUnknown { + message := fmt.Sprintf( + "%s connection was lost after the remote operation started. JCode did not replay the operation because it may already have completed.", + kind, + ) + if err.ReconnectErr != nil { + return message + " The connection could not be restored; reconnect and inspect the remote workspace before continuing." + } + return message + " The connection was restored; inspect the remote workspace before continuing." + } + return fmt.Sprintf( + "%s connection was lost before the remote operation started and could not be restored. The operation was not run; reconnect and try again.", + kind, + ) +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 112e6be3..e7f39f22 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -502,7 +502,7 @@ func runInner( // so wrapping here fixes the display in the TUI, the web UI and ACP // at once — and stops the next frontend from having to remember. config.Logger().Printf("[runner] event error: %v", event.Err) - runErr := internalmodel.WrapFriendly(event.Err, "", "") + runErr := wrapRunError(event.Err, "", "") if persistErr := drainDanglingToolResults(); persistErr != nil { runErr = errors.Join(runErr, persistErr) } @@ -666,7 +666,7 @@ func runInner( return finish(true, runErr) } config.Logger().Printf("[runner] assistant stream error: %v", streamErr) - runErr := internalmodel.WrapFriendly(streamErr, "", "") + runErr := wrapRunError(streamErr, "", "") if persistErr := drainDanglingToolResults(); persistErr != nil { runErr = errors.Join(runErr, persistErr) } diff --git a/internal/session/lastsession.go b/internal/session/lastsession.go index cf34be5d..bffafb3c 100644 --- a/internal/session/lastsession.go +++ b/internal/session/lastsession.go @@ -12,7 +12,9 @@ import ( // recently foregrounded session per project, so a web/desktop client can // return to the conversation that was open before a restart. type lastSessionFile struct { - Projects map[string]string `json:"projects"` // project path → session uuid + Projects map[string]string `json:"projects"` // project path → session uuid + RecentProject string `json:"recent_project,omitempty"` // last foregrounded project across workspaces + RecentSession string `json:"recent_session,omitempty"` // last foregrounded session across workspaces } func lastSessionPath() (string, error) { @@ -44,10 +46,12 @@ func SaveLastSession(project, id string) { if f.Projects == nil { f.Projects = map[string]string{} } - if f.Projects[project] == id { + if f.Projects[project] == id && f.RecentProject == project && f.RecentSession == id { return } f.Projects[project] = id + f.RecentProject = project + f.RecentSession = id if err := ensurePrivateSessionDir(filepath.Dir(p)); err != nil { return @@ -85,7 +89,68 @@ func LoadLastSession(project string) string { if err := json.Unmarshal(data, &f); err != nil { return "" } - id := f.Projects[project] + return validateLastSessionID(f.Projects[project]) +} + +// LoadMostRecentSession returns the last foregrounded project/session across +// all workspaces. Desktop uses this on a cold start because its sidecar always +// boots from a local directory and therefore cannot infer that the previously +// focused workspace was SSH or Docker. Project-scoped web startup continues to +// use LoadLastSession. +func LoadMostRecentSession() (project, id string) { + p, err := lastSessionPath() + if err != nil { + return "", "" + } + data, err := os.ReadFile(p) + if err != nil { + return "", "" + } + var f lastSessionFile + if err := json.Unmarshal(data, &f); err != nil { + return "", "" + } + // One-time compatibility for last_session.json files written before the + // global pointer existed: choose the most recently written transcript among + // the already-recorded per-project foreground entries. The next focus saves + // an exact recent pointer, so this approximation is not used again. + if f.RecentProject == "" || f.RecentSession == "" { + return inferMostRecentLegacySession(f.Projects) + } + id = validateLastSessionID(f.RecentSession) + if id == "" { + return "", "" + } + return f.RecentProject, id +} + +func inferMostRecentLegacySession(projects map[string]string) (project, id string) { + dir, err := config.SessionsDir() + if err != nil { + return "", "" + } + var ( + latest int64 + have bool + ) + for candidateProject, candidateID := range projects { + if ValidateSessionID(candidateID) != nil { + continue + } + info, statErr := os.Stat(filepath.Join(dir, candidateID+".json")) + if statErr != nil { + continue + } + stamp := info.ModTime().UnixNano() + if have && stamp <= latest { + continue + } + project, id, latest, have = candidateProject, candidateID, stamp, true + } + return project, id +} + +func validateLastSessionID(id string) string { if id == "" || ValidateSessionID(id) != nil { return "" } diff --git a/internal/session/lastsession_test.go b/internal/session/lastsession_test.go index eea1c254..857e55ea 100644 --- a/internal/session/lastsession_test.go +++ b/internal/session/lastsession_test.go @@ -1,9 +1,11 @@ package session import ( + "encoding/json" "os" "path/filepath" "testing" + "time" "github.com/cnjack/jcode/internal/config" ) @@ -41,6 +43,9 @@ func TestLastSessionRoundTrip(t *testing.T) { if got := LoadLastSession("/proj/b"); got != "22222222-2222-2222-2222-222222222222" { t.Fatalf("proj/b: got %q", got) } + if project, id := LoadMostRecentSession(); project != "/proj/b" || id != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("recent = (%q, %q), want project b", project, id) + } if got := LoadLastSession("/proj/never-saved"); got != "" { t.Fatalf("unknown project: expected empty, got %q", got) } @@ -50,6 +55,74 @@ func TestLastSessionRoundTrip(t *testing.T) { if got := LoadLastSession("/proj/a"); got != "22222222-2222-2222-2222-222222222222" { t.Fatalf("proj/a after overwrite: got %q", got) } + if project, id := LoadMostRecentSession(); project != "/proj/a" || id != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("recent after switch-back = (%q, %q), want project a", project, id) + } +} + +func TestMostRecentSessionTracksSwitchBackToExistingProjectEntry(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir, err := config.SessionsDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + const first = "11111111-1111-1111-1111-111111111111" + const second = "22222222-2222-2222-2222-222222222222" + for _, id := range []string{first, second} { + if err := os.WriteFile(filepath.Join(dir, id+".json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + SaveLastSession("ssh://root@example.test:22/work", first) + SaveLastSession("/local/work", second) + // The project mapping already points to first. Saving it again must still + // advance the global recent pointer back to the remote workspace. + SaveLastSession("ssh://root@example.test:22/work", first) + project, id := LoadMostRecentSession() + if project != "ssh://root@example.test:22/work" || id != first { + t.Fatalf("recent = (%q, %q), want remote switch-back", project, id) + } +} + +func TestMostRecentSessionMigratesLegacyPerProjectFile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir, err := config.SessionsDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + const older = "11111111-1111-1111-1111-111111111111" + const newer = "22222222-2222-2222-2222-222222222222" + oldTime := time.Unix(100, 0) + newTime := time.Unix(200, 0) + for id, stamp := range map[string]time.Time{older: oldTime, newer: newTime} { + path := filepath.Join(dir, id+".json") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + } + legacy, err := json.Marshal(lastSessionFile{Projects: map[string]string{ + "ssh://root@example.test:22/work": older, + "docker://builder/work": newer, + }}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "last_session.json"), legacy, 0o600); err != nil { + t.Fatal(err) + } + project, id := LoadMostRecentSession() + if project != "docker://builder/work" || id != newer { + t.Fatalf("legacy recent = (%q, %q), want newest Docker session", project, id) + } } // TestLastSessionSkipsStaleIDs: a recorded id whose session file disappeared @@ -61,6 +134,9 @@ func TestLastSessionSkipsStaleIDs(t *testing.T) { if got := LoadLastSession("/proj/a"); got != "" { t.Fatalf("stale id: expected empty, got %q", got) } + if project, id := LoadMostRecentSession(); project != "" || id != "" { + t.Fatalf("stale recent: got (%q, %q)", project, id) + } } // TestLastSessionRejectsBadInput: empty/unsafe values are no-ops, not errors. diff --git a/internal/session/session.go b/internal/session/session.go index 5279145f..99a53abb 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -670,25 +670,37 @@ func (r *Recorder) UUID() string { // Project returns the workspace path this recorder is scoped to. func (r *Recorder) Project() string { return r.project } -// Provider returns the provider the session was opened with. -func (r *Recorder) Provider() string { return r.provider } +// Provider returns the provider currently attributed to recorded usage. +func (r *Recorder) Provider() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.provider +} -// Model returns the model currently attributed to recorded usage. It is the -// model the session was opened with unless SetModel updated it after a switch. +// Model returns the model currently attributed to recorded usage. func (r *Recorder) Model() string { r.mu.Lock() defer r.mu.Unlock() return r.model } -// SetModel updates the model attributed to subsequently recorded usage so a -// mid-session model switch attributes new turns to the new model rather than -// the one the session was opened with. The session-start header is unchanged -// (it records the opening model). -func (r *Recorder) SetModel(model string) { +// SetProviderModel updates the inseparable provider/model pair attributed to +// subsequently recorded usage. Before the first entry it also determines the +// session_start pair; afterwards the immutable opening header is retained while +// the session index follows the current pair. +func (r *Recorder) SetProviderModel(provider, model string) { r.mu.Lock() + r.provider = provider r.model = model + hasRecording := r.file != nil + id := r.uuid r.mu.Unlock() + if hasRecording { + _, _ = UpdateSessionMeta(id, func(m *SessionMeta) { + m.Provider = provider + m.Model = model + }) + } } // SetAgent records the selected top-level custom agent. Empty means the default @@ -1840,6 +1852,35 @@ func ListAllSessions() (map[string][]SessionMeta, error) { return idx.Sessions, nil } +// FindSessionMeta returns the indexed metadata for uuid regardless of which +// project owns it. The project is populated from the index key because older +// index entries may omit the redundant Project field. A nil result means the +// UUID is not indexed. +func FindSessionMeta(uuid string) (*SessionMeta, error) { + if err := ValidateSessionID(uuid); err != nil { + return nil, err + } + all, err := ListAllSessions() + if err != nil { + return nil, err + } + var found *SessionMeta + for project, metas := range all { + for i := range metas { + if metas[i].UUID != uuid { + continue + } + if found != nil { + return nil, fmt.Errorf("session %s is indexed under multiple projects", uuid) + } + meta := metas[i] + meta.Project = project + found = &meta + } + } + return found, nil +} + // ListProjectMeta returns the per-project metadata (last-activity timestamps) // keyed by project path. A nil map (legacy install: no projects.json yet) is // returned as-is; callers fall back to deriving recency from sessions. diff --git a/internal/session/session_model_test.go b/internal/session/session_model_test.go new file mode 100644 index 00000000..19766613 --- /dev/null +++ b/internal/session/session_model_test.go @@ -0,0 +1,31 @@ +package session + +import "testing" + +func TestRecorderSetProviderModelKeepsPairTogether(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + recorder, err := NewRecorder("/workspace", "old-provider", "old-model") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { recorder.Close() }) + + recorder.SetProviderModel("xai", "grok-4.5") + recorder.RecordUser("first turn") + entries, err := LoadSession(recorder.UUID()) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 || entries[0].Provider != "xai" || entries[0].Model != "grok-4.5" { + t.Fatalf("session_start pair = %s/%s, want xai/grok-4.5", entries[0].Provider, entries[0].Model) + } + + recorder.SetProviderModel("github-copilot", "gpt-4.1") + meta, err := FindSessionMeta(recorder.UUID()) + if err != nil { + t.Fatal(err) + } + if meta == nil || meta.Provider != "github-copilot" || meta.Model != "gpt-4.1" { + t.Fatalf("current index pair = %+v, want github-copilot/gpt-4.1", meta) + } +} diff --git a/internal/tools/docker_exec.go b/internal/tools/docker_exec.go index 12c9f0d7..22c8a4da 100644 --- a/internal/tools/docker_exec.go +++ b/internal/tools/docker_exec.go @@ -90,6 +90,7 @@ type DockerExecutor struct { name string // display name without the leading slash platform string startedByUs bool + closeOnce sync.Once } // AcquireDockerContainer binds to a container by id or name. A1 semantics: a @@ -180,12 +181,30 @@ func waitRunning(ctx context.Context, cli *client.Client, id string) error { } func (d *DockerExecutor) Close() error { - if d.startedByUs { - dockerReleaseRef(d.containerID, true) - } + d.closeOnce.Do(func() { + if d.startedByUs { + dockerReleaseRef(d.containerID, true) + } + }) return nil // never close the shared client } +// Probe verifies both Docker daemon reachability and that the bound container +// remains running. The short local deadline is applied even when a caller +// accidentally supplies an unbounded context. +func (d *DockerExecutor) Probe(ctx context.Context) error { + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + info, err := d.cli.ContainerInspect(probeCtx, d.containerID) + if err != nil { + return wrapDockerRunErr("docker probe", err) + } + if info.State == nil || !info.State.Running { + return fmt.Errorf("docker probe: container %q is not running", d.name) + } + return nil +} + // ContainerID exposes the bound container id (used by the terminal backend). func (d *DockerExecutor) ContainerID() string { return d.containerID } diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 95e0a5f9..0d6f7e91 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -153,7 +153,10 @@ func (e *editTool) createFile(ctx context.Context, input EditInput) (string, err return "", fmt.Errorf("new_string is required when creating a file") } - fi, _ := e.env.Exec.Stat(ctx, input.FilePath) + fi, err := e.env.Exec.Stat(ctx, input.FilePath) + if err != nil { + return "", fmt.Errorf("failed to inspect file %s before creating: %w", input.FilePath, err) + } if fi != nil && fi.Exists { return "", fmt.Errorf("file %s already exists. Use old_string to edit existing files, or delete the file first", input.FilePath) } diff --git a/internal/tools/env.go b/internal/tools/env.go index 663225cb..d018b83d 100644 --- a/internal/tools/env.go +++ b/internal/tools/env.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net" "os" "os/exec" "path/filepath" @@ -282,10 +283,20 @@ func (e *Env) IsRemote() bool { // stable, scheme-qualified session key. type RemoteExecutor interface { Executor + // Probe verifies that the bound transport/target is still usable. Every + // implementation must honor ctx and apply its own short upper bound. + Probe(ctx context.Context) error Close() error ProjectLabel(pwd string) string } +// RemoteLeaseCloner is implemented by transports that can safely share their +// underlying connection while giving each Engine independent Close ownership. +// Callers must never share the same RemoteExecutor pointer across engines. +type RemoteLeaseCloner interface { + CloneLease() (RemoteExecutor, error) +} + // Executor abstracts file and command operations so tools can work // transparently on both local and remote (SSH) machines. type Executor interface { @@ -466,39 +477,127 @@ func (l *LocalExecutor) Label() string { return "local" } // --------------------------------------------------------------------------- type SSHExecutor struct { - client *ssh.Client - host string - user string - platform string + transport *sshTransport + host string + user string + platform string + leaseID uint64 + releaseOnce sync.Once + releaseErr error } -// NewSSHExecutor connects to a remote host and returns an executor. -// It tries the SSH agent first, then common key paths. +// sshTransport owns one encrypted TCP transport and its keepalive loop. Every +// SSHExecutor is a lease; sessions/channels are safe to create concurrently on +// ssh.Client, while ref-counting prevents one Engine teardown from closing the +// connection underneath another Engine. +type sshTransport struct { + client *ssh.Client + clientGeneration uint64 + user string + host string + dial func(context.Context) (*ssh.Client, error) + backoff func(int) time.Duration + lifetimeCtx context.Context + lifetimeCancel context.CancelFunc + keepaliveStop chan struct{} + + mu sync.Mutex + refs int + nextLeaseID uint64 + observers map[uint64]RemoteConnectionStatusHandler + closed bool + closeErr error + reconnecting bool + reconnectDone chan struct{} + reconnectErr error + reconnectCause error + reconnectCancel context.CancelFunc + reconnectWaiters int +} + +const ( + sshDialTimeout = 10 * time.Second + sshProbeTimeout = 5 * time.Second + sshFailureProbeTimeout = time.Second + sshSessionOpenTimeout = 5 * time.Second + sshKeepaliveEvery = 25 * time.Second + sshReconnectAttemptTimeout = 5 * time.Second + sshReconnectTotalTimeout = 65 * time.Second + sshReconnectMaxAttempts = 8 + sshReconnectInitialBackoff = 250 * time.Millisecond + sshReconnectMaxBackoff = 4 * time.Second + sshOperationMaxAttempts = 3 + sshKeepaliveRetryEvery = time.Minute +) + +// NewSSHExecutor connects with JCode's strict ~/.jcode/known_hosts policy. +// It is retained for TUI/tool compatibility; web callers use +// NewSSHExecutorContext so request cancellation is propagated. func NewSSHExecutor(addr, user string, authMethods []ssh.AuthMethod) (*SSHExecutor, error) { + hostKeyCallback, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{}) + if err != nil { + return nil, err + } + return NewSSHExecutorContext(context.Background(), addr, user, authMethods, hostKeyCallback) +} + +// NewSSHExecutorContext connects to a remote host using the supplied strict +// host-key callback. TCP dial and SSH handshake share a ten-second upper bound. +func NewSSHExecutorContext( + ctx context.Context, + addr, user string, + authMethods []ssh.AuthMethod, + hostKeyCallback ssh.HostKeyCallback, +) (*SSHExecutor, error) { + if hostKeyCallback == nil { + return nil, fmt.Errorf("SSH host-key callback is required") + } + normalizedAddr, err := normalizeSSHAddress(addr) + if err != nil { + return nil, err + } config := &ssh.ClientConfig{ User: user, Auth: authMethods, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, + HostKeyCallback: hostKeyCallback, + Timeout: sshDialTimeout, } - // Ensure addr includes port - if !strings.Contains(addr, ":") { - addr += ":22" + client, err := dialSSHClient(ctx, normalizedAddr, user, config, sshDialTimeout) + if err != nil { + return nil, err + } + lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) + dial := func(dialCtx context.Context) (*ssh.Client, error) { + return dialSSHClient(dialCtx, normalizedAddr, user, config, sshReconnectAttemptTimeout) } - appconfig.Logger().Printf("[ssh] dial tcp %s@%s", user, addr) - start := time.Now() - client, err := ssh.Dial("tcp", addr, config) - if err != nil { - appconfig.Logger().Printf("[ssh] dial failed after %v: %v", time.Since(start), err) - return nil, fmt.Errorf("ssh dial %s@%s: %w", user, addr, err) + executor := &SSHExecutor{ + transport: &sshTransport{ + client: client, + clientGeneration: 1, + user: user, + host: normalizedAddr, + dial: dial, + backoff: sshReconnectBackoff, + lifetimeCtx: lifetimeCtx, + lifetimeCancel: lifetimeCancel, + keepaliveStop: make(chan struct{}), + refs: 1, + nextLeaseID: 1, + observers: make(map[uint64]RemoteConnectionStatusHandler), + }, + host: normalizedAddr, + user: user, + platform: "linux/amd64", + leaseID: 1, } - appconfig.Logger().Printf("[ssh] dial success %s@%s in %v", user, addr, time.Since(start)) // Detect remote platform - platform := "linux/amd64" - if out, _, err := sshExecSimple(client, "uname -sm"); err == nil { + platformCtx, platformCancel := context.WithTimeout(ctx, sshProbeTimeout) + if out, _, platformErr := executor.runWithRetry( + platformCtx, "uname -sm", "", sshProbeTimeout, true, + ); platformErr == nil { parts := strings.Fields(strings.TrimSpace(out)) if len(parts) == 2 { os := strings.ToLower(parts[0]) @@ -509,31 +608,108 @@ func NewSSHExecutor(addr, user string, authMethods []ssh.AuthMethod) (*SSHExecut case "aarch64": arch = "arm64" } - platform = os + "/" + arch + executor.platform = os + "/" + arch } } + platformCancel() + if !executor.transport.isOpen() { + return nil, fmt.Errorf("SSH transport %s@%s became unavailable during initialization", user, normalizedAddr) + } + + go executor.transport.keepaliveLoop() + return executor, nil +} + +func dialSSHClient( + ctx context.Context, + addr, user string, + config *ssh.ClientConfig, + timeout time.Duration, +) (*ssh.Client, error) { + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + appconfig.Logger().Printf("[ssh] dial tcp %s@%s", user, addr) + start := time.Now() + netConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr) + if err != nil { + appconfig.Logger().Printf("[ssh] dial failed after %v: %v", time.Since(start), err) + return nil, fmt.Errorf("ssh dial %s@%s: %w", user, addr, err) + } + deadline, _ := dialCtx.Deadline() + if err := netConn.SetDeadline(deadline); err != nil { + _ = netConn.Close() + return nil, fmt.Errorf("ssh handshake deadline %s@%s: %w", user, addr, err) + } + clientConn, channels, requests, err := ssh.NewClientConn(netConn, addr, config) + if err != nil { + _ = netConn.Close() + appconfig.Logger().Printf("[ssh] handshake failed after %v: %v", time.Since(start), err) + return nil, fmt.Errorf("ssh handshake %s@%s: %w", user, addr, err) + } + if err := netConn.SetDeadline(time.Time{}); err != nil { + _ = clientConn.Close() + return nil, fmt.Errorf("ssh clear handshake deadline %s@%s: %w", user, addr, err) + } + client := ssh.NewClient(clientConn, channels, requests) + appconfig.Logger().Printf("[ssh] dial success %s@%s in %v", user, addr, time.Since(start)) + return client, nil +} +func (s *SSHExecutor) Close() error { + s.releaseOnce.Do(func() { + if s.transport != nil { + s.releaseErr = s.transport.release(s.leaseID) + } + }) + return s.releaseErr +} + +// CloneLease gives another Engine independent ownership of the same healthy +// SSH transport. The clone opens its own SSH channels but Close only decrements +// the shared ref-count. +func (s *SSHExecutor) CloneLease() (RemoteExecutor, error) { + if s.transport == nil { + return nil, fmt.Errorf("SSH transport %s@%s is closed", s.user, s.host) + } + leaseID, ok := s.transport.retain() + if !ok { + return nil, fmt.Errorf("SSH transport %s@%s is closed", s.user, s.host) + } return &SSHExecutor{ - client: client, - host: addr, - user: user, - platform: platform, + transport: s.transport, + host: s.host, + user: s.user, + platform: s.platform, + leaseID: leaseID, }, nil } -func (s *SSHExecutor) Close() error { - if s.client != nil { - return s.client.Close() +// SetRemoteConnectionStatusHandler binds an observer to this executor lease. +// Replacing or closing one Engine's lease cannot remove another Engine's +// observer even though both share the same SSH transport. +func (s *SSHExecutor) SetRemoteConnectionStatusHandler(handler RemoteConnectionStatusHandler) { + if s.transport != nil { + s.transport.setObserver(s.leaseID, handler) } - return nil +} + +// Probe performs a bounded transport round trip. OpenSSH servers commonly +// reply false to this request; a reply of either polarity proves the encrypted +// transport is alive. A timeout closes the client to unblock the pending SSH +// request and make subsequent activation reconnect instead of reusing a zombie. +func (s *SSHExecutor) Probe(ctx context.Context) error { + if s.transport == nil { + return fmt.Errorf("ssh probe: missing transport") + } + return s.transport.probe(ctx) } func (s *SSHExecutor) ReadFile(ctx context.Context, path string) ([]byte, error) { - out, serr, err := s.run(ctx, fmt.Sprintf("cat %s", ShellQuote(path)), "", 30*time.Second) + out, serr, err := s.runWithRetry(ctx, fmt.Sprintf("cat %s", ShellQuote(path)), "", 30*time.Second, true) if err != nil { detail := strings.TrimSpace(serr) if detail != "" { - return nil, fmt.Errorf("%s", detail) + return nil, fmt.Errorf("%s: %w", detail, err) } return nil, err } @@ -543,14 +719,14 @@ func (s *SSHExecutor) ReadFile(ctx context.Context, path string) ([]byte, error) func (s *SSHExecutor) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error { // Create parent dirs, then write via stdin mkdirCmd := fmt.Sprintf("mkdir -p %s", ShellQuote(filepath.Dir(path))) - if _, _, err := s.run(ctx, mkdirCmd, "", 10*time.Second); err != nil { + if _, _, err := s.runWithRetry(ctx, mkdirCmd, "", 10*time.Second, false); err != nil { return fmt.Errorf("mkdir failed: %w", err) } // Use cat with heredoc-style write. Encode data as base64 to avoid shell escaping issues. encoded := base64Encode(data) writeCmd := sshAtomicWriteCmd(encoded, path, perm) - if _, serr, err := s.run(ctx, writeCmd, "", 30*time.Second); err != nil { + if _, serr, err := s.runWithRetry(ctx, writeCmd, "", 30*time.Second, false); err != nil { return fmt.Errorf("write failed: %s %w", serr, err) } return nil @@ -566,7 +742,9 @@ func sshAtomicWriteCmd(encoded, path string, perm os.FileMode) string { } func (s *SSHExecutor) MkdirAll(ctx context.Context, path string, _ os.FileMode) error { - _, serr, err := s.run(ctx, fmt.Sprintf("mkdir -p %s", ShellQuote(path)), "", 10*time.Second) + _, serr, err := s.runWithRetry( + ctx, fmt.Sprintf("mkdir -p %s", ShellQuote(path)), "", 10*time.Second, false, + ) if err != nil { return fmt.Errorf("mkdir -p failed: %s %w", serr, err) } @@ -575,10 +753,10 @@ func (s *SSHExecutor) MkdirAll(ctx context.Context, path string, _ os.FileMode) func (s *SSHExecutor) Stat(ctx context.Context, path string) (*FileInfo, error) { // Use test command for existence and type checks - out, _, err := s.run(ctx, fmt.Sprintf( + out, _, err := s.runWithRetry(ctx, fmt.Sprintf( `if [ -e %s ]; then if [ -d %s ]; then echo "dir"; else echo "file"; fi; else echo "none"; fi`, ShellQuote(path), ShellQuote(path), - ), "", 5*time.Second) + ), "", 5*time.Second, true) if err != nil { return nil, err } @@ -594,13 +772,33 @@ func (s *SSHExecutor) Stat(ctx context.Context, path string) (*FileInfo, error) } func (s *SSHExecutor) Exec(ctx context.Context, command, workDir string, timeout time.Duration) (string, string, error) { + return s.exec(ctx, command, workDir, timeout, false) +} + +// ExecReadOnly explicitly allows replay after a post-dispatch transport loss. +// Only built-in read/grep/glob/discovery callers use this method; arbitrary +// execute tool input always goes through Exec and is never replayed. +func (s *SSHExecutor) ExecReadOnly( + ctx context.Context, + command, workDir string, + timeout time.Duration, +) (string, string, error) { + return s.exec(ctx, command, workDir, timeout, true) +} + +func (s *SSHExecutor) exec( + ctx context.Context, + command, workDir string, + timeout time.Duration, + readOnly bool, +) (string, string, error) { // Prepend environment variables to disable pagers/editors/prompts on remote. envPrefix := "export GIT_TERMINAL_PROMPT=0 GIT_PAGER=cat PAGER=cat GIT_EDITOR=true; " fullCmd := envPrefix + command if workDir != "" { fullCmd = fmt.Sprintf("cd %s && %s", ShellQuote(workDir), envPrefix+command) } - return s.run(ctx, fullCmd, "", timeout) + return s.runWithRetry(ctx, fullCmd, "", timeout, readOnly) } func (s *SSHExecutor) Platform() string { return s.platform } @@ -621,55 +819,203 @@ func (s *SSHExecutor) ProjectLabel(pwd string) string { return fmt.Sprintf("ssh://%s@%s%s", s.user, s.host, normalizeAbs(pwd)) } -// isSSHConnDead reports whether err means the underlying SSH connection is -// permanently gone (EOF or a closed network connection), as opposed to a -// per-command failure. Deliberately narrow (#16): command exit errors and -// timeouts stay retryable. +// isSSHConnDead reports whether err means the current SSH client generation is +// unusable (EOF or a closed network connection), as opposed to a per-command +// failure. The lease-owning transport may replace that generation by redialing. func isSSHConnDead(err error) bool { if err == nil { return false } - if errors.Is(err, io.EOF) { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe) { return true } - return strings.Contains(err.Error(), "use of closed network connection") + lower := strings.ToLower(err.Error()) + return strings.Contains(lower, "use of closed network connection") || + strings.Contains(lower, "connection reset by peer") || + strings.Contains(lower, "broken pipe") || + strings.Contains(lower, "connection lost") || + strings.Contains(lower, "ssh: disconnect") } -// run executes a command over SSH, respecting both the context and timeout. -func (s *SSHExecutor) run(ctx context.Context, command, _ string, timeout time.Duration) (string, string, error) { - session, err := s.client.NewSession() - if err != nil { - // A dead connection makes every future tool call fail identically: - // mark it Fatal so the run aborts instead of burning iterations. - // All SSHExecutor methods (Exec/ReadFile/WriteFile/Stat/MkdirAll) - // funnel through run(), so this covers the whole surface. - wrapped := fmt.Errorf("ssh session: %w", err) - if isSSHConnDead(err) { - return "", "", Fatal(wrapped) +// runWithRetry separates transport repair from operation replay. Channel-open +// failures are known to be before dispatch and are safe to retry for every +// operation. Once session.Run has been called, only explicitly read-only +// operations may be replayed; all others return a Fatal outcome-unknown error. +func (s *SSHExecutor) runWithRetry( + ctx context.Context, + command, _ string, + timeout time.Duration, + readOnly bool, +) (string, string, error) { + var lastTransportErr *RemoteTransportError + for attempt := 1; attempt <= sshOperationMaxAttempts; attempt++ { + client, generation, err := s.transport.connectedClient(ctx, lastTransportErr) + if err != nil { + if ctx.Err() != nil { + return "", "", ctx.Err() + } + code, retryable := classifySSHReconnectError(err) + return "", "", Fatal(&RemoteTransportError{ + Kind: "ssh", Code: code, Phase: RemoteTransportBeforeDispatch, + Retryable: retryable, Err: err, ReconnectErr: err, + }) + } + + stdout, stderr, runErr := s.runOnce(ctx, client, generation, command, timeout) + if runErr == nil { + return stdout, stderr, nil + } + if ctx.Err() != nil { + return stdout, stderr, ctx.Err() + } + var transportErr *RemoteTransportError + if !errors.As(runErr, &transportErr) { + return stdout, stderr, runErr + } + lastTransportErr = transportErr + s.transport.invalidateClient(client, generation) + if ctx.Err() != nil { + return stdout, stderr, ctx.Err() + } + reconnectErr := s.transport.ensureConnected(ctx, transportErr) + if ctx.Err() != nil { + return stdout, stderr, ctx.Err() + } + if reconnectErr != nil { + code, retryable := classifySSHReconnectError(reconnectErr) + transportErr.Code = code + transportErr.Retryable = retryable + transportErr.ReconnectErr = reconnectErr + return stdout, stderr, Fatal(transportErr) + } + if transportErr.Phase == RemoteTransportOutcomeUnknown && !readOnly { + return stdout, stderr, Fatal(transportErr) + } + if attempt == sshOperationMaxAttempts { + transportErr.Err = fmt.Errorf( + "SSH operation transport retry limit (%d) reached: %w", + sshOperationMaxAttempts, transportErr.Err, + ) + return stdout, stderr, Fatal(transportErr) + } + appconfig.Logger().Printf( + "[ssh] replaying %s operation on %s@%s after transport recovery (attempt %d/%d)", + map[bool]string{true: "read-only", false: "before-dispatch"}[readOnly], + s.user, s.host, attempt+1, sshOperationMaxAttempts, + ) + } + return "", "", Fatal(lastTransportErr) +} + +func (s *SSHExecutor) runOnce( + ctx context.Context, + client *ssh.Client, + generation uint64, + command string, + timeout time.Duration, +) (string, string, error) { + openCtx, openCancel := context.WithTimeout(ctx, sshSessionOpenTimeout) + defer openCancel() + type sessionResult struct { + session *ssh.Session + err error + } + sessionReady := make(chan sessionResult, 1) + go func() { + session, err := client.NewSession() + sessionReady <- sessionResult{session: session, err: err} + }() + + var session *ssh.Session + select { + case result := <-sessionReady: + if result.err != nil { + if isSSHConnDead(result.err) { + return "", "", newSSHTransportError(RemoteTransportBeforeDispatch, result.err) + } + var openErr *ssh.OpenChannelError + if !errors.As(result.err, &openErr) { + if probeErr := probeSSHClient(openCtx, client, sshFailureProbeTimeout); probeErr != nil { + return "", "", newSSHTransportError( + RemoteTransportBeforeDispatch, + fmt.Errorf("%v; SSH transport probe failed: %w", result.err, probeErr), + ) + } + } + return "", "", fmt.Errorf("ssh session: %w", result.err) } - return "", "", wrapped + session = result.session + case <-openCtx.Done(): + // Opening an SSH channel has no cancellation API. Closing the transport + // is the only way to unblock the goroutine and avoids leaking it forever. + s.transport.invalidateClient(client, generation) + return "", "", newSSHTransportError(RemoteTransportBeforeDispatch, openCtx.Err()) + } + if session == nil { + return "", "", Fatal(fmt.Errorf("ssh session: empty session")) } + openCancel() defer func() { _ = session.Close() }() var stdout, stderr bytes.Buffer session.Stdout = &stdout session.Stderr = &stderr - // Run with timeout via goroutine done := make(chan error, 1) go func() { done <- session.Run(command) }() + commandCtx, commandCancel := context.WithTimeout(ctx, timeout) + defer commandCancel() select { case err := <-done: + if isSSHConnDead(err) { + return stdout.String(), stderr.String(), newSSHTransportError(RemoteTransportOutcomeUnknown, err) + } + if err != nil { + var exitErr *ssh.ExitError + if !errors.As(err, &exitErr) { + if probeErr := probeSSHClient(commandCtx, client, sshFailureProbeTimeout); probeErr != nil { + return stdout.String(), stderr.String(), newSSHTransportError( + RemoteTransportOutcomeUnknown, + fmt.Errorf("%v; SSH transport probe failed: %w", err, probeErr), + ) + } + } + } return stdout.String(), stderr.String(), err - case <-time.After(timeout): - terminateSSHCommand(session, done) + case <-commandCtx.Done(): + terminateDone := make(chan struct{}) + go func() { + terminateSSHCommand(session, done) + close(terminateDone) + }() + select { + case <-terminateDone: + case <-time.After(sshFailureProbeTimeout): + _ = session.Close() + } + probeCtx, probeCancel := context.WithTimeout(context.Background(), sshFailureProbeTimeout) + probeErr := probeSSHClient(probeCtx, client, sshFailureProbeTimeout) + probeCancel() + if probeErr != nil { + return stdout.String(), stderr.String(), newSSHTransportError( + RemoteTransportOutcomeUnknown, + fmt.Errorf("command timed out; SSH transport probe failed: %w", probeErr), + ) + } return stdout.String(), stderr.String(), fmt.Errorf("command timed out after %v", timeout) - case <-ctx.Done(): - terminateSSHCommand(session, done) - return stdout.String(), stderr.String(), fmt.Errorf("command cancelled: %w", ctx.Err()) + } +} + +func newSSHTransportError(phase RemoteTransportPhase, err error) *RemoteTransportError { + return &RemoteTransportError{ + Kind: "ssh", + Code: "ssh_connection_failed", + Phase: phase, + Retryable: true, + Err: err, } } @@ -692,18 +1038,24 @@ func terminateSSHCommand(session *ssh.Session, done <-chan error) { // Helpers // --------------------------------------------------------------------------- -func sshExecSimple(client *ssh.Client, command string) (string, string, error) { - session, err := client.NewSession() - if err != nil { - return "", "", err +func normalizeSSHAddress(addr string) (string, error) { + addr = strings.TrimSpace(addr) + if addr == "" { + return "", fmt.Errorf("SSH address is required") } - defer func() { _ = session.Close() }() - - var stdout, stderr bytes.Buffer - session.Stdout = &stdout - session.Stderr = &stderr - err = session.Run(command) - return stdout.String(), stderr.String(), err + if host, port, err := net.SplitHostPort(addr); err == nil { + if host == "" || port == "" { + return "", fmt.Errorf("invalid SSH address %q", addr) + } + return net.JoinHostPort(host, port), nil + } + if ip := net.ParseIP(strings.Trim(addr, "[]")); ip != nil { + return net.JoinHostPort(ip.String(), "22"), nil + } + if strings.Contains(addr, ":") { + return "", fmt.Errorf("invalid SSH address %q", addr) + } + return net.JoinHostPort(addr, "22"), nil } func ShellQuote(s string) string { diff --git a/internal/tools/errors.go b/internal/tools/errors.go index 73c6f011..1991f829 100644 --- a/internal/tools/errors.go +++ b/internal/tools/errors.go @@ -9,9 +9,11 @@ import ( // --------------------------------------------------------------------------- // Fatal errors (#16) — unrecoverable infrastructure failures. // -// A Fatal error marks the executor behind the tools as permanently dead for -// this run (container removed, SSH connection gone): every further tool call -// is guaranteed to fail the same way. Error-folding middleware +// A Fatal error marks an infrastructure failure that this run must not ask the +// model to retry. Examples include a removed container, exhausted SSH reconnect +// attempts, or an SSH command whose outcome became unknown after dispatch. A +// transient SSH loss is repaired inside the executor and is not Fatal when a +// safe operation can be replayed. Error-folding middleware // (internal/agent/middleware.go approvalMiddleware and the subagent // safeToolMiddleware in subagent.go) checks IsFatal BEFORE folding and // propagates the error to abort the run instead of letting the model burn diff --git a/internal/tools/execute.go b/internal/tools/execute.go index e815e02c..1d849b9f 100644 --- a/internal/tools/execute.go +++ b/internal/tools/execute.go @@ -181,5 +181,11 @@ func (et *executeTool) InvokableRun(ctx context.Context, argumentsInJSON string, // LLM; Streams/Meta are reconstructed by the web handler via // ParseExecModelOutput for structured UI rendering. res := BuildExecResult(stdout, stderr, err, elapsed, input.Command) + if IsFatal(err) { + // Preserve the structured RemoteTransportError through approval middleware. + // In particular, an outcome-unknown SSH command must never be presented to + // the model as an ordinary failure it may blindly retry. + return res.ModelOutput, err + } return res.ModelOutput, nil } diff --git a/internal/tools/execute_test.go b/internal/tools/execute_test.go index a88822ae..9f8693a2 100644 --- a/internal/tools/execute_test.go +++ b/internal/tools/execute_test.go @@ -2,6 +2,8 @@ package tools import ( "context" + "errors" + "fmt" "os" "regexp" "runtime" @@ -12,7 +14,10 @@ import ( type countingExecuteExecutor struct { Executor - calls int + calls int + stdout string + stderr string + err error } func (e *countingExecuteExecutor) Exec( @@ -22,7 +27,63 @@ func (e *countingExecuteExecutor) Exec( time.Duration, ) (string, string, error) { e.calls++ - return "ok", "", nil + stdout := e.stdout + if stdout == "" && e.err == nil { + stdout = "ok" + } + return stdout, e.stderr, e.err +} + +func TestExecutePreservesFatalRemoteTransportError(t *testing.T) { + env := NewEnv(t.TempDir(), runtime.GOOS+"/"+runtime.GOARCH) + transportErr := &RemoteTransportError{ + Kind: "ssh", + Code: "ssh_connection_failed", + Phase: RemoteTransportOutcomeUnknown, + Retryable: true, + Err: errors.New("use of closed network connection"), + } + executor := &countingExecuteExecutor{ + Executor: env.Exec, + stdout: "possibly applied\n", + stderr: "connection lost\n", + err: Fatal(transportErr), + } + env.Exec = executor + + modelOutput, err := env.NewExecuteTool(nil).InvokableRun( + context.Background(), `{"command":"touch state"}`, + ) + if !IsFatal(err) { + t.Fatalf("execute error = %v, want Fatal", err) + } + var got *RemoteTransportError + if !errors.As(err, &got) || got != transportErr { + t.Fatalf("execute error = %v, want original RemoteTransportError", err) + } + if !strings.Contains(modelOutput, "possibly applied") { + t.Fatalf("model output lost diagnostic stdout: %q", modelOutput) + } +} + +func TestExecuteOrdinaryExitRemainsModelResult(t *testing.T) { + env := NewEnv(t.TempDir(), runtime.GOOS+"/"+runtime.GOARCH) + executor := &countingExecuteExecutor{ + Executor: env.Exec, + stderr: "ordinary failure\n", + err: fmt.Errorf("exit status 7"), + } + env.Exec = executor + + modelOutput, err := env.NewExecuteTool(nil).InvokableRun( + context.Background(), `{"command":"false"}`, + ) + if err != nil { + t.Fatalf("ordinary command error propagated: %v", err) + } + if !strings.Contains(modelOutput, "ordinary failure") { + t.Fatalf("model output lost ordinary stderr: %q", modelOutput) + } } // execToolRun runs the execute tool against a real LocalExecutor. diff --git a/internal/tools/glob.go b/internal/tools/glob.go index fb2fa504..d2bf236c 100644 --- a/internal/tools/glob.go +++ b/internal/tools/glob.go @@ -109,7 +109,7 @@ func (g *globTool) InvokableRun(ctx context.Context, argumentsInJSON string, opt var err error if g.env.IsRemote() { - stdout, stderr, err = g.env.Exec.Exec(ctx, cmd, "", 30*time.Second) + stdout, stderr, err = ExecReadOnly(ctx, g.env.Exec, cmd, "", 30*time.Second) } else { // Require ripgrep — same hard dependency as the grep tool. if _, lookErr := exec.LookPath("rg"); lookErr != nil { @@ -120,11 +120,14 @@ func (g *globTool) InvokableRun(ctx context.Context, argumentsInJSON string, opt elapsed := time.Since(start) if err != nil { + if IsFatal(err) { + return "", err + } // The pipeline exit code is head's (0); a non-zero code means the cd // failed (bad search path) or the shell itself failed. if stdout == "" { if stderr != "" { - return "", fmt.Errorf("glob error: %s", strings.TrimSpace(stderr)) + return "", fmt.Errorf("glob error: %s: %w", strings.TrimSpace(stderr), err) } return "No files found.", nil } diff --git a/internal/tools/grep.go b/internal/tools/grep.go index b52dce6d..d60ad54d 100644 --- a/internal/tools/grep.go +++ b/internal/tools/grep.go @@ -534,9 +534,12 @@ func relativizeLine(line, pwd string) string { // runRemote builds a command string and runs it over SSH via the Executor. func (g *grepTool) runRemote(ctx context.Context, input GrepInput, maxResults int) (string, error) { cmd := g.buildRemoteCmd(input, maxResults) - stdout, stderr, err := g.env.Exec.Exec(ctx, cmd, "", 30*time.Second) + stdout, stderr, err := ExecReadOnly(ctx, g.env.Exec, cmd, "", 30*time.Second) if err != nil { + if IsFatal(err) { + return "", err + } // Exit code 1 = no matches (files_with_matches/count run rg bare; // content mode pipes through head, whose exit code masks rg's — that // case is handled by the empty-stdout check below). @@ -545,7 +548,7 @@ func (g *grepTool) runRemote(ctx context.Context, input GrepInput, maxResults in } if stdout == "" { if stderr != "" { - return "", fmt.Errorf("search error: %s", strings.TrimSpace(stderr)) + return "", fmt.Errorf("search error: %s: %w", strings.TrimSpace(stderr), err) } return "", fmt.Errorf("search failed: %w", err) } diff --git a/internal/tools/read.go b/internal/tools/read.go index df15466a..28bcb85c 100644 --- a/internal/tools/read.go +++ b/internal/tools/read.go @@ -98,7 +98,9 @@ func (r *readTool) InvokableRun(ctx context.Context, argumentsInJSON string, opt } if stat.IsDir { - out, serr, err := r.env.Exec.Exec(ctx, fmt.Sprintf("ls -la %s", ShellQuote(input.FilePath)), "", 10*time.Second) + out, serr, err := ExecReadOnly( + ctx, r.env.Exec, fmt.Sprintf("ls -la %s", ShellQuote(input.FilePath)), "", 10*time.Second, + ) if err != nil { return "", fmt.Errorf("failed to list directory %s: %w\n%s", input.FilePath, err, serr) } diff --git a/internal/tools/remote_transport.go b/internal/tools/remote_transport.go new file mode 100644 index 00000000..0bfb1683 --- /dev/null +++ b/internal/tools/remote_transport.go @@ -0,0 +1,104 @@ +package tools + +import ( + "context" + "fmt" + "time" +) + +// RemoteTransportPhase records how far a remote operation got before the +// transport failed. Callers may replay before-dispatch failures, but must treat +// outcome-unknown failures as potentially applied on the remote host. +type RemoteTransportPhase string + +const ( + RemoteTransportBeforeDispatch RemoteTransportPhase = "before_dispatch" + RemoteTransportOutcomeUnknown RemoteTransportPhase = "outcome_unknown" +) + +// RemoteTransportError is a machine-readable remote connection failure. A +// failure after dispatch deliberately does not claim that the command failed: +// the remote process may have completed after the local SSH channel disappeared. +type RemoteTransportError struct { + Kind string + Code string + Phase RemoteTransportPhase + Retryable bool + Err error + ReconnectErr error +} + +func (e *RemoteTransportError) Error() string { + if e == nil { + return "remote transport error" + } + message := fmt.Sprintf("%s remote transport failed (%s)", e.Kind, e.Phase) + if e.Phase == RemoteTransportOutcomeUnknown { + message += "; remote command outcome is unknown and it was not replayed" + } + if e.Err != nil { + message += ": " + e.Err.Error() + } + if e.ReconnectErr != nil { + message += "; reconnect failed: " + e.ReconnectErr.Error() + } + return message +} + +func (e *RemoteTransportError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +const ( + RemoteConnectionWaiting = "waiting" + RemoteConnectionReconnecting = "reconnecting" + RemoteConnectionReady = "ready" + RemoteConnectionFailed = "failed" + RemoteConnectionActionRequired = "action_required" +) + +// RemoteConnectionStatus is emitted by a live remote executor while it repairs +// a transient connection. The owning Engine supplies task_id in its WebSocket +// envelope, keeping this transport package UI- and session-agnostic. +type RemoteConnectionStatus struct { + Kind string `json:"kind"` + Status string `json:"status"` + Attempt int `json:"attempt"` + MaxAttempts int `json:"max_attempts"` + Host string `json:"host,omitempty"` + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` + Retryable bool `json:"retryable,omitempty"` + RetryInMS int64 `json:"retry_in_ms,omitempty"` +} + +type RemoteConnectionStatusHandler func(RemoteConnectionStatus) + +// RemoteConnectionStatusSource is implemented by per-Engine remote leases. +// SetRemoteConnectionStatusHandler replaces the callback for only that lease. +type RemoteConnectionStatusSource interface { + SetRemoteConnectionStatusHandler(RemoteConnectionStatusHandler) +} + +// ReadOnlyExecutor lets callers explicitly opt a command into safe transport +// replay. Arbitrary Executor.Exec calls are never inferred to be read-only. +type ReadOnlyExecutor interface { + ExecReadOnly(ctx context.Context, command, workDir string, timeout time.Duration) (stdout, stderr string, err error) +} + +// ExecReadOnly uses replay-aware execution when the executor supports it and +// preserves the ordinary Executor contract for local/Docker implementations. +func ExecReadOnly( + ctx context.Context, + executor Executor, + command, workDir string, + timeout time.Duration, +) (stdout, stderr string, err error) { + if readOnly, ok := executor.(ReadOnlyExecutor); ok { + return readOnly.ExecReadOnly(ctx, command, workDir, timeout) + } + return executor.Exec(ctx, command, workDir, timeout) +} diff --git a/internal/tools/remote_write_safety_test.go b/internal/tools/remote_write_safety_test.go new file mode 100644 index 00000000..d91e46ea --- /dev/null +++ b/internal/tools/remote_write_safety_test.go @@ -0,0 +1,93 @@ +package tools + +import ( + "context" + "errors" + "os" + "runtime" + "testing" +) + +type failingFileMetadataExecutor struct { + Executor + statInfo *FileInfo + statErr error + readErr error + writeCalls int + mkdirCalls int +} + +func (e *failingFileMetadataExecutor) Stat(context.Context, string) (*FileInfo, error) { + return e.statInfo, e.statErr +} + +func (e *failingFileMetadataExecutor) ReadFile(context.Context, string) ([]byte, error) { + return nil, e.readErr +} + +func (e *failingFileMetadataExecutor) WriteFile(context.Context, string, []byte, os.FileMode) error { + e.writeCalls++ + return nil +} + +func (e *failingFileMetadataExecutor) MkdirAll(context.Context, string, os.FileMode) error { + e.mkdirCalls++ + return nil +} + +func fatalMetadataError() error { + return Fatal(&RemoteTransportError{ + Kind: "ssh", Code: "ssh_connection_failed", Phase: RemoteTransportBeforeDispatch, + Retryable: true, Err: errors.New("connection lost"), + }) +} + +func TestWriteFailsClosedWhenRemoteStatFails(t *testing.T) { + env := NewEnv(t.TempDir(), runtime.GOOS+"/"+runtime.GOARCH) + exec := &failingFileMetadataExecutor{Executor: env.Exec, statErr: fatalMetadataError()} + env.Exec = exec + + _, err := env.NewWriteTool().InvokableRun( + context.Background(), `{"file_path":"target.txt","content":"replacement"}`, + ) + if !IsFatal(err) { + t.Fatalf("write error = %v, want Fatal remote transport error", err) + } + if exec.writeCalls != 0 { + t.Fatalf("write dispatched %d times after unknown stat outcome", exec.writeCalls) + } +} + +func TestWriteFailsClosedWhenRemoteBackupReadFails(t *testing.T) { + env := NewEnv(t.TempDir(), runtime.GOOS+"/"+runtime.GOARCH) + exec := &failingFileMetadataExecutor{ + Executor: env.Exec, statInfo: &FileInfo{Exists: true}, readErr: fatalMetadataError(), + } + env.Exec = exec + + _, err := env.NewWriteTool().InvokableRun( + context.Background(), `{"file_path":"target.txt","content":"replacement"}`, + ) + if !IsFatal(err) { + t.Fatalf("write error = %v, want Fatal remote transport error", err) + } + if exec.writeCalls != 0 { + t.Fatalf("write dispatched %d times after failed existing-file read", exec.writeCalls) + } +} + +func TestEditCreateFailsClosedWhenRemoteStatFails(t *testing.T) { + env := NewEnv(t.TempDir(), runtime.GOOS+"/"+runtime.GOARCH) + exec := &failingFileMetadataExecutor{Executor: env.Exec, statErr: fatalMetadataError()} + env.Exec = exec + + _, err := env.NewEditTool().InvokableRun( + context.Background(), `{"file_path":"target.txt","old_string":"","new_string":"new"}`, + ) + if !IsFatal(err) { + t.Fatalf("edit create error = %v, want Fatal remote transport error", err) + } + if exec.mkdirCalls != 0 || exec.writeCalls != 0 { + t.Fatalf("edit create mutated after unknown stat: mkdir=%d write=%d", exec.mkdirCalls, exec.writeCalls) + } +} diff --git a/internal/tools/ssh_auth.go b/internal/tools/ssh_auth.go index d46799c3..f92d4ed8 100644 --- a/internal/tools/ssh_auth.go +++ b/internal/tools/ssh_auth.go @@ -1,8 +1,11 @@ package tools import ( + "fmt" + "io" "net" "os" + "time" "golang.org/x/crypto/ssh" sshagent "golang.org/x/crypto/ssh/agent" @@ -11,19 +14,12 @@ import ( // BuildSSHAuthMethods builds a list of SSH auth methods by checking the // SSH agent socket and common private key files in ~/.ssh/. func BuildSSHAuthMethods() []ssh.AuthMethod { - var methods []ssh.AuthMethod + var signers []ssh.Signer // Try SSH agent (only if it actually holds keys) if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { - if conn, err := net.Dial("unix", sock); err == nil { - ag := sshagent.NewClient(conn) - if keys, err := ag.List(); err == nil && len(keys) > 0 { - // Extract signers eagerly so we can close the agent connection. - if signers, err := ag.Signers(); err == nil { - methods = append(methods, ssh.PublicKeys(signers...)) - } - } - _ = conn.Close() + if agentSigners, err := agentSocketSigners(sock); err == nil { + signers = append(signers, agentSigners...) } } @@ -42,8 +38,113 @@ func BuildSSHAuthMethods() []ssh.AuthMethod { if err != nil { continue } - methods = append(methods, ssh.PublicKeys(signer)) + signers = append(signers, signer) } - return methods + // x/crypto/ssh considers authentication methods by protocol name. Multiple + // ssh.PublicKeys AuthMethods are all named "publickey", so after trying the + // first one it skips the rest. Put every agent/default-key signer into one + // AuthMethod so the SSH package can try each identity in order. + if len(signers) == 0 { + return nil + } + return []ssh.AuthMethod{ssh.PublicKeys(signers...)} +} + +// agentSocketSigners snapshots the public keys currently offered by an SSH +// agent without tying the returned signers to the short-lived List connection. +// The signers reconnect for each signature request. This matters because the +// signers returned by agent.Client.Signers retain the client internally; closing +// that client's socket before ssh.NewClientConn finishes authentication makes +// every agent-backed login fail with a closed-pipe error. +func agentSocketSigners(socketPath string) ([]ssh.Signer, error) { + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return nil, err + } + + keys, err := sshagent.NewClient(conn).List() + if err != nil { + return nil, err + } + signers := make([]ssh.Signer, 0, len(keys)) + for _, key := range keys { + signers = append(signers, &agentSocketSigner{socketPath: socketPath, publicKey: key}) + } + return signers, nil +} + +// agentSocketSigner is deliberately connectionless between calls. It avoids a +// process-lifetime fd leak while keeping agent signatures usable after auth +// method discovery has returned. +type agentSocketSigner struct { + socketPath string + publicKey ssh.PublicKey +} + +func (s *agentSocketSigner) PublicKey() ssh.PublicKey { return s.publicKey } + +func (s *agentSocketSigner) Sign(_ io.Reader, data []byte) (*ssh.Signature, error) { + return s.sign(data, "") } + +func (s *agentSocketSigner) SignWithAlgorithm(_ io.Reader, data []byte, algorithm string) (*ssh.Signature, error) { + return s.sign(data, algorithm) +} + +func (s *agentSocketSigner) sign(data []byte, algorithm string) (*ssh.Signature, error) { + conn, err := net.DialTimeout("unix", s.socketPath, 2*time.Second) + if err != nil { + return nil, fmt.Errorf("connect to SSH agent: %w", err) + } + defer func() { _ = conn.Close() }() + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return nil, fmt.Errorf("set SSH agent deadline: %w", err) + } + + agent := sshagent.NewClient(conn) + switch algorithm { + case "": + return agent.Sign(s.publicKey, data) + case ssh.KeyAlgoRSASHA256: + return agent.SignWithFlags(s.publicKey, data, sshagent.SignatureFlagRsaSha256) + case ssh.KeyAlgoRSASHA512: + return agent.SignWithFlags(s.publicKey, data, sshagent.SignatureFlagRsaSha512) + default: + if algorithm == agentUnderlyingAlgorithm(s.publicKey.Type()) { + return agent.Sign(s.publicKey, data) + } + return nil, fmt.Errorf("SSH agent does not support signature algorithm %q", algorithm) + } +} + +func agentUnderlyingAlgorithm(keyType string) string { + switch keyType { + case ssh.CertAlgoRSAv01: + return ssh.KeyAlgoRSA + case ssh.CertAlgoRSASHA256v01: + return ssh.KeyAlgoRSASHA256 + case ssh.CertAlgoRSASHA512v01: + return ssh.KeyAlgoRSASHA512 + case ssh.CertAlgoECDSA256v01: + return ssh.KeyAlgoECDSA256 + case ssh.CertAlgoECDSA384v01: + return ssh.KeyAlgoECDSA384 + case ssh.CertAlgoECDSA521v01: + return ssh.KeyAlgoECDSA521 + case ssh.CertAlgoSKECDSA256v01: + return ssh.KeyAlgoSKECDSA256 + case ssh.CertAlgoED25519v01: + return ssh.KeyAlgoED25519 + case ssh.CertAlgoSKED25519v01: + return ssh.KeyAlgoSKED25519 + default: + return keyType + } +} + +var _ ssh.AlgorithmSigner = (*agentSocketSigner)(nil) diff --git a/internal/tools/ssh_auth_test.go b/internal/tools/ssh_auth_test.go new file mode 100644 index 00000000..8454d01f --- /dev/null +++ b/internal/tools/ssh_auth_test.go @@ -0,0 +1,228 @@ +package tools + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "golang.org/x/crypto/ssh" + sshagent "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/testdata" +) + +func TestAgentSocketSignerReconnectsAfterDiscovery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-domain ssh-agent socket test") + } + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + keyring := sshagent.NewKeyring() + if err := keyring.Add(sshagent.AddedKey{PrivateKey: privateKey}); err != nil { + t.Fatal(err) + } + + socketPath := shortUnixSocketPath(t) + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + var servers sync.WaitGroup + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + servers.Add(1) + go func() { + defer servers.Done() + _ = sshagent.ServeAgent(keyring, conn) + _ = conn.Close() + }() + } + }() + + signers, err := agentSocketSigners(socketPath) + if err != nil { + t.Fatalf("agentSocketSigners: %v", err) + } + if len(signers) != 1 { + t.Fatalf("got %d signers, want 1", len(signers)) + } + data := []byte("sign after the discovery socket has closed") + signature, err := signers[0].Sign(rand.Reader, data) + if err != nil { + t.Fatalf("sign via a fresh agent connection: %v", err) + } + if err := signers[0].PublicKey().Verify(data, signature); err != nil { + t.Fatalf("verify agent signature: %v", err) + } + if _, ok := signers[0].(ssh.AlgorithmSigner); !ok { + t.Fatal("agent-backed signer must support negotiated RSA algorithms") + } + + _ = listener.Close() + <-done + servers.Wait() +} + +func TestBuildSSHAuthMethodsCombinesAgentAndDefaultKey(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-domain ssh-agent socket test") + } + home := t.TempDir() + t.Setenv("HOME", home) + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + + // The agent key is intentionally rejected; the default on-disk key must + // still be attempted by the same publickey AuthMethod and succeed. + _, rejectedPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _, acceptedPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + acceptedSigner, err := ssh.NewSignerFromKey(acceptedPrivate) + if err != nil { + t.Fatal(err) + } + pemBlock, err := ssh.MarshalPrivateKey(acceptedPrivate, "") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sshDir, "id_ed25519"), pem.EncodeToMemory(pemBlock), 0o600); err != nil { + t.Fatal(err) + } + + keyring := sshagent.NewKeyring() + if err := keyring.Add(sshagent.AddedKey{PrivateKey: rejectedPrivate}); err != nil { + t.Fatal(err) + } + socketPath := shortUnixSocketPath(t) + _, stopAgent := serveTestAgent(t, socketPath, keyring) + defer stopAgent() + t.Setenv("SSH_AUTH_SOCK", socketPath) + + authMethods := BuildSSHAuthMethods() + if len(authMethods) != 1 { + t.Fatalf("got %d auth methods, want one combined publickey method", len(authMethods)) + } + + sshListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = sshListener.Close() }() + hostSigner, err := ssh.ParsePrivateKey(testdata.PEMBytes["rsa"]) + if err != nil { + t.Fatal(err) + } + serverConfig := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) != string(acceptedSigner.PublicKey().Marshal()) { + return nil, fmt.Errorf("rejected test identity") + } + return nil, nil + }, + } + serverConfig.AddHostKey(hostSigner) + serverDone := make(chan error, 1) + go func() { + serverSide, acceptErr := sshListener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + conn, _, _, serverErr := ssh.NewServerConn(serverSide, serverConfig) + if conn != nil { + _ = conn.Close() + } + serverDone <- serverErr + }() + + clientConfig := &ssh.ClientConfig{ + User: "test", + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // in-memory auth-only test + Timeout: time.Second, + } + clientSide, err := net.DialTimeout("tcp", sshListener.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + clientConn, _, _, err := ssh.NewClientConn(clientSide, sshListener.Addr().String(), clientConfig) + if err != nil { + t.Fatalf("second identity in combined AuthMethod was not attempted: %v", err) + } + _ = clientConn.Close() + select { + case <-serverDone: + case <-time.After(time.Second): + t.Fatal("SSH test server did not exit") + } +} + +func serveTestAgent(t *testing.T, socketPath string, keyring sshagent.Agent) (net.Listener, func()) { + t.Helper() + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + var servers sync.WaitGroup + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + servers.Add(1) + go func() { + defer servers.Done() + _ = sshagent.ServeAgent(keyring, conn) + _ = conn.Close() + }() + } + }() + return listener, func() { + _ = listener.Close() + <-done + servers.Wait() + } +} + +func shortUnixSocketPath(t *testing.T) string { + t.Helper() + file, err := os.CreateTemp("/tmp", "jcode-agent-*.sock") + if err != nil { + t.Fatal(err) + } + path := file.Name() + if err := file.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(path) }) + return path +} diff --git a/internal/tools/ssh_known_hosts.go b/internal/tools/ssh_known_hosts.go new file mode 100644 index 00000000..be5a95a3 --- /dev/null +++ b/internal/tools/ssh_known_hosts.go @@ -0,0 +1,282 @@ +package tools + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "sync" + + appconfig "github.com/cnjack/jcode/internal/config" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +const ( + SSHHostKeyUnknown = "ssh_host_key_unknown" + SSHHostKeyChanged = "ssh_host_key_changed" + SSHHostKeyConfirmationMismatch = "ssh_host_key_confirmation_mismatch" +) + +// SSHHostKeyError is safe to expose to a UI so it can distinguish first-use +// trust from a changed key. A changed key is never accepted by the normal TOFU +// flow; it requires the user to repair the trust store out of band. +type SSHHostKeyError struct { + Code string + Host string + Fingerprint string + KeyType string + OldFingerprint string + ExpectedFingerprint string + cause error +} + +func (e *SSHHostKeyError) Error() string { + switch e.Code { + case SSHHostKeyUnknown: + return fmt.Sprintf("SSH host key for %s is not trusted (%s %s)", e.Host, e.KeyType, e.Fingerprint) + case SSHHostKeyChanged: + return fmt.Sprintf("SSH host key for %s changed (received %s %s; trusted %s)", e.Host, e.KeyType, e.Fingerprint, e.OldFingerprint) + case SSHHostKeyConfirmationMismatch: + return fmt.Sprintf("SSH host key confirmation for %s does not match (expected %s; received %s)", e.Host, e.ExpectedFingerprint, e.Fingerprint) + default: + return fmt.Sprintf("SSH host key verification failed for %s", e.Host) + } +} + +func (e *SSHHostKeyError) Unwrap() error { return e.cause } + +// SSHHostKeyPolicy controls JCode's TOFU trust flow. AcceptUnknown must only be +// set after displaying the unknown-key details to the user. ExpectedFingerprint +// must contain that displayed SHA256 fingerprint, preventing a key swap between +// the prompt and the confirmation request. +type SSHHostKeyPolicy struct { + AcceptUnknown bool + ExpectedFingerprint string + // KnownHostsPath is an internal override used by callers that need an + // isolated store. Empty selects ~/.jcode/known_hosts. + KnownHostsPath string +} + +// SSHKnownHostsPath returns JCode's private host-key trust store. +func SSHKnownHostsPath() string { + return filepath.Join(appconfig.ConfigDir(), "known_hosts") +} + +// NewSSHHostKeyCallback builds a strict known_hosts callback. Reading an +// unknown host does not create or modify any file. Only an explicit, +// fingerprint-bound AcceptUnknown policy persists a new key. +func NewSSHHostKeyCallback(policy SSHHostKeyPolicy) (ssh.HostKeyCallback, error) { + path := policy.KnownHostsPath + if path == "" { + path = SSHKnownHostsPath() + } + checker, err := loadKnownHostsCallback(path) + if err != nil { + return nil, fmt.Errorf("load SSH known_hosts: %w", err) + } + + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + host := knownhosts.Normalize(hostname) + fingerprint := ssh.FingerprintSHA256(key) + if checker != nil { + err := checker(hostname, remote, key) + if err == nil { + return nil + } + if hostKeyErr := classifyKnownHostsError(err, host, key); hostKeyErr != nil { + if hostKeyErr.Code == SSHHostKeyChanged { + return hostKeyErr + } + } else { + return fmt.Errorf("verify SSH host key for %s: %w", host, err) + } + } + + unknown := &SSHHostKeyError{ + Code: SSHHostKeyUnknown, + Host: host, + Fingerprint: fingerprint, + KeyType: key.Type(), + } + if !policy.AcceptUnknown { + return unknown + } + + expected := strings.TrimSpace(policy.ExpectedFingerprint) + if expected == "" || expected != fingerprint { + return &SSHHostKeyError{ + Code: SSHHostKeyConfirmationMismatch, + Host: host, + Fingerprint: fingerprint, + KeyType: key.Type(), + ExpectedFingerprint: expected, + } + } + return trustUnknownHostKey(path, hostname, remote, key) + }, nil +} + +func classifyKnownHostsError(err error, host string, key ssh.PublicKey) *SSHHostKeyError { + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) { + if len(keyErr.Want) == 0 { + return &SSHHostKeyError{ + Code: SSHHostKeyUnknown, + Host: host, + Fingerprint: ssh.FingerprintSHA256(key), + KeyType: key.Type(), + cause: err, + } + } + return changedHostKeyError(host, key, keyErr.Want[0].Key, err) + } + var revoked *knownhosts.RevokedError + if errors.As(err, &revoked) { + return changedHostKeyError(host, key, revoked.Revoked.Key, err) + } + return nil +} + +func changedHostKeyError(host string, received, trusted ssh.PublicKey, cause error) *SSHHostKeyError { + oldFingerprint := "" + if trusted != nil { + oldFingerprint = ssh.FingerprintSHA256(trusted) + } + return &SSHHostKeyError{ + Code: SSHHostKeyChanged, + Host: host, + Fingerprint: ssh.FingerprintSHA256(received), + KeyType: received.Type(), + OldFingerprint: oldFingerprint, + cause: cause, + } +} + +var knownHostsWriteMu sync.Mutex + +// trustUnknownHostKey re-checks under the writer mutex before appending. This +// keeps two simultaneous confirmations from duplicating a key and refuses to +// overwrite a key that another connection trusted first. +func trustUnknownHostKey(path, hostname string, remote net.Addr, key ssh.PublicKey) error { + knownHostsWriteMu.Lock() + defer knownHostsWriteMu.Unlock() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create SSH trust directory: %w", err) + } + if err := os.Chmod(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("secure SSH trust directory: %w", err) + } + lock, err := acquireKnownHostsFileLock(path + ".lock") + if err != nil { + return fmt.Errorf("lock SSH known_hosts: %w", err) + } + defer func() { + if releaseErr := lock.release(); releaseErr != nil { + appconfig.Logger().Printf("[ssh] release known_hosts lock: %v", releaseErr) + } + }() + + checker, err := loadKnownHostsCallback(path) + if err != nil { + return fmt.Errorf("reload SSH known_hosts: %w", err) + } + if checker != nil { + if err := checker(hostname, remote, key); err == nil { + return nil + } else if hostKeyErr := classifyKnownHostsError(err, knownhosts.Normalize(hostname), key); hostKeyErr != nil && hostKeyErr.Code == SSHHostKeyChanged { + return hostKeyErr + } else if hostKeyErr == nil { + return fmt.Errorf("verify SSH host key before trust: %w", err) + } + } + + current, err := readPrivateKnownHosts(path) + if err != nil { + return err + } + if len(current) > 0 && current[len(current)-1] != '\n' { + current = append(current, '\n') + } + // Normalize explicitly so a non-default port is persisted as + // [host]:port and round-trips through knownhosts.New. + current = append(current, knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)...) + current = append(current, '\n') + if err := durableWriteKnownHosts(path, current); err != nil { + return fmt.Errorf("persist SSH host key: %w", err) + } + return nil +} + +func loadKnownHostsCallback(path string) (ssh.HostKeyCallback, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("refusing non-regular known_hosts file %s", path) + } + if err := os.Chmod(path, 0o600); err != nil { + return nil, fmt.Errorf("secure known_hosts permissions: %w", err) + } + return knownhosts.New(path) +} + +func readPrivateKnownHosts(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read SSH known_hosts: %w", err) + } + return data, nil +} + +func durableWriteKnownHosts(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, ".known_hosts.tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + if tmpPath != "" { + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(0o600); err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + tmpPath = "" + if dirHandle, err := os.Open(dir); err == nil { + _ = dirHandle.Sync() + _ = dirHandle.Close() + } + return nil +} diff --git a/internal/tools/ssh_known_hosts_lock_unix.go b/internal/tools/ssh_known_hosts_lock_unix.go new file mode 100644 index 00000000..1ce0226f --- /dev/null +++ b/internal/tools/ssh_known_hosts_lock_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package tools + +import ( + "os" + + "golang.org/x/sys/unix" +) + +type knownHostsFileLock struct{ file *os.File } + +func acquireKnownHostsFileLock(path string) (*knownHostsFileLock, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return nil, err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + _ = file.Close() + return nil, err + } + return &knownHostsFileLock{file: file}, nil +} + +func (lock *knownHostsFileLock) release() error { + if lock == nil || lock.file == nil { + return nil + } + _ = unix.Flock(int(lock.file.Fd()), unix.LOCK_UN) + return lock.file.Close() +} diff --git a/internal/tools/ssh_known_hosts_lock_windows.go b/internal/tools/ssh_known_hosts_lock_windows.go new file mode 100644 index 00000000..c34407af --- /dev/null +++ b/internal/tools/ssh_known_hosts_lock_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package tools + +import ( + "os" + + "golang.org/x/sys/windows" +) + +type knownHostsFileLock struct{ file *os.File } + +func acquireKnownHostsFileLock(path string) (*knownHostsFileLock, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + overlapped := new(windows.Overlapped) + if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped); err != nil { + _ = file.Close() + return nil, err + } + return &knownHostsFileLock{file: file}, nil +} + +func (lock *knownHostsFileLock) release() error { + if lock == nil || lock.file == nil { + return nil + } + overlapped := new(windows.Overlapped) + _ = windows.UnlockFileEx(windows.Handle(lock.file.Fd()), 0, 1, 0, overlapped) + return lock.file.Close() +} diff --git a/internal/tools/ssh_known_hosts_test.go b/internal/tools/ssh_known_hosts_test.go new file mode 100644 index 00000000..b8748b6a --- /dev/null +++ b/internal/tools/ssh_known_hosts_test.go @@ -0,0 +1,159 @@ +package tools + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestSSHKnownHostsTOFURoundTripNonDefaultPort(t *testing.T) { + path := filepath.Join(t.TempDir(), ".jcode", "known_hosts") + key := testSSHPublicKey(t) + hostname := "example.test:2222" + remoteAddr := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 2222} + + strict, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{KnownHostsPath: path}) + if err != nil { + t.Fatal(err) + } + err = strict(hostname, remoteAddr, key) + assertHostKeyError(t, err, SSHHostKeyUnknown, "[example.test]:2222", ssh.FingerprintSHA256(key)) + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("unknown lookup wrote trust store: stat error = %v", statErr) + } + + accept, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{ + AcceptUnknown: true, + ExpectedFingerprint: ssh.FingerprintSHA256(key), + KnownHostsPath: path, + }) + if err != nil { + t.Fatal(err) + } + if err := accept(hostname, remoteAddr, key); err != nil { + t.Fatalf("accept first-use key: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("known_hosts mode = %o, want 600", got) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(contents), "[example.test]:2222 ") { + t.Fatalf("non-default port was not canonicalized: %q", contents) + } + + reloaded, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{KnownHostsPath: path}) + if err != nil { + t.Fatal(err) + } + if err := reloaded(hostname, remoteAddr, key); err != nil { + t.Fatalf("trusted key did not round-trip: %v", err) + } +} + +func TestSSHKnownHostsChangedKeyCannotBeAccepted(t *testing.T) { + path := filepath.Join(t.TempDir(), "known_hosts") + trusted := testSSHPublicKey(t) + received := testSSHPublicKey(t) + hostname := "host.example:22" + remoteAddr := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 22} + + acceptTrusted, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{ + AcceptUnknown: true, + ExpectedFingerprint: ssh.FingerprintSHA256(trusted), + KnownHostsPath: path, + }) + if err != nil { + t.Fatal(err) + } + if err := acceptTrusted(hostname, remoteAddr, trusted); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + acceptChanged, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{ + AcceptUnknown: true, + ExpectedFingerprint: ssh.FingerprintSHA256(received), + KnownHostsPath: path, + }) + if err != nil { + t.Fatal(err) + } + err = acceptChanged(hostname, remoteAddr, received) + hostKeyErr := assertHostKeyError(t, err, SSHHostKeyChanged, "host.example", ssh.FingerprintSHA256(received)) + if hostKeyErr.OldFingerprint != ssh.FingerprintSHA256(trusted) { + t.Fatalf("old fingerprint = %q, want %q", hostKeyErr.OldFingerprint, ssh.FingerprintSHA256(trusted)) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("changed-key confirmation modified known_hosts") + } +} + +func TestSSHKnownHostsConfirmationBindsFingerprint(t *testing.T) { + path := filepath.Join(t.TempDir(), "known_hosts") + displayed := testSSHPublicKey(t) + received := testSSHPublicKey(t) + hostname := "race.example:22" + remoteAddr := &net.TCPAddr{IP: net.ParseIP("192.0.2.30"), Port: 22} + callback, err := NewSSHHostKeyCallback(SSHHostKeyPolicy{ + AcceptUnknown: true, + ExpectedFingerprint: ssh.FingerprintSHA256(displayed), + KnownHostsPath: path, + }) + if err != nil { + t.Fatal(err) + } + err = callback(hostname, remoteAddr, received) + assertHostKeyError(t, err, SSHHostKeyConfirmationMismatch, "race.example", ssh.FingerprintSHA256(received)) + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("mismatched confirmation wrote trust store: stat error = %v", statErr) + } +} + +func testSSHPublicKey(t *testing.T) ssh.PublicKey { + t.Helper() + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := ssh.NewPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + return key +} + +func assertHostKeyError(t *testing.T, err error, code, host, fingerprint string) SSHHostKeyError { + t.Helper() + var hostKeyErr *SSHHostKeyError + if !errors.As(err, &hostKeyErr) { + t.Fatalf("error = %v, want *SSHHostKeyError", err) + } + if hostKeyErr.Code != code || hostKeyErr.Host != host || hostKeyErr.Fingerprint != fingerprint { + t.Fatalf("host key error = %+v, want code=%q host=%q fingerprint=%q", hostKeyErr, code, host, fingerprint) + } + if hostKeyErr.KeyType == "" { + t.Fatal("host key error omitted key type") + } + return *hostKeyErr +} diff --git a/internal/tools/ssh_lease_test.go b/internal/tools/ssh_lease_test.go new file mode 100644 index 00000000..44574293 --- /dev/null +++ b/internal/tools/ssh_lease_test.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "net" + "runtime" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestSSHCloneLeaseKeepsTransportAlive(t *testing.T) { + if runtime.GOOS == "js" { + t.Skip("loopback listener is unavailable") + } + addr, hostKey, clientSigner, serverDone := startLeaseTestSSHServer(t) + exec, err := NewSSHExecutorContext( + context.Background(), + addr, + "test", + []ssh.AuthMethod{ssh.PublicKeys(clientSigner)}, + ssh.FixedHostKey(hostKey), + ) + if err != nil { + t.Fatalf("connect test SSH server: %v", err) + } + + lease, err := exec.CloneLease() + if err != nil { + t.Fatalf("clone SSH lease: %v", err) + } + if err := exec.Close(); err != nil { + t.Fatalf("close first lease: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := lease.Probe(ctx); err != nil { + t.Fatalf("shared transport died with first lease: %v", err) + } + stdout, stderr, err := lease.Exec(ctx, "printf clone-ok", "", time.Second) + if err != nil { + t.Fatalf("execute through cloned lease: %v (stderr %q)", err, stderr) + } + if !strings.Contains(stdout, "clone-ok") { + t.Fatalf("cloned lease output = %q, want clone-ok", stdout) + } + if err := lease.Close(); err != nil && !strings.Contains(err.Error(), "closed") { + t.Fatalf("close final lease: %v", err) + } + select { + case err := <-serverDone: + if err != nil && !strings.Contains(err.Error(), "EOF") { + t.Fatalf("test SSH server: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("final lease did not close shared SSH transport") + } +} + +func startLeaseTestSSHServer(t *testing.T) (string, ssh.PublicKey, ssh.Signer, <-chan error) { + t.Helper() + _, hostPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + hostSigner, err := ssh.NewSignerFromKey(hostPrivate) + if err != nil { + t.Fatal(err) + } + _, clientPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + clientSigner, err := ssh.NewSignerFromKey(clientPrivate) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + + serverConfig := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if string(key.Marshal()) != string(clientSigner.PublicKey().Marshal()) { + return nil, fmt.Errorf("unexpected client key") + } + return nil, nil + }, + } + serverConfig.AddHostKey(hostSigner) + done := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + done <- acceptErr + return + } + serverConn, channels, requests, handshakeErr := ssh.NewServerConn(conn, serverConfig) + if handshakeErr != nil { + done <- handshakeErr + return + } + go func() { + for req := range requests { + _ = req.Reply(false, nil) + } + }() + go serveLeaseTestChannels(channels) + done <- serverConn.Wait() + }() + return listener.Addr().String(), hostSigner.PublicKey(), clientSigner, done +} + +func serveLeaseTestChannels(channels <-chan ssh.NewChannel) { + for newChannel := range channels { + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(ssh.UnknownChannelType, "session channels only") + continue + } + channel, requests, err := newChannel.Accept() + if err != nil { + continue + } + go func() { + defer func() { _ = channel.Close() }() + for req := range requests { + if req.Type != "exec" { + _ = req.Reply(false, nil) + continue + } + var payload struct{ Command string } + if err := ssh.Unmarshal(req.Payload, &payload); err != nil { + _ = req.Reply(false, nil) + return + } + _ = req.Reply(true, nil) + switch { + case strings.Contains(payload.Command, "uname -sm"): + _, _ = io.WriteString(channel, "Linux x86_64\n") + case strings.Contains(payload.Command, "clone-ok"): + _, _ = io.WriteString(channel, "clone-ok") + } + _, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0})) + return + } + }() + } +} diff --git a/internal/tools/ssh_reconnect.go b/internal/tools/ssh_reconnect.go new file mode 100644 index 00000000..37573655 --- /dev/null +++ b/internal/tools/ssh_reconnect.go @@ -0,0 +1,489 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "strings" + "time" + + appconfig "github.com/cnjack/jcode/internal/config" + "golang.org/x/crypto/ssh" +) + +func (t *sshTransport) isOpen() bool { + t.mu.Lock() + defer t.mu.Unlock() + return !t.closed +} + +func (t *sshTransport) retain() (uint64, bool) { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed || t.refs <= 0 { + return 0, false + } + t.refs++ + t.nextLeaseID++ + return t.nextLeaseID, true +} + +func (t *sshTransport) setObserver(leaseID uint64, handler RemoteConnectionStatusHandler) { + if leaseID == 0 { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return + } + if handler == nil { + delete(t.observers, leaseID) + return + } + if t.observers == nil { + t.observers = make(map[uint64]RemoteConnectionStatusHandler) + } + t.observers[leaseID] = handler +} + +func (t *sshTransport) emit(status RemoteConnectionStatus) { + t.mu.Lock() + handlers := make([]RemoteConnectionStatusHandler, 0, len(t.observers)) + for _, handler := range t.observers { + if handler != nil { + handlers = append(handlers, handler) + } + } + t.mu.Unlock() + for _, handler := range handlers { + handler(status) + } +} + +func (t *sshTransport) release(leaseID uint64) error { + t.mu.Lock() + delete(t.observers, leaseID) + if t.refs > 0 { + t.refs-- + } + last := t.refs == 0 + err := t.closeErr + t.mu.Unlock() + if last { + return t.shutdown() + } + return err +} + +// shutdown is the terminal transport close. Transient connection loss uses +// invalidateClient and keeps the lease set alive for a generation swap. +func (t *sshTransport) shutdown() error { + t.mu.Lock() + if t.closed { + err := t.closeErr + t.mu.Unlock() + return err + } + t.closed = true + client := t.client + t.client = nil + if t.lifetimeCancel != nil { + t.lifetimeCancel() + } + close(t.keepaliveStop) + t.mu.Unlock() + + var err error + if client != nil { + err = client.Close() + } + t.mu.Lock() + t.closeErr = err + t.mu.Unlock() + return err +} + +func (t *sshTransport) clientSnapshot() (*ssh.Client, uint64, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return nil, 0, fmt.Errorf("SSH transport %s@%s is closed", t.user, t.host) + } + if t.client == nil { + return nil, t.clientGeneration, fmt.Errorf("SSH transport %s@%s is disconnected", t.user, t.host) + } + return t.client, t.clientGeneration, nil +} + +func (t *sshTransport) connectedClient( + ctx context.Context, + cause error, +) (*ssh.Client, uint64, error) { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + if client, generation, err := t.clientSnapshot(); err == nil { + return client, generation, nil + } + if err := t.ensureConnected(ctx, cause); err != nil { + return nil, 0, err + } + return t.clientSnapshot() +} + +func (t *sshTransport) invalidateClient(client *ssh.Client, generation uint64) { + if client == nil { + return + } + t.mu.Lock() + if t.client != client || t.clientGeneration != generation { + t.mu.Unlock() + return + } + t.client = nil + t.mu.Unlock() + _ = client.Close() +} + +// ensureConnected performs a per-transport singleflight redial. Every lease +// waits on the same reconnectDone channel and observes the same client generation. +// The redial owns a transport-lifetime context rather than the first caller's +// context; a caller only cancels its own wait. If no waiters remain, the shared +// redial is cancelled so Stop does not leave background retries running. +func (t *sshTransport) ensureConnected(ctx context.Context, cause error) error { + for { + if err := ctx.Err(); err != nil { + return err + } + t.mu.Lock() + if t.closed { + t.mu.Unlock() + return fmt.Errorf("SSH transport %s@%s is closed", t.user, t.host) + } + if t.client != nil { + t.mu.Unlock() + return nil + } + if !t.reconnecting { + t.startReconnectLocked(cause) + } + done := t.reconnectDone + t.reconnectWaiters++ + t.mu.Unlock() + + select { + case <-ctx.Done(): + t.finishReconnectWait(done, true) + return ctx.Err() + case <-done: + t.finishReconnectWait(done, false) + } + if err := ctx.Err(); err != nil { + return err + } + + t.mu.Lock() + err := t.reconnectErr + connected := t.client != nil + closed := t.closed + t.mu.Unlock() + if connected { + return nil + } + if closed { + return fmt.Errorf("SSH transport %s@%s is closed", t.user, t.host) + } + // A prior caller may have cancelled the shared attempt when it became + // the last waiter. A still-live waiter elects itself into a new attempt. + if errors.Is(err, context.Canceled) { + continue + } + if err == nil { + err = fmt.Errorf("SSH reconnect ended without a connection") + } + return err + } +} + +func (t *sshTransport) startReconnectLocked(cause error) { + base := t.lifetimeCtx + if base == nil { + base = context.Background() + } + reconnectCtx, cancel := context.WithTimeout(base, sshReconnectTotalTimeout) + t.reconnecting = true + t.reconnectDone = make(chan struct{}) + t.reconnectErr = nil + t.reconnectCause = cause + t.reconnectCancel = cancel + t.reconnectWaiters = 0 + done := t.reconnectDone + go t.reconnect(reconnectCtx, cause, done, cancel) +} + +func (t *sshTransport) finishReconnectWait(done <-chan struct{}, cancelled bool) { + t.mu.Lock() + if t.reconnectDone != done { + t.mu.Unlock() + return + } + if t.reconnectWaiters > 0 { + t.reconnectWaiters-- + } + if cancelled && t.reconnecting && t.reconnectWaiters == 0 && t.reconnectCancel != nil { + t.reconnectCancel() + } + t.mu.Unlock() +} + +func (t *sshTransport) reconnect( + ctx context.Context, + cause error, + done chan struct{}, + cancel context.CancelFunc, +) { + defer cancel() + err := t.reconnectAttempts(ctx, cause) + + t.mu.Lock() + // Only the goroutine owning this done channel may publish its result. + if t.reconnectDone == done { + t.reconnectErr = err + t.reconnectCause = nil + t.reconnectCancel = nil + t.reconnecting = false + close(done) + } + t.mu.Unlock() +} + +func (t *sshTransport) reconnectAttempts(ctx context.Context, cause error) error { + if t.dial == nil { + return fmt.Errorf("SSH reconnect is unavailable") + } + lastErr := cause + for attempt := 1; attempt <= sshReconnectMaxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + if attempt > 1 { + delay := t.reconnectBackoff(attempt - 1) + t.emit(RemoteConnectionStatus{ + Kind: "ssh", + Status: RemoteConnectionWaiting, + Attempt: attempt, + MaxAttempts: sshReconnectMaxAttempts, + Host: t.host, + Error: errorString(lastErr), + Code: "ssh_connection_failed", + Retryable: true, + RetryInMS: delay.Milliseconds(), + }) + if err := waitContext(ctx, delay); err != nil { + return err + } + } + + t.emit(RemoteConnectionStatus{ + Kind: "ssh", + Status: RemoteConnectionReconnecting, + Attempt: attempt, + MaxAttempts: sshReconnectMaxAttempts, + Host: t.host, + Error: errorString(lastErr), + Code: "ssh_connection_failed", + Retryable: true, + }) + client, err := t.dial(ctx) + if err == nil { + if ctx.Err() != nil { + _ = client.Close() + return ctx.Err() + } + if installErr := t.installClient(client); installErr != nil { + _ = client.Close() + return installErr + } + t.emit(RemoteConnectionStatus{ + Kind: "ssh", + Status: RemoteConnectionReady, + Attempt: attempt, + MaxAttempts: sshReconnectMaxAttempts, + Host: t.host, + }) + appconfig.Logger().Printf("[ssh] reconnected %s@%s on attempt %d", t.user, t.host, attempt) + return nil + } + lastErr = err + if ctx.Err() != nil { + return ctx.Err() + } + code, retryable := classifySSHReconnectError(err) + if !retryable { + t.emit(RemoteConnectionStatus{ + Kind: "ssh", + Status: RemoteConnectionActionRequired, + Attempt: attempt, + MaxAttempts: sshReconnectMaxAttempts, + Host: t.host, + Error: err.Error(), + Code: code, + }) + return err + } + } + + t.emit(RemoteConnectionStatus{ + Kind: "ssh", + Status: RemoteConnectionFailed, + Attempt: sshReconnectMaxAttempts, + MaxAttempts: sshReconnectMaxAttempts, + Host: t.host, + Error: errorString(lastErr), + Code: "ssh_connection_failed", + Retryable: true, + }) + return lastErr +} + +func (t *sshTransport) installClient(client *ssh.Client) error { + if client == nil { + return fmt.Errorf("SSH reconnect returned an empty client") + } + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return fmt.Errorf("SSH transport %s@%s is closed", t.user, t.host) + } + t.client = client + t.clientGeneration++ + return nil +} + +func (t *sshTransport) reconnectBackoff(attempt int) time.Duration { + if t.backoff != nil { + return t.backoff(attempt) + } + return sshReconnectBackoff(attempt) +} + +func sshReconnectBackoff(attempt int) time.Duration { + if attempt < 1 { + attempt = 1 + } + delay := sshReconnectInitialBackoff + for i := 1; i < attempt && delay < sshReconnectMaxBackoff; i++ { + delay *= 2 + if delay > sshReconnectMaxBackoff { + delay = sshReconnectMaxBackoff + } + } + // Equal jitter prevents several conversations sharing one restored network + // from redialing their independent hosts at exactly the same instant. + half := delay / 2 + return half + time.Duration(rand.Int64N(int64(delay-half)+1)) +} + +func waitContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return ctx.Err() + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func classifySSHReconnectError(err error) (string, bool) { + var hostKeyErr *SSHHostKeyError + if errors.As(err, &hostKeyErr) { + return hostKeyErr.Code, false + } + lower := strings.ToLower(errorString(err)) + if strings.Contains(lower, "unable to authenticate") || + strings.Contains(lower, "no supported methods remain") || + strings.Contains(lower, "no ssh credentials") { + return "ssh_auth_required", false + } + return "ssh_connection_failed", true +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func probeSSHClient(ctx context.Context, client *ssh.Client, timeout time.Duration) error { + if client == nil { + return fmt.Errorf("empty SSH client") + } + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + // Probe by opening a disposable channel instead of sending a want-reply + // global keepalive. x/crypto/ssh's global-response drain loop spins forever + // when that response channel is already closed, turning every probe of a + // dead connection into a leaked 100% CPU goroutine. + type probeResult struct { + session *ssh.Session + err error + } + done := make(chan probeResult, 1) + go func() { + session, err := client.NewSession() + done <- probeResult{session: session, err: err} + }() + select { + case result := <-done: + if result.session != nil { + _ = result.session.Close() + } + return result.err + case <-probeCtx.Done(): + return probeCtx.Err() + } +} + +func (t *sshTransport) probe(ctx context.Context) error { + client, generation, err := t.connectedClient(ctx, fmt.Errorf("SSH probe found no live client")) + if err != nil { + return fmt.Errorf("ssh probe: %w", err) + } + probeErr := probeSSHClient(ctx, client, sshProbeTimeout) + if probeErr == nil { + return nil + } + t.invalidateClient(client, generation) + if err := ctx.Err(); err != nil { + return err + } + if reconnectErr := t.ensureConnected(ctx, probeErr); reconnectErr != nil { + return fmt.Errorf("ssh probe: %w", reconnectErr) + } + return nil +} + +func (t *sshTransport) keepaliveLoop() { + delay := sshKeepaliveEvery + for { + if err := waitContext(t.lifetimeCtx, delay); err != nil { + return + } + if err := t.probe(t.lifetimeCtx); err != nil { + if t.lifetimeCtx != nil && t.lifetimeCtx.Err() != nil { + return + } + appconfig.Logger().Printf("[ssh] keepalive could not recover %s@%s: %v", t.user, t.host, err) + delay = sshKeepaliveRetryEvery + continue + } + delay = sshKeepaliveEvery + } +} diff --git a/internal/tools/ssh_reconnect_test.go b/internal/tools/ssh_reconnect_test.go new file mode 100644 index 00000000..30be2ebe --- /dev/null +++ b/internal/tools/ssh_reconnect_test.go @@ -0,0 +1,276 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestProbeSSHClientClosedConnectionDoesNotLeakBusyGoroutines(t *testing.T) { + addr, hostKey, clientSigner, _ := startLeaseTestSSHServer(t) + exec, err := NewSSHExecutorContext( + context.Background(), + addr, + "test", + []ssh.AuthMethod{ssh.PublicKeys(clientSigner)}, + ssh.FixedHostKey(hostKey), + ) + if err != nil { + t.Fatalf("connect test SSH server: %v", err) + } + t.Cleanup(func() { _ = exec.Close() }) + + client, _, _ := exec.transport.clientSnapshot() + if client == nil { + t.Fatal("missing SSH client") + } + _ = client.Close() + time.Sleep(20 * time.Millisecond) + baseline := runtime.NumGoroutine() + for range 12 { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + _ = probeSSHClient(ctx, client, 50*time.Millisecond) + cancel() + } + time.Sleep(50 * time.Millisecond) + if got := runtime.NumGoroutine(); got > baseline+3 { + t.Fatalf("closed-client probes leaked goroutines: baseline=%d after=%d", baseline, got) + } +} + +func TestSSHReconnectSingleflight(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var calls atomic.Int32 + releaseDial := make(chan struct{}) + transport := &sshTransport{ + user: "test", + host: "example.test:22", + refs: 2, + lifetimeCtx: ctx, + lifetimeCancel: cancel, + keepaliveStop: make(chan struct{}), + observers: make(map[uint64]RemoteConnectionStatusHandler), + backoff: func(int) time.Duration { return 0 }, + } + transport.dial = func(context.Context) (*ssh.Client, error) { + calls.Add(1) + <-releaseDial + return &ssh.Client{}, nil + } + + results := make(chan error, 2) + var started sync.WaitGroup + started.Add(2) + for range 2 { + go func() { + started.Done() + results <- transport.ensureConnected(context.Background(), errors.New("lost")) + }() + } + started.Wait() + waitFor(t, time.Second, func() bool { return calls.Load() == 1 }) + close(releaseDial) + for range 2 { + if err := <-results; err != nil { + t.Fatalf("ensureConnected: %v", err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("dial calls = %d, want one shared attempt", got) + } +} + +func TestSSHReconnectCallerCancellationDoesNotCancelOtherWaiter(t *testing.T) { + lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) + defer lifetimeCancel() + firstStarted := make(chan struct{}) + allowSuccess := make(chan struct{}) + var calls atomic.Int32 + transport := &sshTransport{ + user: "test", + host: "example.test:22", + refs: 2, + lifetimeCtx: lifetimeCtx, + lifetimeCancel: lifetimeCancel, + keepaliveStop: make(chan struct{}), + observers: make(map[uint64]RemoteConnectionStatusHandler), + backoff: func(int) time.Duration { return 0 }, + } + transport.dial = func(ctx context.Context) (*ssh.Client, error) { + if calls.Add(1) == 1 { + close(firstStarted) + } + select { + case <-allowSuccess: + return &ssh.Client{}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + shortCtx, shortCancel := context.WithCancel(context.Background()) + shortResult := make(chan error, 1) + go func() { + shortResult <- transport.ensureConnected(shortCtx, errors.New("lost")) + }() + <-firstStarted + longResult := make(chan error, 1) + go func() { + longResult <- transport.ensureConnected(context.Background(), errors.New("lost")) + }() + waitFor(t, time.Second, func() bool { + transport.mu.Lock() + defer transport.mu.Unlock() + return transport.reconnectWaiters == 2 + }) + shortCancel() + if err := <-shortResult; !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled waiter error = %v, want context.Canceled", err) + } + close(allowSuccess) + if err := <-longResult; err != nil { + t.Fatalf("live waiter inherited cancellation: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("dial calls = %d, want original singleflight to continue", got) + } +} + +func TestSSHReconnectPermanentErrorStopsImmediately(t *testing.T) { + lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) + defer lifetimeCancel() + var calls atomic.Int32 + var statuses []RemoteConnectionStatus + transport := &sshTransport{ + user: "test", + host: "example.test:22", + refs: 1, + lifetimeCtx: lifetimeCtx, + lifetimeCancel: lifetimeCancel, + keepaliveStop: make(chan struct{}), + observers: map[uint64]RemoteConnectionStatusHandler{ + 1: func(status RemoteConnectionStatus) { statuses = append(statuses, status) }, + }, + backoff: func(int) time.Duration { return 0 }, + } + transport.dial = func(context.Context) (*ssh.Client, error) { + calls.Add(1) + return nil, &SSHHostKeyError{Code: SSHHostKeyChanged, Host: "example.test:22"} + } + + err := transport.ensureConnected(context.Background(), errors.New("lost")) + var hostKeyErr *SSHHostKeyError + if !errors.As(err, &hostKeyErr) { + t.Fatalf("error = %v, want SSHHostKeyError", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("dial calls = %d, want permanent failure to stop immediately", got) + } + if len(statuses) == 0 || statuses[len(statuses)-1].Status != RemoteConnectionActionRequired { + t.Fatalf("statuses = %+v, want action_required terminal status", statuses) + } + if statuses[len(statuses)-1].Code != SSHHostKeyChanged { + t.Fatalf("terminal code = %q, want %q", statuses[len(statuses)-1].Code, SSHHostKeyChanged) + } +} + +func TestSSHReconnectExhaustionEmitsFailed(t *testing.T) { + lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) + defer lifetimeCancel() + var calls atomic.Int32 + var statusesMu sync.Mutex + var statuses []RemoteConnectionStatus + transport := &sshTransport{ + user: "test", + host: "example.test:22", + refs: 1, + lifetimeCtx: lifetimeCtx, + lifetimeCancel: lifetimeCancel, + keepaliveStop: make(chan struct{}), + observers: map[uint64]RemoteConnectionStatusHandler{ + 1: func(status RemoteConnectionStatus) { + statusesMu.Lock() + statuses = append(statuses, status) + statusesMu.Unlock() + }, + }, + backoff: func(int) time.Duration { return 0 }, + } + transport.dial = func(context.Context) (*ssh.Client, error) { + calls.Add(1) + return nil, fmt.Errorf("network unreachable") + } + + if err := transport.ensureConnected(context.Background(), errors.New("lost")); err == nil { + t.Fatal("expected reconnect exhaustion") + } + if got := calls.Load(); got != sshReconnectMaxAttempts { + t.Fatalf("dial calls = %d, want %d", got, sshReconnectMaxAttempts) + } + statusesMu.Lock() + defer statusesMu.Unlock() + if len(statuses) == 0 || statuses[len(statuses)-1].Status != RemoteConnectionFailed { + t.Fatalf("statuses = %+v, want failed terminal status", statuses) + } +} + +func TestSSHLastLeaseCloseCancelsReconnect(t *testing.T) { + lifetimeCtx, lifetimeCancel := context.WithCancel(context.Background()) + dialStarted := make(chan struct{}) + transport := &sshTransport{ + user: "test", + host: "example.test:22", + refs: 1, + nextLeaseID: 1, + lifetimeCtx: lifetimeCtx, + lifetimeCancel: lifetimeCancel, + keepaliveStop: make(chan struct{}), + observers: make(map[uint64]RemoteConnectionStatusHandler), + backoff: func(int) time.Duration { return 0 }, + } + transport.dial = func(ctx context.Context) (*ssh.Client, error) { + select { + case <-dialStarted: + default: + close(dialStarted) + } + <-ctx.Done() + return nil, ctx.Err() + } + result := make(chan error, 1) + go func() { + result <- transport.ensureConnected(context.Background(), errors.New("lost")) + }() + <-dialStarted + if err := transport.release(1); err != nil { + t.Fatalf("release final lease: %v", err) + } + select { + case err := <-result: + if err == nil { + t.Fatal("reconnect unexpectedly succeeded after final Close") + } + case <-time.After(time.Second): + t.Fatal("final lease Close did not cancel reconnect promptly") + } +} + +func waitFor(t *testing.T, timeout time.Duration, condition func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("condition was not met before timeout") +} diff --git a/internal/tools/write.go b/internal/tools/write.go index 41ff4bc3..777764a3 100644 --- a/internal/tools/write.go +++ b/internal/tools/write.go @@ -65,7 +65,10 @@ func (w *writeTool) InvokableRun(ctx context.Context, argumentsInJSON string, op return "", fmt.Errorf("content too large (%d bytes, max %d)", len(input.Content), MaxWriteFileSize) } - fi, _ := w.env.Exec.Stat(ctx, input.FilePath) + fi, err := w.env.Exec.Stat(ctx, input.FilePath) + if err != nil { + return "", fmt.Errorf("failed to inspect file %s before writing: %w", input.FilePath, err) + } isNew := fi == nil || !fi.Exists var oldContent string @@ -91,14 +94,16 @@ func (w *writeTool) InvokableRun(ctx context.Context, argumentsInJSON string, op } // Read existing content for backup and diff. - if existing, err := w.env.Exec.ReadFile(ctx, input.FilePath); err == nil { - oldContent = string(existing) - // Backup before overwriting. - if w.env.FileTracker != nil { - bp, bErr := w.env.FileTracker.CreateBackup(input.FilePath, existing) - if bErr == nil { - backupPath = bp - } + existing, err := w.env.Exec.ReadFile(ctx, input.FilePath) + if err != nil { + return "", fmt.Errorf("failed to read existing file %s before writing: %w", input.FilePath, err) + } + oldContent = string(existing) + // Backup before overwriting. + if w.env.FileTracker != nil { + bp, bErr := w.env.FileTracker.CreateBackup(input.FilePath, existing) + if bErr == nil { + backupPath = bp } } } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 678ae9c0..860670f6 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -195,8 +195,10 @@ type ToolApprovalResponse struct { // SSHConnectMsg is sent when user initially requests connection type SSHConnectMsg struct { - Addr string // user@host - Path string // remote working dir (optional) + Addr string // user@host + Path string // remote working dir (optional) + AcceptHostKey bool + HostKeyFingerprint string } // SSHListDirReqMsg is sent when TUI needs to list a directory on the remote machine @@ -213,9 +215,13 @@ type SSHDirResultsMsg struct { // SSHStatusMsg carries the result of an SSH connection attempt. type SSHStatusMsg struct { - Success bool - Label string // e.g. "root@myserver:22" - Err error + Success bool + Label string // e.g. "root@myserver:22" + Err error + HostKeyCode string + Host string + Fingerprint string + KeyType string } // SSHCancelMsg is sent when user cancels the SSH dir picker via Esc. diff --git a/internal/tui/ssh_handlers.go b/internal/tui/ssh_handlers.go index 53e8fadf..dd0268a5 100644 --- a/internal/tui/ssh_handlers.go +++ b/internal/tui/ssh_handlers.go @@ -119,6 +119,38 @@ func (m *Model) handleSSHSaveAlias(input string, cmds []tea.Cmd) (tea.Model, tea return m, tea.Batch(cmds...) } +func (m *Model) handleSSHHostKeyConfirm(input string, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + accepted := strings.EqualFold(strings.TrimSpace(input), "yes") || strings.EqualFold(strings.TrimSpace(input), "y") + m.sshHostKeyPrompt = false + m.textarea.Placeholder = "Type your prompt here..." + if !accepted { + m.lines = append(m.lines, textLine(toolLabelStyle.Render("⚙ SSH:")+" Host key was not trusted; connection cancelled.")) + m.sshSaveAddr = "" + m.sshSavePath = "" + m.sshHostKeyFingerprint = "" + m.refreshViewport() + return m, tea.Batch(cmds...) + } + + fingerprint := m.sshHostKeyFingerprint + addr := m.sshAddr + path := m.sshPath + m.sshHostKeyFingerprint = "" + m.agentDone = false + m.thinking = true + m.lines = append(m.lines, textLine(toolLabelStyle.Render("🔐 SSH:")+" Trusting the displayed host key and reconnecting...")) + cmds = append(cmds, func() tea.Msg { + return SSHConnectMsg{ + Addr: addr, + Path: path, + AcceptHostKey: true, + HostKeyFingerprint: fingerprint, + } + }) + cmds = append(cmds, m.spinner.Tick) + return m, tea.Batch(cmds...) +} + // handleSSHStep handles input during the SSH setup wizard. func (m *Model) handleSSHStep(input string, cmds []tea.Cmd) (tea.Model, tea.Cmd) { if m.sshStep == 1 { @@ -146,6 +178,7 @@ func (m *Model) handleSSHStep(input string, cmds []tea.Cmd) (tea.Model, tea.Cmd) func (m *Model) startSSHConnect(addr, path string, cmds []tea.Cmd) (tea.Model, tea.Cmd) { m.sshStep = 0 m.sshAddr = addr + m.sshPath = path m.mode = ModeAgent m.agentDone = false m.thinking = true diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6b90fbe6..e6202e53 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -101,9 +101,11 @@ type Model struct { sshAliasPicker list.Model pickingSSHAlias bool - sshSavePrompt bool - sshSaveAddr string - sshSavePath string + sshSavePrompt bool + sshSaveAddr string + sshSavePath string + sshHostKeyPrompt bool + sshHostKeyFingerprint string history []string historyIndex int @@ -419,7 +421,7 @@ func (m *Model) confirmCancelAgent() { } func (m Model) inputActive() bool { - return (m.mode == ModeAgent || m.sshStep > 0 || m.sshSavePrompt) && !m.pickingModel && !m.managingModels && !m.pickingTheme && !m.showingSetting && !m.showingHelp && !m.showingTranscript && !m.pickingSSHAlias && !m.pickingSession && !m.approvalPending && !m.planReviewActive && !m.askUserActive + return (m.mode == ModeAgent || m.sshStep > 0 || m.sshSavePrompt || m.sshHostKeyPrompt) && !m.pickingModel && !m.managingModels && !m.pickingTheme && !m.showingSetting && !m.showingHelp && !m.showingTranscript && !m.pickingSSHAlias && !m.pickingSession && !m.approvalPending && !m.planReviewActive && !m.askUserActive } // ModelOption configures a Model before the BubbleTea program starts. diff --git a/internal/tui/update.go b/internal/tui/update.go index fc96cf9d..de1f46f8 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -908,6 +908,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen } } + if m.sshHostKeyPrompt { + return m.handleSSHHostKeyConfirm(prompt, cmds) + } + if m.sshSavePrompt { return m.handleSSHSaveAlias(prompt, cmds) } @@ -1690,7 +1694,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen case SSHStatusMsg: m.thinking = false - if msg.Success { + switch { + case msg.Success: m.envLabel = msg.Label m.invalidateSidebarCache() m.lines = append(m.lines, textLine(fmt.Sprintf(" %s Connected to %s", @@ -1711,7 +1716,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen m.lines = append(m.lines, textLine(toolLabelStyle.Render("⚙ SSH:")+" Save as alias? Enter alias name (or press Enter/type 'n' to skip)")) m.textarea.Placeholder = "Enter alias name (e.g. my-server)..." } - } else { + case msg.HostKeyCode == "ssh_host_key_unknown": + m.sshHostKeyPrompt = true + m.sshHostKeyFingerprint = msg.Fingerprint + m.lines = append(m.lines, + textLine(toolLabelStyle.Render("🔐 SSH host key requires trust:")), + textLine(toolResultStyle.Render(" Host: "+msg.Host)), + textLine(toolResultStyle.Render(" Key type: "+msg.KeyType)), + textLine(toolResultStyle.Render(" Fingerprint: "+msg.Fingerprint)), + textLine(toolLabelStyle.Render("⚠ SSH:")+" Verify this fingerprint, then type 'yes' to trust it (anything else cancels)."), + ) + m.textarea.Placeholder = "Type yes to trust this exact fingerprint..." + default: m.lines = append(m.lines, textLine(fmt.Sprintf(" %s %s", toolErrorStyle.Render("✗ SSH Error:"), toolResultStyle.Render(msg.Err.Error())))) diff --git a/internal/web/activation.go b/internal/web/activation.go new file mode 100644 index 00000000..b7002182 --- /dev/null +++ b/internal/web/activation.go @@ -0,0 +1,553 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + pathpkg "path" + "path/filepath" + "strings" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/mode" + "github.com/cnjack/jcode/internal/remote" + "github.com/cnjack/jcode/internal/session" + "github.com/cnjack/jcode/internal/tools" +) + +type conversationKind string + +const ( + conversationLocal conversationKind = "local" + conversationSSH conversationKind = "ssh" + conversationDocker conversationKind = "docker" +) + +var ( + errConversationNotFound = errors.New("conversation not found") + errInvalidConversationTarget = errors.New("invalid conversation target") + errConversationBusy = errors.New("conversation runtime is busy") + errWorkspaceNotAllowed = errors.New("workspace is not allowed") +) + +type conversationTarget struct { + kind conversationKind + project string + pwd string + sshHost string + sshUser string + container string +} + +// parseConversationTarget treats the session index's project key as the +// execution authority. Remote keys are produced by RemoteExecutor.ProjectLabel +// and contain enough information to reconnect without relying on a mutable +// alias. Unknown URI schemes are rejected instead of being downgraded to a +// local filesystem path. +func parseConversationTarget(project string) (conversationTarget, error) { + if project == "" { + return conversationTarget{}, fmt.Errorf("conversation project is empty") + } + if !strings.Contains(project, "://") { + return conversationTarget{kind: conversationLocal, project: project, pwd: project}, nil + } + + u, err := url.Parse(project) + if err != nil { + return conversationTarget{}, fmt.Errorf("parse conversation project %q: %w", project, err) + } + if u.RawQuery != "" || u.Fragment != "" { + return conversationTarget{}, fmt.Errorf("conversation project %q must not contain a query or fragment", project) + } + pwd := pathpkg.Clean(u.Path) + if !strings.HasPrefix(pwd, "/") { + return conversationTarget{}, fmt.Errorf("conversation project %q has no absolute remote path", project) + } + + switch strings.ToLower(u.Scheme) { + case string(conversationSSH): + if u.Host == "" || u.User == nil || strings.TrimSpace(u.User.Username()) == "" { + return conversationTarget{}, fmt.Errorf("conversation SSH project %q requires user and host", project) + } + if _, hasPassword := u.User.Password(); hasPassword { + return conversationTarget{}, fmt.Errorf("conversation SSH project %q must not embed a password", project) + } + return conversationTarget{ + kind: conversationSSH, project: project, pwd: pwd, + sshHost: u.Host, sshUser: u.User.Username(), + }, nil + case string(conversationDocker): + if u.Host == "" || u.User != nil { + return conversationTarget{}, fmt.Errorf("conversation Docker project %q requires a container", project) + } + return conversationTarget{ + kind: conversationDocker, project: project, pwd: pwd, container: u.Host, + }, nil + default: + return conversationTarget{}, fmt.Errorf("unsupported conversation project scheme %q", u.Scheme) + } +} + +type activationResult struct { + Status string `json:"status"` + SessionID string `json:"session_id"` + Kind conversationKind `json:"kind"` + Pwd string `json:"pwd"` + Project string `json:"project"` + WorkspaceKey string `json:"workspace_key"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Agent string `json:"agent,omitempty"` + Mode string `json:"mode"` + Running bool `json:"running"` + Activated bool `json:"activated"` + Focused bool `json:"focused"` +} + +func activationSnapshot(eng *Engine, kind conversationKind, activated bool) activationResult { + provider, modelName, modeName := eng.modelSnapshot() + project := engineProject(eng) + return activationResult{ + Status: "ready", SessionID: eng.taskID, Kind: kind, Pwd: eng.pwd, + Project: project, WorkspaceKey: project, + Provider: provider, Model: modelName, Agent: eng.curAgentRole(), Mode: modeName, + Running: eng.running.Load(), Activated: activated, + } +} + +// handleActivateSession makes a conversation executable without changing the +// Desktop foreground. It deliberately returns no transcript; callers that need +// to render history continue to use GET /api/sessions/{id}. +func (s *Server) handleActivateSession(w http.ResponseWriter, r *http.Request) { + var req struct { + SessionID string `json:"session_id,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + Source string `json:"source,omitempty"` + Focus bool `json:"focus,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + result, err := s.ensureConversation(r.Context(), req.SessionID, req.ProjectPath, req.Source) + if err != nil { + writeConversationActivationError(w, err) + return + } + if req.Focus { + if eng := s.resolveEngine(result.SessionID); eng != nil { + s.setActiveEngine(eng) + result.Focused = true + } + } + writeJSON(w, http.StatusOK, result) +} + +type conversationActivationError struct { + code string + kind conversationKind + retryable bool + err error +} + +func (e *conversationActivationError) Error() string { return e.err.Error() } +func (e *conversationActivationError) Unwrap() error { return e.err } + +func writeConversationActivationError(w http.ResponseWriter, err error) { + if writeSSHHostKeyError(w, err) { + return + } + status := http.StatusServiceUnavailable + code := "activation_failed" + retryable := true + kind := conversationKind("") + switch { + case errors.Is(err, errConversationNotFound): + status, code, retryable = http.StatusNotFound, "conversation_not_found", false + case errors.Is(err, errInvalidConversationTarget): + status, code, retryable = http.StatusBadRequest, "invalid_conversation_target", false + case errors.Is(err, errWorkspaceNotAllowed): + status, code, retryable = http.StatusForbidden, "workspace_not_allowed", false + case errors.Is(err, errConversationBusy): + status, code = http.StatusConflict, "conversation_busy" + } + var activationErr *conversationActivationError + if errors.As(err, &activationErr) { + code, kind, retryable = activationErr.code, activationErr.kind, activationErr.retryable + status = http.StatusBadGateway + if code == "ssh_auth_required" { + status = http.StatusConflict + } + } + payload := map[string]any{"error": err.Error(), "code": code, "retryable": retryable} + if kind != "" { + payload["kind"] = kind + } + writeJSON(w, status, payload) +} + +// ensureConversation resolves a live engine or cold-activates one from the +// durable session metadata and JSONL state. It never calls setActiveEngine. +func (s *Server) ensureConversation( + ctx context.Context, + sessionID, projectPath, source string, +) (activationResult, error) { + s.taskCreateMu.Lock() + defer s.taskCreateMu.Unlock() + return s.ensureConversationLocked(ctx, sessionID, projectPath, source) +} + +func (s *Server) ensureConversationLocked( + ctx context.Context, + sessionID, projectPath, source string, +) (activationResult, error) { + + var meta *session.SessionMeta + if sessionID != "" { + var err error + meta, err = session.FindSessionMeta(sessionID) + if err != nil { + return activationResult{}, fmt.Errorf("load conversation metadata: %w", err) + } + } + + if sessionID != "" && meta == nil { + if s.resolveEngine(sessionID) == nil { + return activationResult{}, fmt.Errorf("%w: %s", errConversationNotFound, sessionID) + } + } + + project := projectPath + buildMode := s.activeMode() + var restoredState *session.SessionState + if meta != nil { + project = meta.Project + entries, err := session.LoadSession(sessionID) + if err != nil { + return activationResult{}, fmt.Errorf("%w: %s: %v", errConversationNotFound, sessionID, err) + } + restoredState = session.ReconstructState(entries) + buildMode = mode.Approval.String() + if savedMode, err := session.LoadSessionModeStrict(sessionID); err != nil { + config.Logger().Printf("[web] activation: mode journal unavailable for %s; restoring approval: %v", sessionID, err) + } else { + buildMode = restoredWebSessionMode(savedMode).String() + } + } else { + if project == "" { + project = engineProject(s.activeEngine()) + } + // A Cloud-created task must not inherit Full access from whichever Desktop + // conversation happens to be foregrounded. The command may explicitly + // promote it to auto after activation. + if source != "" { + buildMode = mode.Approval.String() + } + } + var old *Engine + if sessionID != "" { + old = s.resolveEngine(sessionID) + } + if project == "" { + project = engineProject(old) + } + + target, err := parseConversationTarget(project) + if err != nil { + return activationResult{}, fmt.Errorf("%w: %v", errInvalidConversationTarget, err) + } + if meta == nil && source != "" && target.kind != conversationLocal { + if err := s.authorizeCloudRemoteWorkspace(target); err != nil { + return activationResult{}, err + } + } + + if old != nil { + liveErr := validateLiveConversation(ctx, old, target) + switch { + case liveErr == nil: + return activationSnapshot(old, target.kind, false), nil + case old.running.Load(): + return activationResult{}, fmt.Errorf("%w: %s: %v", errConversationBusy, sessionID, liveErr) + case meta == nil: + return activationResult{}, fmt.Errorf("conversation %s cannot be repaired without persisted metadata: %w", sessionID, liveErr) + default: + config.Logger().Printf("[web] activation: replacing idle unhealthy runtime %s: %v", sessionID, liveErr) + } + } + + eng, err := s.assembleConversationEngine(ctx, sessionID, target, buildMode) + if err != nil { + return activationResult{}, err + } + if restoredState != nil { + hydrateConversationEngine(eng, restoredState, mode.Parse(buildMode)) + } + if err := s.publishEngineCandidate(eng, old); err != nil { + eng.teardown() + return activationResult{}, fmt.Errorf("publish conversation %s: %w", eng.taskID, err) + } + if sessionID == "" { + s.stampCloudSync(eng.taskID, source, true) + } + return activationSnapshot(eng, target.kind, true), nil +} + +func (s *Server) assembleConversationEngine( + ctx context.Context, + sessionID string, + target conversationTarget, + modeName string, +) (*Engine, error) { + if target.kind == conversationLocal { + if s.newEngine == nil { + return nil, fmt.Errorf("activate local conversation: task creation is not supported") + } + eng, err := s.assembleLocalEngine(sessionID, target.pwd, modeName, s.newEngine) + if err != nil { + return nil, fmt.Errorf("activate local conversation: %w", err) + } + return eng, nil + } + + var ( + exec tools.RemoteExecutor + err error + ) + switch target.kind { + case conversationSSH: + exec, err = s.cloneHealthySSHLease(ctx, target) + if err == nil && exec != nil { + break + } + if err != nil { + config.Logger().Printf("[web] activation: healthy SSH lease clone failed for %s; dialing: %v", target.project, err) + } + if s.dialSSH != nil { + exec, err = s.dialSSH(ctx, target.sshHost, target.sshUser) + } else { + exec, err = remote.ConnectContext(ctx, remote.SSHOptions{Host: target.sshHost, User: target.sshUser}) + } + case conversationDocker: + if s.dialDocker != nil { + exec, err = s.dialDocker(ctx, target.container) + } else { + exec, err = remote.ConnectDocker(ctx, target.container) + } + } + if err != nil { + code := "docker_unavailable" + if target.kind == conversationSSH { + code = "ssh_connection_failed" + if strings.Contains(strings.ToLower(err.Error()), "unable to authenticate") || + strings.Contains(strings.ToLower(err.Error()), "no ssh credentials") { + code = "ssh_auth_required" + } + } + return nil, &conversationActivationError{ + code: code, kind: target.kind, retryable: true, + err: fmt.Errorf("connect %s conversation: %w", target.kind, err), + } + } + eng, buildErr := s.assembleRemoteEngine(sessionID, exec, target.pwd, modeName) + if buildErr != nil { + _ = exec.Close() + return nil, fmt.Errorf("activate %s conversation: %w", target.kind, buildErr) + } + return eng, nil +} + +// cloneHealthySSHLease reuses a transport only through the executor's explicit +// ref-counted lease contract. Sharing the same executor pointer would let one +// Engine teardown close the connection out from under every other task. +func (s *Server) cloneHealthySSHLease(ctx context.Context, target conversationTarget) (tools.RemoteExecutor, error) { + s.tasksMu.RLock() + engines := make([]*Engine, 0, len(s.tasks)) + for _, eng := range s.tasks { + engines = append(engines, eng) + } + s.tasksMu.RUnlock() + for _, eng := range engines { + if eng == nil || eng.env == nil { + continue + } + live, err := parseConversationTarget(engineProject(eng)) + if err != nil || live.kind != conversationSSH || !sameConversationLocation(live, target) { + continue + } + exec, ok := eng.env.Exec.(tools.RemoteExecutor) + if !ok { + continue + } + cloner, ok := exec.(tools.RemoteLeaseCloner) + if !ok { + continue + } + if err := exec.Probe(ctx); err != nil { + continue + } + return cloner.CloneLease() + } + return nil, nil +} + +func (s *Server) authorizeCloudRemoteWorkspace(target conversationTarget) error { + all, err := session.ListAllSessions() + if err != nil { + return fmt.Errorf("check remote workspace index: %w", err) + } + for project := range all { + indexed, parseErr := parseConversationTarget(project) + if parseErr == nil && sameConversationLocation(indexed, target) { + return nil + } + } + + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + if s.cfg != nil { + switch target.kind { + case conversationSSH: + for _, alias := range s.cfg.SSHAliases { + if strings.TrimSpace(alias.Path) == "" || strings.TrimSpace(alias.Addr) == "" { + continue + } + candidate, parseErr := parseConversationTarget("ssh://" + strings.TrimSpace(alias.Addr) + pathpkg.Clean(alias.Path)) + if parseErr == nil && sameConversationLocation(candidate, target) { + return nil + } + } + case conversationDocker: + for _, alias := range s.cfg.DockerAliases { + if strings.TrimSpace(alias.Path) == "" || strings.TrimSpace(alias.Container) == "" { + continue + } + candidate, parseErr := parseConversationTarget("docker://" + strings.TrimSpace(alias.Container) + pathpkg.Clean(alias.Path)) + if parseErr == nil && sameConversationLocation(candidate, target) { + return nil + } + } + } + } + return fmt.Errorf("%w: remote project %q is neither indexed nor a saved alias", errWorkspaceNotAllowed, target.project) +} + +func hydrateConversationEngine( + eng *Engine, + state *session.SessionState, + restoredMode mode.SessionMode, +) { + if eng.rebuildForRole != nil { + eng.rebuildMu.Lock() + provider, modelName, _ := eng.modelSnapshot() + built, err := eng.rebuildForRole(state.Agent, provider, modelName) + if err != nil { + config.Logger().Printf("[web] activation: custom agent %q unavailable for %s: %v", state.Agent, eng.taskID, err) + if fallback, fallbackErr := eng.rebuildForRole("", provider, modelName); fallbackErr == nil { + eng.applyAgentRoleSwitch("", fallback) + } + } else { + eng.applyAgentRoleSwitch(state.Agent, built) + } + eng.rebuildMu.Unlock() + } + + eng.emu.Lock() + eng.history = state.History + eng.emu.Unlock() + if eng.approvalState != nil { + eng.approvalState.SetSessionMode(restoredMode) + } + if eng.todoStore != nil { + items := make([]tools.TodoItem, len(state.Todos)) + for i, item := range state.Todos { + items[i] = tools.TodoItem{ID: item.ID, Title: item.Title, Status: tools.TodoStatus(item.Status)} + } + eng.todoStore.Update(items) + } + if eng.env != nil && eng.env.GoalStore != nil { + eng.env.GoalStore.RestoreFromSnapshot(state.Goal) + } +} + +func engineProject(eng *Engine) string { + if eng == nil { + return "" + } + eng.emu.Lock() + recorder := eng.recorder + eng.emu.Unlock() + if recorder != nil && recorder.Project() != "" { + return recorder.Project() + } + if eng.env != nil { + if exec, ok := eng.env.Exec.(tools.RemoteExecutor); ok { + return exec.ProjectLabel(eng.pwd) + } + } + return eng.pwd +} + +func validateLiveConversation(ctx context.Context, eng *Engine, target conversationTarget) error { + if eng == nil || eng.env == nil { + return fmt.Errorf("conversation %s has no execution environment", eng.taskID) + } + project := engineProject(eng) + liveTarget, err := parseConversationTarget(project) + if err != nil { + return fmt.Errorf("conversation %s has invalid live project %q: %w", eng.taskID, project, err) + } + if liveTarget.kind != target.kind || !sameConversationLocation(liveTarget, target) { + return fmt.Errorf("conversation %s is live on %q, persisted target is %q", eng.taskID, project, target.project) + } + if target.kind == conversationLocal { + if eng.env.IsRemote() { + return fmt.Errorf("conversation %s is unexpectedly bound to a remote executor", eng.taskID) + } + return nil + } + if !eng.env.IsRemote() { + return fmt.Errorf("conversation %s is remote in the session index but live on the local executor", eng.taskID) + } + exec, ok := eng.env.Exec.(tools.RemoteExecutor) + if !ok { + return fmt.Errorf("conversation %s has an unsupported remote executor", eng.taskID) + } + if err := exec.Probe(ctx); err != nil { + return fmt.Errorf("conversation %s %s connection is unhealthy: %w", eng.taskID, target.kind, err) + } + return nil +} + +func sameConversationLocation(a, b conversationTarget) bool { + if a.kind != b.kind || a.pwd != b.pwd { + return false + } + switch a.kind { + case conversationLocal: + return filepath.Clean(a.project) == filepath.Clean(b.project) + case conversationSSH: + return sameSSHHost(a.sshHost, b.sshHost) && a.sshUser == b.sshUser + case conversationDocker: + return a.container == b.container + default: + return false + } +} + +func sameSSHHost(a, b string) bool { + normalize := func(host string) string { + host = strings.TrimSpace(host) + name, port, err := net.SplitHostPort(host) + if err != nil { + name, port = strings.Trim(host, "[]"), "22" + } + return net.JoinHostPort(strings.ToLower(name), port) + } + return normalize(a) == normalize(b) +} diff --git a/internal/web/activation_test.go b/internal/web/activation_test.go new file mode 100644 index 00000000..70ce9a74 --- /dev/null +++ b/internal/web/activation_test.go @@ -0,0 +1,306 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/cloudwego/eino/adk" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/session" + "github.com/cnjack/jcode/internal/tools" +) + +type activationRemoteExecutor struct { + project string + probeErr error + closed atomic.Bool +} + +func (e *activationRemoteExecutor) ReadFile(context.Context, string) ([]byte, error) { + return nil, os.ErrNotExist +} +func (e *activationRemoteExecutor) WriteFile(context.Context, string, []byte, os.FileMode) error { + return nil +} +func (e *activationRemoteExecutor) MkdirAll(context.Context, string, os.FileMode) error { + return nil +} +func (e *activationRemoteExecutor) Stat(context.Context, string) (*tools.FileInfo, error) { + return nil, os.ErrNotExist +} +func (e *activationRemoteExecutor) Exec(context.Context, string, string, time.Duration) (string, string, error) { + return "", "", nil +} +func (e *activationRemoteExecutor) Platform() string { return "linux/amd64" } +func (e *activationRemoteExecutor) Label() string { return e.project } +func (e *activationRemoteExecutor) ProjectLabel(string) string { return e.project } +func (e *activationRemoteExecutor) Probe(context.Context) error { return e.probeErr } +func (e *activationRemoteExecutor) Close() error { e.closed.Store(true); return nil } + +func recordActivationSession(t *testing.T, id, project string) { + recordActivationSessionWithModel(t, id, project, "test-provider", "test-model") +} + +func recordActivationSessionWithModel(t *testing.T, id, project, provider, model string) { + t.Helper() + recorder, err := session.NewRecorder(project, provider, model) + if err != nil { + t.Fatal(err) + } + recorder.SetUUID(id) + recorder.RecordUser("persisted turn") + recorder.Close() +} + +func activationTestServer(t *testing.T, blockRole <-chan struct{}) (*Server, *Engine, *atomic.Int32) { + t.Helper() + activePwd := t.TempDir() + activeEnv := tools.NewEnv(activePwd, "darwin/arm64") + active := newEngine(&EngineConfig{ + TaskID: "active-task", Pwd: activePwd, Mode: "approval", + ProviderName: "test-provider", ModelName: "test-model", + Env: activeEnv, TodoStore: activeEnv.TodoStore, Handler: handler.NewWebHandler(), + }) + dials := &atomic.Int32{} + s := &Server{ + Engine: active, tasks: map[string]*Engine{"active-task": active}, + wsBroker: NewWSBroker(), cfg: &config.Config{}, ptyMgr: newPTYManager(), + } + s.dialSSH = func(_ context.Context, host, user string) (tools.RemoteExecutor, error) { + dials.Add(1) + return &activationRemoteExecutor{project: "ssh://" + user + "@" + host + "/work"}, nil + } + s.newRemoteEngine = func(taskID string, exec tools.RemoteExecutor, pwd, modeName string) (*EngineConfig, error) { + env := tools.NewEnv(pwd, exec.Platform()) + env.SetRemote(exec, pwd) + recorder, _ := session.NewRecorder(exec.ProjectLabel(pwd), "test-provider", "test-model") + recorder.SetUUID(taskID) + cfg := &EngineConfig{ + TaskID: taskID, Pwd: pwd, Mode: modeName, + ProviderName: "test-provider", ModelName: "test-model", + Env: env, TodoStore: env.TodoStore, Recorder: recorder, + Handler: handler.NewWebHandler(), + CreateAgent: func(string, string) (*adk.ChatModelAgent, error) { + return &adk.ChatModelAgent{}, nil + }, + } + if blockRole != nil { + cfg.RebuildForRole = func(string, string, string) (*AgentRoleBuild, error) { + <-blockRole + return &AgentRoleBuild{Provider: "test-provider", Model: "test-model"}, nil + } + } + return cfg, nil + } + t.Cleanup(s.CloseAllEngines) + return s, active, dials +} + +func TestEnsureConversationColdResumeUsesCurrentDefaultModel(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( + id = "remote-model-session" + project = "ssh://alice@example.test:22/work" + ) + // Session metadata describes the model used when the transcript was + // created. Cold resume has historically followed the current/default model; + // treating the old fields as a live pair can combine a stale provider with a + // model id that now belongs to another provider. + recordActivationSessionWithModel(t, id, project, "legacy-provider", "grok-4.5") + s, _, _ := activationTestServer(t, nil) + + if _, err := s.ensureConversation(context.Background(), id, "", "desktop"); err != nil { + t.Fatal(err) + } + provider, modelName, _ := s.resolveEngine(id).modelSnapshot() + if provider != "test-provider" || modelName != "test-model" { + t.Fatalf("cold resume model = %s/%s, want current default test-provider/test-model", provider, modelName) + } +} + +func TestEnsureConversationColdRemoteHydratesBeforePublish(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( + id = "remote-cold-session" + project = "ssh://alice@example.test:22/work" + ) + recordActivationSession(t, id, project) + release := make(chan struct{}) + s, active, dials := activationTestServer(t, release) + + done := make(chan error, 1) + go func() { + _, err := s.ensureConversation(context.Background(), id, "", "cloud") + done <- err + }() + // The factory has dialed, but role hydration is intentionally blocked. The + // candidate must remain invisible until hydration finishes. + deadline := time.Now().Add(time.Second) + for dials.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if got := s.resolveEngine(id); got != nil { + t.Fatalf("unhydrated candidate was published: %p", got) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + eng := s.resolveEngine(id) + if eng == nil { + t.Fatal("hydrated conversation was not published") + } + eng.emu.Lock() + historyLen := len(eng.history) + eng.emu.Unlock() + if historyLen != 1 { + t.Fatalf("history length = %d, want 1", historyLen) + } + if s.activeEngine() != active { + t.Fatal("background activation changed the Desktop foreground") + } + if eng.env == nil || !eng.env.IsRemote() { + t.Fatal("persisted remote session fell back to a local engine") + } +} + +func TestEnsureConversationReplacesIdleUnhealthyRuntime(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( + id = "remote-stale-session" + project = "ssh://alice@example.test:22/work" + ) + recordActivationSession(t, id, project) + s, _, dials := activationTestServer(t, nil) + if _, err := s.ensureConversation(context.Background(), id, "", "cloud"); err != nil { + t.Fatal(err) + } + old := s.resolveEngine(id) + oldExec := old.env.Exec.(*activationRemoteExecutor) + oldExec.probeErr = errors.New("transport closed") + + if _, err := s.ensureConversation(context.Background(), id, "", "cloud"); err != nil { + t.Fatal(err) + } + if got := s.resolveEngine(id); got == nil || got == old { + t.Fatal("idle unhealthy runtime was not atomically replaced") + } + if !oldExec.closed.Load() { + t.Fatal("replaced runtime did not release its remote lease") + } + if dials.Load() != 2 { + t.Fatalf("dial count = %d, want 2", dials.Load()) + } +} + +func TestParseConversationTargetRejectsRemoteDowngrade(t *testing.T) { + for _, project := range []string{ + "ftp://host/work", + "ssh://host/work", + "ssh://user:secret@host/work", + "docker:///work", + } { + if _, err := parseConversationTarget(project); err == nil { + t.Errorf("parseConversationTarget(%q) unexpectedly succeeded", project) + } + } +} + +func TestRemoteConnRegistryClaimRestoreAndShutdown(t *testing.T) { + rg := newRemoteConnRegistry() + exec := &activationRemoteExecutor{project: "ssh://alice@example.test:22/work"} + id := rg.add(&pendingConn{exec: exec, createdAt: time.Now()}) + + claimed := rg.claim(id) + if claimed == nil || claimed.exec != exec { + t.Fatal("claim did not transfer the pending connection") + } + rg.closeAll() + if exec.closed.Load() { + t.Fatal("registry shutdown closed a connection owned by an in-flight claim") + } + rg.restore(id, claimed) + rg.closeAll() + if !exec.closed.Load() { + t.Fatal("registry shutdown did not close a restored pending connection") + } + if got := rg.get(id); got != nil { + t.Fatal("registry retained a connection after shutdown") + } +} + +func TestRemoteBindHydratesExistingSessionWithoutImplicitFocus(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const ( + id = "remote-bind-session" + project = "ssh://alice@example.test:22/work" + ) + recordActivationSession(t, id, project) + s, active, _ := activationTestServer(t, nil) + s.remoteConns = newRemoteConnRegistry() + exec := &activationRemoteExecutor{project: project} + connectionID := s.remoteConns.add(&pendingConn{exec: exec, createdAt: time.Now()}) + body, _ := json.Marshal(map[string]any{ + "connection_id": connectionID, + "path": "/work", + "session_id": id, + }) + recorder := httptest.NewRecorder() + s.handleRemoteBind(recorder, httptest.NewRequest(http.MethodPost, "/api/remote/bind", bytes.NewReader(body))) + if recorder.Code != http.StatusOK { + t.Fatalf("bind code=%d body=%s", recorder.Code, recorder.Body.String()) + } + if s.activeEngine() != active { + t.Fatal("background existing-session bind changed the foreground") + } + eng := s.resolveEngine(id) + if eng == nil || !eng.env.IsRemote() { + t.Fatal("existing remote session was not published on its remote executor") + } + eng.emu.Lock() + historyLen := len(eng.history) + eng.emu.Unlock() + if historyLen != 1 { + t.Fatalf("hydrated history length=%d, want 1", historyLen) + } + if s.remoteConns.get(connectionID) != nil { + t.Fatal("bound connection remained in the pending registry") + } +} + +func TestRemoteBindNewWorkspacePreservesLegacyImplicitFocus(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + const project = "ssh://alice@example.test:22/work" + s, active, _ := activationTestServer(t, nil) + s.remoteConns = newRemoteConnRegistry() + exec := &activationRemoteExecutor{project: project} + connectionID := s.remoteConns.add(&pendingConn{exec: exec, createdAt: time.Now()}) + body, _ := json.Marshal(map[string]any{ + "connection_id": connectionID, + "path": "/work", + }) + recorder := httptest.NewRecorder() + s.handleRemoteBind(recorder, httptest.NewRequest(http.MethodPost, "/api/remote/bind", bytes.NewReader(body))) + if recorder.Code != http.StatusOK { + t.Fatalf("bind code=%d body=%s", recorder.Code, recorder.Body.String()) + } + if s.activeEngine() == active || engineProject(s.activeEngine()) != project { + t.Fatalf("new workspace was not focused: active project=%q", engineProject(s.activeEngine())) + } + var result activationResult + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.Focused { + t.Fatal("new workspace bind response did not report implicit focus") + } +} diff --git a/internal/web/approval.go b/internal/web/approval.go index 741203c8..a744c68f 100644 --- a/internal/web/approval.go +++ b/internal/web/approval.go @@ -34,19 +34,21 @@ func (s *Server) handleGetGoal(w http.ResponseWriter, _ *http.Request) { // handleSetGoal sets (or replaces) the session goal. Unless start=false, it also // kicks off an agent run so work begins immediately. func (s *Server) handleSetGoal(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil || eng.env == nil || eng.env.GoalStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "goals not available"}) - return - } var req struct { Objective string `json:"objective"` Start *bool `json:"start,omitempty"` // default true + TaskID string `json:"task_id,omitempty"` + Source string `json:"source,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + eng := s.resolveEngine(req.TaskID) + if eng == nil || eng.env == nil || eng.env.GoalStore == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task goals not available"}) + return + } objective, err := tools.ValidateGoalObjective(req.Objective) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) @@ -59,7 +61,7 @@ func (s *Server) handleSetGoal(w http.ResponseWriter, r *http.Request) { // will pick the goal up after the current run finishes. Targets the active // task. if eng.running.CompareAndSwap(false, true) { - s.submitMessage(eng, tools.GoalKickoffPrompt(objective), eng.curMode(), "", "", nil) + s.submitMessage(eng, tools.GoalKickoffPrompt(objective), eng.curMode(), req.Source, req.TaskID, nil) } } writeJSON(w, http.StatusOK, g) diff --git a/internal/web/chat.go b/internal/web/chat.go index a278c368..d0b8c166 100644 --- a/internal/web/chat.go +++ b/internal/web/chat.go @@ -50,7 +50,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // Resolve (or lazily create) the engine for this task. Different tasks run // concurrently; the per-task running flag only blocks double-running the SAME // task. - eng, err := s.engineForChat(req.SessionID, modeStr) + eng, err := s.engineForChatContext(r.Context(), req.SessionID, modeStr) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -71,6 +71,27 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // its engine; an unknown id lazily spins up a fresh engine for it (a new task or // the first message of a not-yet-live task), rooted at the active task's pwd. func (s *Server) engineForChat(taskID, modeStr string) (*Engine, error) { + return s.engineForChatContext(context.Background(), taskID, modeStr) +} + +func (s *Server) engineForChatContext(ctx context.Context, taskID, modeStr string) (*Engine, error) { + if taskID != "" { + meta, err := session.FindSessionMeta(taskID) + if err != nil { + return nil, fmt.Errorf("load conversation metadata: %w", err) + } + if meta != nil { + result, err := s.ensureConversation(ctx, taskID, "", "") + if err != nil { + return nil, err + } + eng := s.resolveEngine(result.SessionID) + if eng == nil { + return nil, fmt.Errorf("activated task %s is unavailable", result.SessionID) + } + return eng, nil + } + } if eng := s.resolveEngine(taskID); eng != nil { return eng, nil } @@ -84,11 +105,37 @@ func (s *Server) engineForChat(taskID, modeStr string) (*Engine, error) { return eng, nil } } - pwd := "" - if a := s.activeEngine(); a != nil { - pwd = a.pwd + project := engineProject(s.activeEngine()) + if project == "" { + // Setup-focused tests and embedders may have a bootstrap engine without a + // workspace yet. This branch is only for a genuinely new, non-indexed task; + // persisted remote ids were resolved above and can never reach it. + if s.newEngine == nil { + return nil, fmt.Errorf("task creation is not supported") + } + eng, err := s.assembleLocalEngine(taskID, "", modeStr, s.newEngine) + if err != nil { + return nil, err + } + if err := s.publishEngineCandidate(eng, nil); err != nil { + eng.teardown() + return nil, err + } + return eng, nil + } + target, err := parseConversationTarget(project) + if err != nil { + return nil, fmt.Errorf("resolve active conversation target: %w", err) } - return s.buildLocalEngine(taskID, pwd, modeStr) + eng, err := s.assembleConversationEngine(ctx, taskID, target, modeStr) + if err != nil { + return nil, err + } + if err := s.publishEngineCandidate(eng, nil); err != nil { + eng.teardown() + return nil, err + } + return eng, nil } // chatImage represents a base64-encoded image in a chat request. @@ -170,14 +217,16 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str // (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 != "" { - eng.handler.Emit("user_message", map[string]string{ - "content": message, - "source": source, - }) + // Every durable user turn is emitted so the Cloud event mirror cannot miss + // messages composed on Desktop. Desktop already renders its own optimistic + // message, so local_echo lets that frontend ignore only this echoed event; + // remote/channel messages carry their source and render normally. One call + // emits exactly once, including Cloud-originated turns. + userEvent := map[string]any{"content": message, "source": source} + if source == "" { + userEvent["local_echo"] = true } + eng.handler.Emit("user_message", userEvent) // Ensure a recorder exists (lazy creation on first message). // If the client provided a session_id and the current recorder differs, diff --git a/internal/web/engine.go b/internal/web/engine.go index f2ca7a36..063fec61 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -307,16 +307,17 @@ func (e *Engine) recUUID() string { } // applyModelSwitch swaps the engine's agent + provider/model under emu and -// re-tags the recorder with the new model. +// re-tags the recorder with the new provider/model pair. func (e *Engine) applyModelSwitch(ag *adk.ChatModelAgent, provider, model string) { e.emu.Lock() - defer e.emu.Unlock() e.agent = ag e.agentRevision++ e.providerName = provider e.modelName = model - if e.recorder != nil { - e.recorder.SetModel(model) + rec := e.recorder + e.emu.Unlock() + if rec != nil { + rec.SetProviderModel(provider, model) } } @@ -344,8 +345,8 @@ func (e *Engine) applyAgentRoleSwitch(roleName string, built *AgentRoleBuild) { e.emu.Unlock() if rec != nil { rec.SetAgent(roleName) - if built != nil && built.Model != "" { - rec.SetModel(built.Model) + if built != nil && built.Provider != "" && built.Model != "" { + rec.SetProviderModel(built.Provider, built.Model) } } } @@ -405,11 +406,7 @@ func (s *Server) registerEngine(eng *Engine) error { if eng == nil { return nil } - if eng.handler != nil { - eng.handler.SetModePromotionCallback(func() error { - return s.syncModeAfterApproval(eng, true, true) - }) - } + s.prepareEngineRegistration(eng) if eng.taskID == "" { return nil } @@ -444,6 +441,112 @@ func (s *Server) registerEngine(eng *Engine) error { return nil } +func (s *Server) prepareEngineRegistration(eng *Engine) { + if eng == nil { + return + } + if eng.handler != nil { + eng.handler.SetModePromotionCallback(func() error { + return s.syncModeAfterApproval(eng, true, true) + }) + } + if eng.env == nil { + return + } + statusSource, ok := eng.env.Exec.(tools.RemoteConnectionStatusSource) + if !ok { + return + } + taskID := eng.taskID + statusSource.SetRemoteConnectionStatusHandler(func(status tools.RemoteConnectionStatus) { + // Reconnect state is task-scoped control-plane data. It bypasses the + // WebHandler pump because it originates below the agent runner, but uses + // the same task-tagged broker envelope so task subscriptions still apply. + if taskID == "" || s.wsBroker == nil { + return + } + s.wsBroker.Broadcast(WSEvent{ + TaskID: taskID, + Type: "remote_connection_status", + Data: status, + }) + }) +} + +// publishEngineCandidate atomically installs a fully built and hydrated engine. +// expected is nil for a cold/new task and the currently published engine for an +// idle reconnect. The old runtime is never removed before its replacement is +// ready, so concurrent task resolution cannot observe an empty or unhydrated +// conversation. Callers serialize this with taskCreateMu. +func (s *Server) publishEngineCandidate(eng, expected *Engine) error { + if eng == nil || eng.taskID == "" { + return fmt.Errorf("cannot publish an engine without a task id") + } + s.prepareEngineRegistration(eng) + + base := s.rootCtx() + if base == nil { + base = context.Background() + } + pumpCtx, cancel := context.WithCancel(base) + eng.pumpCancel = cancel + + s.tasksMu.Lock() + existing, exists := s.tasks[eng.taskID] + s.mu.Lock() + activeMatchesExpected := expected != nil && s.Engine == expected + if expected == nil { + if exists { + s.mu.Unlock() + s.tasksMu.Unlock() + cancel() + return errTaskAlreadyRegistered + } + if len(s.tasks) >= maxLiveEngines { + s.mu.Unlock() + s.tasksMu.Unlock() + cancel() + return errTooManyTasks + } + } else { + if expected.taskID != eng.taskID { + s.mu.Unlock() + s.tasksMu.Unlock() + cancel() + return fmt.Errorf("replacement task id %s does not match %s", eng.taskID, expected.taskID) + } + if (exists && existing != expected) || (!exists && !activeMatchesExpected) { + s.mu.Unlock() + s.tasksMu.Unlock() + cancel() + return fmt.Errorf("task engine changed during activation") + } + if expected.running.Load() { + s.mu.Unlock() + s.tasksMu.Unlock() + cancel() + return fmt.Errorf("conversation %s is running; refusing to replace its runtime", eng.taskID) + } + } + s.tasks[eng.taskID] = eng + if activeMatchesExpected { + // Replacing an unhealthy runtime for the same foreground identity is not a + // focus change. Keep Desktop attached to the repaired engine. + s.Engine = eng + } + s.mu.Unlock() + s.tasksMu.Unlock() + + s.startPump(pumpCtx, eng) + if expected != nil { + if s.ptyMgr != nil { + s.ptyMgr.closeForTask(expected.taskID) + } + expected.teardown() + } + return nil +} + // startPump forwards eng's handler events to the WS broker, stamped with the // engine's task id, until ctx is cancelled (teardown) or the channel closes. // Each engine gets its own pump so concurrent tasks never serialize on one @@ -478,6 +581,20 @@ func (s *Server) buildLocalEngine(taskID, pwd, modeStr string) (*Engine, error) // runs can pass the headless factory (which drops interactive tools) while // sharing all the registration/model-inheritance plumbing with normal tasks. func (s *Server) buildLocalEngineWith(taskID, pwd, modeStr string, factory func(taskID, pwd, mode string) (*EngineConfig, error)) (*Engine, error) { + eng, err := s.assembleLocalEngine(taskID, pwd, modeStr, factory) + if err != nil { + return nil, err + } + if err := s.registerEngine(eng); err != nil { + eng.teardown() + return nil, err + } + return eng, nil +} + +// assembleLocalEngine constructs a candidate without publishing it. Activation +// uses this to finish hydration before the task becomes resolvable. +func (s *Server) assembleLocalEngine(taskID, pwd, modeStr string, factory func(taskID, pwd, mode string) (*EngineConfig, error)) (*Engine, error) { ec, err := factory(taskID, pwd, modeStr) if err != nil { return nil, err @@ -500,15 +617,12 @@ func (s *Server) buildLocalEngineWith(taskID, pwd, modeStr string, factory func( } } } - if err := s.registerEngine(eng); err != nil { - eng.teardown() - return nil, err - } return eng, nil } -// buildRemoteEngine creates and registers a fresh remote (SSH or Docker) task engine. -func (s *Server) buildRemoteEngine(taskID string, exec tools.RemoteExecutor, remotePwd, modeStr string) (*Engine, error) { +// assembleRemoteEngine constructs an unpublished remote candidate. The caller +// retains ownership of exec until the candidate is atomically published. +func (s *Server) assembleRemoteEngine(taskID string, exec tools.RemoteExecutor, remotePwd, modeStr string) (*Engine, error) { if s.newRemoteEngine == nil { return nil, fmt.Errorf("remote task creation is not supported") } @@ -517,10 +631,6 @@ func (s *Server) buildRemoteEngine(taskID string, exec tools.RemoteExecutor, rem return nil, err } eng := newEngine(ec) - if err := s.registerEngine(eng); err != nil { - eng.teardown() - return nil, err - } return eng, nil } @@ -566,7 +676,7 @@ func (s *Server) setActiveEngine(eng *Engine) { // pwd, so remote workspaces never clobber the local entry) — health reports // it after a restart so clients return to their last conversation. Runs // outside s.mu: this is best-effort file I/O. - session.SaveLastSession(eng.pwd, eng.taskID) + session.SaveLastSession(engineProject(eng), eng.taskID) } // deleteEngine removes a task engine from the map and tears it down (stops its @@ -648,7 +758,7 @@ func (s *Server) setTaskStatus(eng *Engine, running bool) { "task_id": eng.taskID, "running": running, "status": status, - "project": eng.pwd, + "project": engineProject(eng), "updated_at": now, }}) go func(id, st, ts string) { @@ -661,6 +771,9 @@ func (s *Server) setTaskStatus(eng *Engine, running bool) { // CloseAllEngines tears down every live engine. Called on server shutdown. func (s *Server) CloseAllEngines() { + if s.remoteConns != nil { + s.remoteConns.closeAll() + } s.tasksMu.Lock() engines := make([]*Engine, 0, len(s.tasks)) for _, e := range s.tasks { diff --git a/internal/web/models.go b/internal/web/models.go index 6c5f00ac..20681017 100644 --- a/internal/web/models.go +++ b/internal/web/models.go @@ -307,23 +307,23 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSwitchModel(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) - return - } - // No running gate: applyModelSwitch swaps eng.agent under eng.emu (the lock the - // run reads it under), so a mid-run switch is safe and takes effect next turn — - // consistent with mode/approval switching. - var req struct { Provider string `json:"provider"` Model string `json:"model"` + TaskID string `json:"task_id,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } + eng := s.resolveEngine(req.TaskID) + if eng == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + // No running gate: applyModelSwitch swaps eng.agent under eng.emu (the lock the + // run reads it under), so a mid-run switch is safe and takes effect next turn — + // consistent with mode/approval switching. if req.Provider == "" || req.Model == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) return @@ -350,6 +350,14 @@ func (s *Server) handleSwitchModel(w http.ResponseWriter, r *http.Request) { } eng.applyModelSwitch(ag, req.Provider, req.Model) eng.rebuildMu.Unlock() + if req.TaskID != "" { + s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ + "provider": req.Provider, + "model": req.Model, + }}) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } // Persist the selection so a restart resumes on this model — matches the // TUI model picker, which writes cfg.Model on every switch. In-place on the @@ -391,7 +399,8 @@ func (s *Server) handleSwitchModel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSwitchMode(w http.ResponseWriter, r *http.Request) { var req struct { - Mode string `json:"mode"` + Mode string `json:"mode"` + TaskID string `json:"task_id,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) @@ -406,9 +415,9 @@ func (s *Server) handleSwitchMode(w http.ResponseWriter, r *http.Request) { } sm := mode.Parse(req.Mode) - eng := s.activeEngine() + eng := s.resolveEngine(req.TaskID) if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return } // No running gate: applyModeSwitch writes eng.agent under eng.emu, the same diff --git a/internal/web/remote.go b/internal/web/remote.go index 4fe7398e..1c13024d 100644 --- a/internal/web/remote.go +++ b/internal/web/remote.go @@ -2,6 +2,7 @@ package web import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -13,7 +14,9 @@ import ( "github.com/google/uuid" "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/mode" "github.com/cnjack/jcode/internal/remote" + "github.com/cnjack/jcode/internal/session" "github.com/cnjack/jcode/internal/tools" ) @@ -57,19 +60,41 @@ func (rg *remoteConnRegistry) add(pc *pendingConn) string { func (rg *remoteConnRegistry) get(id string) *pendingConn { rg.mu.Lock() defer rg.mu.Unlock() - return rg.conns[id] + rg.sweepLocked() + pc := rg.conns[id] + if pc != nil { + // The TTL is idle time, not total wizard time. Directory browsing may + // legitimately take longer than ten minutes on a large remote tree. + pc.createdAt = time.Now() + } + return pc } -// take removes a connection WITHOUT closing it: ownership transfers to the -// caller (e.g. the live env after a successful bind). -func (rg *remoteConnRegistry) take(id string) *pendingConn { +// claim removes a connection WITHOUT closing it while a bind is in progress. +// This prevents a concurrent cancel or second bind from closing/reusing the +// executor during candidate construction. The caller either transfers ownership +// to the published engine or restores the same id on failure. +func (rg *remoteConnRegistry) claim(id string) *pendingConn { rg.mu.Lock() defer rg.mu.Unlock() + rg.sweepLocked() pc := rg.conns[id] delete(rg.conns, id) return pc } +func (rg *remoteConnRegistry) restore(id string, pc *pendingConn) { + if id == "" || pc == nil { + return + } + rg.mu.Lock() + if _, exists := rg.conns[id]; !exists { + pc.createdAt = time.Now() + rg.conns[id] = pc + } + rg.mu.Unlock() +} + // drop removes and closes a pending connection (cancel / abandon). func (rg *remoteConnRegistry) drop(id string) { rg.mu.Lock() @@ -81,6 +106,21 @@ func (rg *remoteConnRegistry) drop(id string) { } } +// closeAll releases connections that were established in the wizard but never +// bound to an Engine. Engine teardown only sees published runtimes, so pending +// SSH transports need their own shutdown path. +func (rg *remoteConnRegistry) closeAll() { + rg.mu.Lock() + conns := rg.conns + rg.conns = make(map[string]*pendingConn) + rg.mu.Unlock() + for _, pc := range conns { + if pc != nil && pc.exec != nil { + _ = pc.exec.Close() + } + } +} + // sweepLocked closes and removes connections older than the TTL. Caller holds mu. func (rg *remoteConnRegistry) sweepLocked() { now := time.Now() @@ -112,6 +152,10 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { KeyPath string `json:"key_path"` Passphrase string `json:"passphrase"` Container string `json:"container"` // docker: container id or name + // SSH TOFU confirmation: both fields must be supplied together on the + // retry after an ssh_host_key_unknown response. + AcceptHostKey bool `json:"accept_host_key"` + HostKeyFingerprint string `json:"host_key_fingerprint"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) @@ -119,7 +163,7 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { } if req.Type == "docker" { - s.connectDocker(w, r, strings.TrimSpace(req.Container)) + s.connectDockerWizard(w, r, strings.TrimSpace(req.Container)) return } if req.Type != "" && req.Type != "ssh" { @@ -131,7 +175,13 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { return } - opts := remote.SSHOptions{Host: req.Host, Port: req.Port, User: req.User} + opts := remote.SSHOptions{ + Host: req.Host, + Port: req.Port, + User: req.User, + AcceptHostKey: req.AcceptHostKey, + HostKeyFingerprint: req.HostKeyFingerprint, + } if req.AuthMethod == "password" { opts.Password = req.Password } else { @@ -139,8 +189,11 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { opts.Passphrase = req.Passphrase } - exec, err := remote.Connect(opts) + exec, err := remote.ConnectContext(r.Context(), opts) if err != nil { + if writeSSHHostKeyError(w, err) { + return + } writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } @@ -164,9 +217,34 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { }) } +// writeSSHHostKeyError writes the stable API contract used by Desktop's trust +// prompt. It deliberately returns 409 for all trust-state conflicts while +// keeping ordinary authentication/network failures on the existing 502 path. +func writeSSHHostKeyError(w http.ResponseWriter, err error) bool { + var hostKeyErr *remote.SSHHostKeyError + if !errors.As(err, &hostKeyErr) { + return false + } + payload := map[string]any{ + "error": hostKeyErr.Error(), + "code": hostKeyErr.Code, + "host": hostKeyErr.Host, + "fingerprint": hostKeyErr.Fingerprint, + "key_type": hostKeyErr.KeyType, + } + if hostKeyErr.OldFingerprint != "" { + payload["old_fingerprint"] = hostKeyErr.OldFingerprint + } + if hostKeyErr.ExpectedFingerprint != "" { + payload["expected_fingerprint"] = hostKeyErr.ExpectedFingerprint + } + writeJSON(w, http.StatusConflict, payload) + return true +} + // connectDocker binds (starting if stopped) the named container and parks it in // the pending registry, mirroring the SSH connect flow. -func (s *Server) connectDocker(w http.ResponseWriter, r *http.Request, containerRef string) { +func (s *Server) connectDockerWizard(w http.ResponseWriter, r *http.Request, containerRef string) { if containerRef == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "container is required"}) return @@ -233,9 +311,10 @@ func (s *Server) handleRemoteListDir(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"path": path, "dirs": dirs}) } -// handleRemoteBind commits a pending connection: it binds the shared env to the -// remote executor at the chosen directory and rebuilds the agent (same path as -// a local project switch). +// handleRemoteBind atomically turns an explicitly authenticated/trusted pending +// connection into either a new task or the replacement runtime for a persisted +// remote conversation. The candidate is hydrated before publication, so a +// session id never briefly resolves to an empty-history engine. func (s *Server) handleRemoteBind(w http.ResponseWriter, r *http.Request) { // No running gate: binding a remote workspace builds a NEW engine; the // previous task keeps running in the background. @@ -246,12 +325,19 @@ func (s *Server) handleRemoteBind(w http.ResponseWriter, r *http.Request) { var req struct { ConnectionID string `json:"connection_id"` Path string `json:"path"` + SessionID string `json:"session_id,omitempty"` + Focus bool `json:"focus,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } - pc := s.remoteConns.get(req.ConnectionID) + // Preserve the original wizard contract for new workspaces: older clients + // did not send a focus flag because bind always foregrounded the new task. + // Existing-session reconnects opt in explicitly so a candidate can be + // prepared in the background while its history page is still loading. + focus := req.Focus || req.SessionID == "" + pc := s.remoteConns.claim(req.ConnectionID) if pc == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "connection expired or not found"}) return @@ -261,45 +347,107 @@ func (s *Server) handleRemoteBind(w http.ResponseWriter, r *http.Request) { remotePwd = remote.DiscoverPwd(r.Context(), pc.exec, "/root") } - // Snapshot the outgoing task once, then build the new engine BEFORE tearing - // anything down — a failed bind must not disrupt the current task's PTYs. - prevTaskID, curMode := "", "" - if cur := s.activeEngine(); cur != nil { - prevTaskID, curMode = cur.taskID, cur.curMode() + label := pc.exec.ProjectLabel(remotePwd) + target, targetErr := parseConversationTarget(label) + if targetErr != nil { + s.remoteConns.restore(req.ConnectionID, pc) + writeConversationActivationError(w, fmt.Errorf("%w: %v", errInvalidConversationTarget, targetErr)) + return + } + + s.taskCreateMu.Lock() + var old *Engine + if req.SessionID != "" { + old = s.resolveEngine(req.SessionID) + } + buildMode := mode.Approval.String() + var ( + meta *session.SessionMeta + state *session.SessionState + err error + ) + if req.SessionID != "" { + meta, err = session.FindSessionMeta(req.SessionID) + if err == nil && meta == nil { + err = fmt.Errorf("%w: %s", errConversationNotFound, req.SessionID) + } + if err == nil { + var entries []session.Entry + entries, err = session.LoadSession(req.SessionID) + if err == nil { + state = session.ReconstructState(entries) + } + } + if err == nil { + persistedTarget, parseErr := parseConversationTarget(meta.Project) + if parseErr != nil || !sameConversationLocation(persistedTarget, target) { + err = fmt.Errorf("%w: authenticated target %q does not match persisted project %q", errInvalidConversationTarget, label, meta.Project) + } + } + if err == nil { + if savedMode, modeErr := session.LoadSessionModeStrict(req.SessionID); modeErr == nil { + buildMode = restoredWebSessionMode(savedMode).String() + } + } + } + if err == nil && old != nil && old.running.Load() { + err = fmt.Errorf("%w: %s", errConversationBusy, req.SessionID) + } + var eng *Engine + if err == nil { + eng, err = s.assembleRemoteEngine(req.SessionID, pc.exec, remotePwd, buildMode) } - eng, err := s.buildRemoteEngine("", pc.exec, remotePwd, curMode) + if err == nil && state != nil { + hydrateConversationEngine(eng, state, mode.Parse(buildMode)) + } + if err == nil { + err = s.publishEngineCandidate(eng, old) + } + s.taskCreateMu.Unlock() if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to bind remote workspace: %v", err)}) + if eng != nil { + // The pending registry still owns pc.exec on failure. Clearing env.Exec + // prevents candidate teardown from closing that retryable connection. + if eng.env != nil { + eng.env.Exec = nil + } + eng.teardown() + } + s.remoteConns.restore(req.ConnectionID, pc) + writeConversationActivationError(w, fmt.Errorf("bind remote conversation: %w", err)) return } - s.ptyMgr.closeForTask(prevTaskID) // outgoing task's PTYs only; others keep theirs - s.setActiveEngine(eng) - - label := pc.exec.ProjectLabel(remotePwd) - if eng.todoStore != nil { - eng.todoStore.Update(nil) + // Ownership transferred when the hydrated candidate was published; the + // claimed registry entry intentionally stays absent. + if focus { + prevTaskID := "" + if cur := s.activeEngine(); cur != nil { + prevTaskID = cur.taskID + } + if prevTaskID != "" && prevTaskID != eng.taskID { + s.ptyMgr.closeForTask(prevTaskID) + } + s.setActiveEngine(eng) + s.wsBroker.Broadcast(WSEvent{ + TaskID: eng.taskID, Type: "project_switched", + Data: map[string]string{"pwd": remotePwd, "label": label}, + }) + } + if req.SessionID == "" { + s.stampCloudSync(eng.taskID, "", true) } - // Ownership of the executor has transferred to the live env; remove the - // pending entry WITHOUT closing it. - s.remoteConns.take(req.ConnectionID) - - s.wsBroker.Broadcast(WSEvent{ - Type: "project_switched", - Data: map[string]string{"pwd": remotePwd, "label": label}, - }) - + result := activationSnapshot(eng, target.kind, true) + result.Focused = focus writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "kind": pc.kind, - "pwd": remotePwd, - "label": label, - "name": pathpkg.Base(remotePwd), - "host": pc.host, - "user": pc.user, - "port": pc.port, - "container": pc.container, + "status": result.Status, "session_id": result.SessionID, + "kind": result.Kind, "pwd": result.Pwd, "project": result.Project, + "workspace_key": result.WorkspaceKey, "provider": result.Provider, + "model": result.Model, "agent": result.Agent, "mode": result.Mode, + "running": result.Running, "activated": result.Activated, "focused": result.Focused, + "label": label, "name": pathpkg.Base(remotePwd), "host": pc.host, + "user": pc.user, "port": pc.port, "container": pc.container, "remote_path": remotePwd, }) } diff --git a/internal/web/remote_connection_status_test.go b/internal/web/remote_connection_status_test.go new file mode 100644 index 00000000..d89b9671 --- /dev/null +++ b/internal/web/remote_connection_status_test.go @@ -0,0 +1,100 @@ +package web + +import ( + "context" + "encoding/json" + "os" + "sync" + "testing" + "time" + + "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/tools" +) + +type statusRemoteExecutor struct { + mu sync.Mutex + handler tools.RemoteConnectionStatusHandler +} + +func (*statusRemoteExecutor) ReadFile(context.Context, string) ([]byte, error) { return nil, nil } +func (*statusRemoteExecutor) WriteFile(context.Context, string, []byte, os.FileMode) error { + return nil +} +func (*statusRemoteExecutor) MkdirAll(context.Context, string, os.FileMode) error { return nil } +func (*statusRemoteExecutor) Stat(context.Context, string) (*tools.FileInfo, error) { + return &tools.FileInfo{Exists: true}, nil +} +func (*statusRemoteExecutor) Exec(context.Context, string, string, time.Duration) (string, string, error) { + return "", "", nil +} +func (*statusRemoteExecutor) Platform() string { return "linux/amd64" } +func (*statusRemoteExecutor) Label() string { return "status-remote" } +func (*statusRemoteExecutor) ProjectLabel(pwd string) string { return "ssh://test@example.test" + pwd } +func (*statusRemoteExecutor) Probe(context.Context) error { return nil } +func (e *statusRemoteExecutor) SetRemoteConnectionStatusHandler(h tools.RemoteConnectionStatusHandler) { + e.mu.Lock() + e.handler = h + e.mu.Unlock() +} +func (e *statusRemoteExecutor) Close() error { + e.SetRemoteConnectionStatusHandler(nil) + return nil +} +func (e *statusRemoteExecutor) emit(status tools.RemoteConnectionStatus) { + e.mu.Lock() + h := e.handler + e.mu.Unlock() + if h != nil { + h(status) + } +} + +func TestRemoteConnectionStatusBridgeIsTaskScoped(t *testing.T) { + s := &Server{Engine: &Engine{}, tasks: make(map[string]*Engine), wsBroker: NewWSBroker()} + bg := context.Background() + s.ctxPtr.Store(&bg) + + exec := &statusRemoteExecutor{} + env := tools.NewEnv("/local", "darwin/arm64") + env.SetRemote(exec, "/work") + eng := &Engine{ + taskID: "remote-task", env: env, + handler: handler.NewWebHandler(), + } + client := newWSClient(nil) + client.subscribe([]string{"remote-task"}) + s.wsBroker.mu.Lock() + s.wsBroker.clients[1] = client + s.wsBroker.mu.Unlock() + + if err := s.registerEngine(eng); err != nil { + t.Fatalf("register engine: %v", err) + } + t.Cleanup(func() { s.deleteEngine(eng.taskID) }) + + exec.emit(tools.RemoteConnectionStatus{ + Kind: "ssh", Status: "reconnecting", Attempt: 2, MaxAttempts: 3, + Host: "example.test:22", RetryInMS: 250, + }) + + select { + case raw := <-client.sendCh: + var event struct { + Type string `json:"type"` + TaskID string `json:"task_id"` + Data tools.RemoteConnectionStatus `json:"data"` + } + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatalf("decode event: %v", err) + } + if event.Type != "remote_connection_status" || event.TaskID != eng.taskID { + t.Fatalf("event = %+v, want task-scoped remote_connection_status", event) + } + if event.Data.Status != "reconnecting" || event.Data.Attempt != 2 || event.Data.MaxAttempts != 3 { + t.Fatalf("status data = %+v", event.Data) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for remote connection status") + } +} diff --git a/internal/web/remote_host_key_test.go b/internal/web/remote_host_key_test.go new file mode 100644 index 00000000..5fb9615a --- /dev/null +++ b/internal/web/remote_host_key_test.go @@ -0,0 +1,50 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cnjack/jcode/internal/remote" +) + +func TestWriteSSHHostKeyErrorContract(t *testing.T) { + recorder := httptest.NewRecorder() + err := fmt.Errorf("connect: %w", &remote.SSHHostKeyError{ + Code: remote.SSHHostKeyChanged, + Host: "[example.test]:2222", + Fingerprint: "SHA256:new", + KeyType: "ssh-ed25519", + OldFingerprint: "SHA256:old", + }) + if !writeSSHHostKeyError(recorder, err) { + t.Fatal("structured host-key error was not handled") + } + if recorder.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", recorder.Code) + } + var body map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "code": remote.SSHHostKeyChanged, + "host": "[example.test]:2222", + "fingerprint": "SHA256:new", + "key_type": "ssh-ed25519", + "old_fingerprint": "SHA256:old", + } { + if got := body[key]; got != want { + t.Fatalf("response %s = %#v, want %q", key, got, want) + } + } +} + +func TestWriteSSHHostKeyErrorIgnoresOrdinaryFailure(t *testing.T) { + recorder := httptest.NewRecorder() + if writeSSHHostKeyError(recorder, fmt.Errorf("authentication failed")) { + t.Fatal("ordinary SSH error was classified as a host-key error") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 025ee6f4..57785189 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -96,6 +96,11 @@ type Server struct { // newRemoteEngine is newEngine's remote sibling: it builds a task engine bound // to a remote executor (SSH or Docker) instead of a local pwd. newRemoteEngine func(taskID string, executor tools.RemoteExecutor, remotePwd, mode string) (*EngineConfig, error) + // dialSSH/dialDocker are the cold-activation seams for persisted remote + // conversations. Production falls back to internal/remote; focused tests can + // inject bounded fakes without dialing a host or Docker daemon. + dialSSH func(context.Context, string, string) (tools.RemoteExecutor, error) + dialDocker func(context.Context, string) (tools.RemoteExecutor, error) // newAutomationEngine builds a headless task engine for automation runs: like // newEngine but drops interactive tools (ask_user) so an unattended run can't @@ -442,6 +447,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/sessions/{id}", s.handleGetSession) mux.HandleFunc("DELETE /api/sessions/{id}", s.handleDeleteSession) mux.HandleFunc("POST /api/sessions", s.handleNewSession) + mux.HandleFunc("POST /api/sessions/activate", s.handleActivateSession) mux.HandleFunc("GET /api/config", s.handleGetConfig) mux.HandleFunc("GET /api/todos", s.handleGetTodos) mux.HandleFunc("GET /api/goal", s.handleGetGoal) @@ -671,24 +677,31 @@ func (s *Server) currentModelSupportsImage(eng *Engine) bool { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { eng := s.activeEngine() + recentProject, recentSessionID := session.LoadMostRecentSession() if s.needsSetup || eng == nil { pwd := "" + project := "" if eng != nil { pwd = eng.pwd + project = engineProject(eng) } writeJSON(w, http.StatusOK, map[string]any{ - "status": "needs_setup", - "version": s.version, - "pwd": pwd, - "provider": "", - "model": "", - "agent": "", - "mode": "build", - "session_id": "", - "running": false, - "needs_setup": true, - "auth_required": s.requireAuth, + "status": "needs_setup", + "version": s.version, + "pwd": pwd, + "project": project, + "workspace_key": project, + "provider": "", + "model": "", + "agent": "", + "mode": "build", + "session_id": "", + "recent_project": recentProject, + "recent_session_id": recentSessionID, + "running": false, + "needs_setup": true, + "auth_required": s.requireAuth, }) return } @@ -704,22 +717,27 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { throwaway := (eng.recorder == nil || !eng.recorder.HasRecording()) && !eng.running.Load() eng.emu.Unlock() if throwaway { - if last := session.LoadLastSession(eng.pwd); last != "" { + if last := session.LoadLastSession(engineProject(eng)); last != "" { sessionID = last } } + project := engineProject(eng) writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "version": s.version, - "pwd": eng.pwd, - "provider": provider, - "model": mdl, - "agent": eng.curAgentRole(), - "mode": modeStr, - "session_id": sessionID, - "running": eng.running.Load(), - "image_support": s.currentModelSupportsImage(eng), - "auth_required": s.requireAuth, + "status": "ok", + "version": s.version, + "pwd": eng.pwd, + "project": project, + "workspace_key": project, + "provider": provider, + "model": mdl, + "agent": eng.curAgentRole(), + "mode": modeStr, + "session_id": sessionID, + "recent_project": recentProject, + "recent_session_id": recentSessionID, + "running": eng.running.Load(), + "image_support": s.currentModelSupportsImage(eng), + "auth_required": s.requireAuth, }) } @@ -729,13 +747,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) statusSnapshot(eng *Engine) map[string]any { full := eng.tokenUsage.GetFull() provider, mdl, modeStr := eng.modelSnapshot() + project := engineProject(eng) return map[string]any{ - "running": eng.running.Load(), - "pwd": eng.pwd, - "provider": provider, - "model": mdl, - "agent": eng.curAgentRole(), - "mode": modeStr, + "running": eng.running.Load(), + "pwd": eng.pwd, + "project": project, + "workspace_key": project, + "provider": provider, + "model": mdl, + "agent": eng.curAgentRole(), + "mode": modeStr, // Live token snapshot so a client reconnecting between turns can render // the context bar + cache hit rate without waiting for the next // token_update WS event. total_tokens = current context occupancy. @@ -755,9 +776,9 @@ func (s *Server) statusSnapshot(eng *Engine) map[string]any { } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() + eng := s.resolveEngine(r.URL.Query().Get("task_id")) if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return } payload := s.statusSnapshot(eng) diff --git a/internal/web/sessions.go b/internal/web/sessions.go index 35d4c6b9..d9c067a4 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -10,7 +10,6 @@ import ( "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/mode" "github.com/cnjack/jcode/internal/session" - "github.com/cnjack/jcode/internal/tools" ) // taskItem is the sidebar's view of a session: its persisted metadata plus a @@ -377,8 +376,6 @@ func (s *Server) writeResumeReply(w http.ResponseWriter, eng *Engine, entries [] } func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { - // Parse optional resume session ID + project. Creating a task no longer - // blocks on "is the agent running" — tasks run concurrently. var req struct { SessionID string `json:"session_id,omitempty"` Pwd string `json:"pwd,omitempty"` @@ -394,116 +391,24 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { return } - // Already-live task: just focus it (do not disturb its run). - if req.SessionID != "" { - if eng := s.resolveEngine(req.SessionID); eng != nil { - s.setActiveEngine(eng) - // Best-effort: a read failure only drops the embedded entries — - // the client falls back to GET /api/sessions/{id}. - entries, err := session.LoadSession(req.SessionID) - if err != nil { - config.Logger().Printf("[web] resume: embedded entries unavailable for %s (client falls back to GET): %v", req.SessionID, err) - } - s.writeResumeReply(w, eng, entries) - return - } - } - - if s.newEngine == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "task creation is not supported"}) - return - } - - // Resume mode is task-owned authorization state. Load it before asking the - // factory to build an agent so this task can never inherit the foreground - // task's Full access mode (or Server.activeMode) during a restart. As in the - // TUI and ACP, a saved Plan resumes as Approval: the plan remains in session - // history, while the agent comes back with the normal tool set and per-call - // approval instead of being stranded in a read-only planning agent. - var entries []session.Entry - var restoredState *session.SessionState - restoredMode := mode.Approval - buildMode := s.activeMode() - if req.SessionID != "" { - var loadErr error - entries, loadErr = session.LoadSession(req.SessionID) - if loadErr != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) - return - } - restoredState = session.ReconstructState(entries) - savedMode, modeErr := session.LoadSessionModeStrict(req.SessionID) - if modeErr != nil { - // Conversational replay may salvage a damaged transcript, but mode is - // authorization state. A line that cannot be parsed might be a newer - // revoke, so restore the safe default and keep details in the local log. - config.Logger().Printf("[web] resume: mode journal unavailable for %s; restoring approval: %v", req.SessionID, modeErr) - } else { - restoredMode = restoredWebSessionMode(savedMode) - } - buildMode = restoredMode.String() - } - - // Each new/resumed task gets its OWN engine (env, agent, recorder, handler), - // so it runs independently of every other task. - pwd := req.Pwd - if pwd == "" { - if a := s.activeEngine(); a != nil { - pwd = a.pwd - } - } - eng, err := s.buildLocalEngine(req.SessionID, pwd, buildMode) + // Build or recover the task without changing the foreground until every + // remote connection and hydration step has succeeded. This is the same cold + // activation path used by Cloud commands, so a persisted SSH/Docker UUID can + // never silently fall back to a local engine. + result, err := s.ensureConversation(r.Context(), req.SessionID, req.Pwd, req.Source) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeConversationActivationError(w, err) return } - - // Resume: hydrate the fresh engine with the persisted conversation/todos/goal. - if req.SessionID != "" { - st := restoredState - if eng.rebuildForRole != nil { - eng.rebuildMu.Lock() - provider, model, _ := eng.modelSnapshot() - built, rebuildErr := eng.rebuildForRole(st.Agent, provider, model) - if rebuildErr != nil { - config.Logger().Printf("[web] resume: custom agent %q unavailable for %s: %v", st.Agent, req.SessionID, rebuildErr) - if fallback, fallbackErr := eng.rebuildForRole("", provider, model); fallbackErr == nil { - eng.applyAgentRoleSwitch("", fallback) - } - } else { - eng.applyAgentRoleSwitch(st.Agent, built) - } - eng.rebuildMu.Unlock() - } - eng.emu.Lock() - eng.history = st.History - eng.emu.Unlock() - if eng.approvalState != nil { - eng.approvalState.SetSessionMode(restoredMode) - } - if eng.todoStore != nil { - items := make([]tools.TodoItem, len(st.Todos)) - for i, t := range st.Todos { - items[i] = tools.TodoItem{ID: t.ID, Title: t.Title, Status: tools.TodoStatus(t.Status)} - } - eng.todoStore.Update(items) - } - if eng.env != nil && eng.env.GoalStore != nil { - eng.env.GoalStore.RestoreFromSnapshot(st.Goal) - if eng.handler != nil { - eng.handler.Emit("goal_update", eng.env.GoalStore.Get()) - } - } + eng := s.resolveEngine(result.SessionID) + if eng == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "activated task is unavailable"}) + return } - s.setActiveEngine(eng) - // 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) resp := s.statusSnapshot(eng) resp["status"] = "ok" resp["session_id"] = eng.taskID @@ -511,8 +416,10 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { return } - // Resume: one-shot reply (entries + goal + todos + status) so the client - // repaints without follow-up round trips. + entries, loadErr := session.LoadSession(req.SessionID) + if loadErr != nil { + config.Logger().Printf("[web] resume: embedded entries unavailable for %s (client falls back to GET): %v", req.SessionID, loadErr) + } s.writeResumeReply(w, eng, entries) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 0a20121e..de81411a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,8 +37,8 @@ import { sessionActions, uiActions, chatActions, - loadSession, loadWorkspaceState, + openConversation, replaySession, startNewChat, } from './app/store' @@ -86,8 +86,12 @@ export default function App() { dispatch(modelActions.setMode(normalizeMode(h.mode))) dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) - dispatch(sessionActions.setProjectPath(h.pwd)) - dispatch(sessionActions.setCurrentSession(h.session_id || '')) + dispatch(sessionActions.setProjectPath(h.project || h.workspace_key || h.pwd)) + const restoreSessionId = isTauri ? h.recent_session_id || h.session_id : h.session_id + const restoreProject = isTauri && h.recent_session_id + ? h.recent_project || h.project || h.workspace_key || h.pwd + : h.project || h.workspace_key || h.pwd + dispatch(sessionActions.setCurrentSession(restoreSessionId || '')) dispatch(chatActions.setRunning(!!h.running)) if (h.auth_required) dispatch(uiActions.setNeedsAuth(true)) if (h.needs_setup) dispatch(uiActions.setNeedsSetup(true)) @@ -95,7 +99,16 @@ export default function App() { // has persisted history. A fresh empty session should stay on welcome. if (!h.auth_required && !h.needs_setup) { await dispatch(loadWorkspaceState()) - if (h.session_id) await dispatch(loadSession(h.session_id)) + if (restoreSessionId) { + const state = store_getState() + const indexedTask = state.session.tasks.find((task) => task.uuid === restoreSessionId) + const indexedSession = state.session.sessions.find((session) => session.uuid === restoreSessionId) + await dispatch(openConversation({ + uuid: restoreSessionId, + project: indexedTask?.project || restoreProject, + title: indexedTask?.title || indexedSession?.title, + })) + } } } catch (err) { if (!cancelled) { diff --git a/web/src/app/conversationLoad.test.ts b/web/src/app/conversationLoad.test.ts new file mode 100644 index 00000000..acbbc2f5 --- /dev/null +++ b/web/src/app/conversationLoad.test.ts @@ -0,0 +1,473 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionActivationResponse, SessionEntry } from '../lib/types' +import { api } from '../lib/api' +import { + cancelConversationLoad, + continueConversationLoad, + conversationLoadActions, + conversationLoadTimeouts, + chatActions, + loadSession, + openConversation, + sessionActions, + startNewChat, + store, +} from './store' +import { createWSHandlers } from './wsBridge' + +beforeEach(async () => { + await store.dispatch(cancelConversationLoad()) + store.dispatch(chatActions.clearChat()) + store.dispatch(chatActions.dropSessionQueue('session-agent-done-cancel')) + store.dispatch(chatActions.dropSessionQueue('session-agent-done-commit')) + store.dispatch(sessionActions.setCurrentSession('')) + store.dispatch(sessionActions.setProjectPath('')) +}) + +afterEach(async () => { + await store.dispatch(cancelConversationLoad()) + store.dispatch(conversationLoadActions.reset()) + vi.useRealTimers() + vi.restoreAllMocks() +}) + +function activation(sessionID: string, project = '/workspace', focused = true): SessionActivationResponse { + return { + status: 'ready', + session_id: sessionID, + kind: project.startsWith('ssh://') ? 'ssh' : 'local', + pwd: project.startsWith('ssh://') ? '/workspace' : project, + project, + workspace_key: project, + provider: 'openai', + model: 'test-model', + agent: '', + mode: 'approval', + running: false, + activated: true, + focused, + } +} + +function history(content: string): SessionEntry[] { + return [{ type: 'user', content, timestamp: '2026-08-12T00:00:00Z' }] +} + +function mockFollowUpAPIs() { + vi.spyOn(api, 'goal').mockResolvedValue(null) + vi.spyOn(api, 'todos').mockResolvedValue([]) + vi.spyOn(api, 'askPending').mockResolvedValue([]) + vi.spyOn(api, 'approvalPending').mockResolvedValue([]) + vi.spyOn(api, 'sessions').mockResolvedValue([]) + vi.spyOn(api, 'tasks').mockResolvedValue([]) + vi.spyOn(api, 'projects').mockResolvedValue([]) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +describe('conversation loading state', () => { + it('ignores history and errors from an older navigation request', () => { + store.dispatch(conversationLoadActions.begin({ + requestId: 'old', + target: { uuid: 'session-old', project: '/old' }, + })) + store.dispatch(conversationLoadActions.begin({ + requestId: 'new', + target: { uuid: 'session-new', project: '/new' }, + })) + + store.dispatch(conversationLoadActions.historyReady({ requestId: 'old', timeline: [] })) + store.dispatch(conversationLoadActions.failed({ requestId: 'old', error: 'stale failure' })) + + expect(store.getState().conversationLoad).toMatchObject({ + requestId: 'new', + phase: 'loading', + historyStatus: 'loading', + error: '', + }) + }) + + it('uses atomic activation and never resumes through the legacy new-session endpoint', async () => { + mockFollowUpAPIs() + vi.spyOn(api, 'session').mockResolvedValue(history('atomic history')) + const activate = vi.spyOn(api, 'activateSession').mockImplementation((request) => + Promise.resolve(activation('session-atomic', '/workspace', !!request.focus))) + const legacy = vi.spyOn(api, 'newSession') + const connect = vi.spyOn(api, 'remoteConnect') + + await store.dispatch(openConversation({ uuid: 'session-atomic', project: '/workspace' })) + + expect(activate).toHaveBeenNthCalledWith(1, { + session_id: 'session-atomic', project_path: '/workspace', focus: false, + }, expect.any(AbortSignal)) + expect(activate).toHaveBeenNthCalledWith(2, { + session_id: 'session-atomic', project_path: '/workspace', focus: true, + }, expect.any(AbortSignal)) + expect(legacy).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() + expect(store.getState().session.currentSessionId).toBe('session-atomic') + expect(store.getState().chat.timeline[0]).toMatchObject({ + kind: 'message', data: { role: 'user', content: 'atomic history' }, + }) + }) + + it('binds an authenticated SSH connection directly to the requested session', async () => { + mockFollowUpAPIs() + const project = 'ssh://root@example.com/workspace' + vi.spyOn(api, 'session').mockResolvedValue(history('remote history')) + const activate = vi.spyOn(api, 'activateSession') + .mockRejectedValueOnce(Object.assign(new Error('authentication required'), { + status: 409, + code: 'ssh_auth_required', + body: { code: 'ssh_auth_required', error: 'authentication required', retryable: true, kind: 'ssh' }, + })) + .mockResolvedValueOnce(activation('session-ssh', project, true)) + vi.spyOn(api, 'remoteConnect').mockResolvedValue({ + connection_id: 'connection-1', remote_pwd: '/root', platform: 'linux', + }) + const bind = vi.spyOn(api, 'remoteBind').mockResolvedValue({ + ...activation('session-ssh', project, false), + status: 'ready', + kind: 'ssh', + label: project, + name: 'workspace', + host: 'example.com', + user: 'root', + port: 22, + remote_path: '/workspace', + }) + + await store.dispatch(openConversation({ uuid: 'session-ssh', project })) + expect(store.getState().conversationLoad.phase).toBe('awaiting_auth') + + await store.dispatch(continueConversationLoad({ + requestId: store.getState().conversationLoad.requestId, + credentials: { authMethod: 'password', password: 'secret' }, + })) + + expect(bind).toHaveBeenCalledWith( + 'connection-1', + '/workspace', + { session_id: 'session-ssh', focus: false }, + expect.any(AbortSignal), + ) + expect(activate).toHaveBeenLastCalledWith({ + session_id: 'session-ssh', project_path: project, focus: true, + }, expect.any(AbortSignal)) + expect(store.getState().session.currentSessionId).toBe('session-ssh') + expect(store.getState().session.projectPath).toBe(project) + }) + + it('pins the displayed host-key fingerprint into the trust retry', async () => { + mockFollowUpAPIs() + const project = 'ssh://root@example.com/workspace' + vi.spyOn(api, 'session').mockResolvedValue(history('trusted history')) + vi.spyOn(api, 'activateSession') + .mockRejectedValueOnce(Object.assign(new Error('unknown host key'), { + status: 409, + code: 'ssh_host_key_unknown', + body: { + code: 'ssh_host_key_unknown', + error: 'unknown host key', + host: 'example.com', + fingerprint: 'SHA256:shown-to-user', + key_type: 'ssh-ed25519', + }, + })) + .mockResolvedValueOnce(activation('session-trusted', project, true)) + const connect = vi.spyOn(api, 'remoteConnect').mockResolvedValue({ + connection_id: 'connection-trusted', remote_pwd: '/root', platform: 'linux', + }) + vi.spyOn(api, 'remoteBind').mockResolvedValue({ + ...activation('session-trusted', project, false), + status: 'ready', + kind: 'ssh', + label: project, + name: 'workspace', + host: 'example.com', + user: 'root', + port: 22, + remote_path: '/workspace', + }) + + await store.dispatch(openConversation({ uuid: 'session-trusted', project })) + expect(store.getState().conversationLoad.hostKey?.fingerprint).toBe('SHA256:shown-to-user') + await store.dispatch(continueConversationLoad({ + requestId: store.getState().conversationLoad.requestId, + acceptHostKey: true, + })) + + expect(connect).toHaveBeenCalledWith(expect.objectContaining({ + accept_host_key: true, + host_key_fingerprint: 'SHA256:shown-to-user', + auth_method: undefined, + key_path: undefined, + }), expect.any(AbortSignal)) + }) + + it('retains explicitly submitted credentials across a host-key confirmation retry', async () => { + mockFollowUpAPIs() + const project = 'ssh://dev@example.com/workspace' + vi.spyOn(api, 'session').mockResolvedValue(history('credentialed history')) + vi.spyOn(api, 'activateSession') + .mockRejectedValueOnce(Object.assign(new Error('authentication required'), { + status: 409, + code: 'ssh_auth_required', + body: { code: 'ssh_auth_required', error: 'authentication required', retryable: true, kind: 'ssh' }, + })) + .mockResolvedValueOnce(activation('session-credentialed', project, true)) + const connect = vi.spyOn(api, 'remoteConnect') + .mockRejectedValueOnce(Object.assign(new Error('unknown host key'), { + status: 409, + code: 'ssh_host_key_unknown', + body: { + code: 'ssh_host_key_unknown', + error: 'unknown host key', + host: 'example.com', + fingerprint: 'SHA256:credentialed-host', + key_type: 'ssh-ed25519', + }, + })) + .mockResolvedValueOnce({ + connection_id: 'connection-credentialed', remote_pwd: '/root', platform: 'linux', + }) + vi.spyOn(api, 'remoteBind').mockResolvedValue({ + ...activation('session-credentialed', project, false), + status: 'ready', + kind: 'ssh', + label: project, + name: 'workspace', + host: 'example.com', + user: 'dev', + port: 22, + remote_path: '/workspace', + }) + + await store.dispatch(openConversation({ uuid: 'session-credentialed', project })) + const requestId = store.getState().conversationLoad.requestId + await store.dispatch(continueConversationLoad({ + requestId, + credentials: { authMethod: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: 'secret phrase' }, + })) + expect(store.getState().conversationLoad.phase).toBe('awaiting_host_key') + + await store.dispatch(continueConversationLoad({ requestId, acceptHostKey: true })) + + expect(connect).toHaveBeenNthCalledWith(2, expect.objectContaining({ + auth_method: 'key', + key_path: '~/.ssh/id_ed25519', + passphrase: 'secret phrase', + accept_host_key: true, + host_key_fingerprint: 'SHA256:credentialed-host', + }), expect.any(AbortSignal)) + expect(store.getState().session.currentSessionId).toBe('session-credentialed') + }) + + it('never lets a late older request replace the newest conversation', async () => { + mockFollowUpAPIs() + const historyA = deferred() + const activationA = deferred() + vi.spyOn(api, 'session').mockImplementation((id) => id === 'session-a' ? historyA.promise : Promise.resolve(history('B'))) + vi.spyOn(api, 'activateSession').mockImplementation((request) => request.session_id === 'session-a' + ? activationA.promise + : Promise.resolve(activation('session-b', '/b'))) + + const older = store.dispatch(openConversation({ uuid: 'session-a', project: '/a' })) + await store.dispatch(openConversation({ uuid: 'session-b', project: '/b' })) + expect(store.getState().session.currentSessionId).toBe('session-b') + + activationA.resolve(activation('session-a', '/a')) + historyA.resolve(history('A arrived late')) + await older + + expect(store.getState().session.currentSessionId).toBe('session-b') + expect(store.getState().chat.timeline[0]).toMatchObject({ data: { content: 'B' } }) + }) + + it('turns bounded request deadlines into a retryable page instead of endless loading', async () => { + vi.useFakeTimers() + const aborting = (signal?: AbortSignal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason || new DOMException('Aborted', 'AbortError')), { once: true }) + }) + vi.spyOn(api, 'session').mockImplementation((_id, signal) => aborting(signal)) + vi.spyOn(api, 'activateSession').mockImplementation((_request, signal) => aborting(signal)) + + const opening = store.dispatch(openConversation({ uuid: 'session-timeout', project: '/slow' })) + await vi.advanceTimersByTimeAsync(conversationLoadTimeouts.historyMs) + expect(store.getState().conversationLoad.historyStatus).toBe('error') + + await vi.advanceTimersByTimeAsync(conversationLoadTimeouts.activationMs - conversationLoadTimeouts.historyMs) + await opening + expect(store.getState().conversationLoad).toMatchObject({ + phase: 'error', + environmentStatus: 'error', + }) + }) + + it('does not focus the prepared environment when history cannot be loaded', async () => { + vi.spyOn(api, 'session').mockRejectedValue(new Error('history unavailable')) + const activate = vi.spyOn(api, 'activateSession').mockResolvedValue( + activation('session-history-error', '/workspace', false), + ) + + await store.dispatch(openConversation({ uuid: 'session-history-error', project: '/workspace' })) + + expect(activate).toHaveBeenCalledTimes(1) + expect(activate).toHaveBeenCalledWith({ + session_id: 'session-history-error', project_path: '/workspace', focus: false, + }, expect.any(AbortSignal)) + expect(store.getState().conversationLoad).toMatchObject({ + phase: 'error', + issue: 'history', + historyStatus: 'error', + environmentStatus: 'ready', + retryable: true, + }) + }) + + it('preserves backend non-retryable activation errors for the loading page', async () => { + vi.spyOn(api, 'session').mockResolvedValue(history('stale conversation')) + vi.spyOn(api, 'activateSession').mockRejectedValue(Object.assign(new Error('conversation not found'), { + status: 404, + code: 'conversation_not_found', + body: { code: 'conversation_not_found', error: 'conversation not found', retryable: false }, + })) + + await store.dispatch(openConversation({ uuid: 'missing-session', project: '/workspace' })) + + expect(store.getState().conversationLoad).toMatchObject({ + phase: 'error', + errorCode: 'conversation_not_found', + retryable: false, + environmentStatus: 'error', + }) + }) + + it('cancels an in-flight conversation navigation before creating a new chat', async () => { + const prepared = deferred() + vi.spyOn(api, 'session').mockResolvedValue(history('old target')) + vi.spyOn(api, 'activateSession').mockReturnValue(prepared.promise) + vi.spyOn(api, 'newSession').mockResolvedValue({ status: 'ok', session_id: 'brand-new' }) + + const opening = store.dispatch(openConversation({ uuid: 'old-target', project: '/old' })) + await vi.waitFor(() => expect(store.getState().conversationLoad.historyStatus).toBe('ready')) + await store.dispatch(startNewChat()) + prepared.resolve(activation('old-target', '/old', false)) + await opening + + expect(store.getState().conversationLoad.phase).toBe('idle') + expect(store.getState().session.currentSessionId).toBe('brand-new') + expect(store.getState().chat.timeline).toEqual([]) + }) + + it('guards a background history repair from overwriting a newer navigation', async () => { + store.dispatch(sessionActions.setCurrentSession('session-old')) + store.dispatch(chatActions.addMessage({ role: 'user', content: 'keep me' })) + const freshHistory = deferred() + vi.spyOn(api, 'session').mockReturnValue(freshHistory.promise) + const legacyResume = vi.spyOn(api, 'newSession') + + const refresh = store.dispatch(loadSession({ uuid: 'session-old', background: true })) + await vi.waitFor(() => expect(api.session).toHaveBeenCalledWith('session-old')) + store.dispatch(conversationLoadActions.begin({ + requestId: 'new-navigation', + target: { uuid: 'session-new', project: '/new' }, + })) + freshHistory.resolve(history('late stale replay')) + await refresh + + expect(legacyResume).not.toHaveBeenCalled() + expect(store.getState().chat.timeline[0]).toMatchObject({ data: { content: 'keep me' } }) + }) + + it('flushes target WS events received after the history barrier in order', async () => { + mockFollowUpAPIs() + const prepared = deferred() + vi.spyOn(api, 'session').mockResolvedValue(history('snapshot')) + vi.spyOn(api, 'activateSession').mockImplementation((request) => request.focus + ? Promise.resolve(activation('session-live', '/live', true)) + : prepared.promise) + + const opening = store.dispatch(openConversation({ uuid: 'session-live', project: '/live' })) + await vi.waitFor(() => expect(store.getState().conversationLoad.historyStatus).toBe('ready')) + const handlers = createWSHandlers(() => store.getState(), store.dispatch) + expect(handlers.pendingTaskId?.()).toBe('session-live') + handlers.onPendingTaskEvent?.({ + type: 'agent_text', taskId: 'session-live', data: { text: 'live delta' }, + }) + prepared.resolve(activation('session-live', '/live', false)) + await opening + + expect(store.getState().chat.timeline).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'message', data: expect.objectContaining({ content: 'snapshot' }) }), + expect.objectContaining({ kind: 'message', data: expect.objectContaining({ content: 'live delta' }) }), + ])) + }) + + it('drains a pending target queue once even when navigation is cancelled', async () => { + mockFollowUpAPIs() + const targetId = 'session-agent-done-cancel' + const prepared = deferred() + vi.spyOn(api, 'session').mockResolvedValue(history('snapshot')) + vi.spyOn(api, 'activateSession').mockReturnValue(prepared.promise) + const chat = vi.spyOn(api, 'chat').mockResolvedValue({ status: 'ok', session_id: targetId }) + + const opening = store.dispatch(openConversation({ uuid: targetId, project: '/pending' })) + await vi.waitFor(() => expect(store.getState().conversationLoad.historyStatus).toBe('ready')) + store.dispatch(chatActions.enqueueMessage({ + sessionId: targetId, + message: { id: 'queued-cancel', text: 'send despite cancel' }, + })) + const handlers = createWSHandlers(() => store.getState(), store.dispatch) + handlers.onPendingTaskEvent?.({ + type: 'agent_done', taskId: targetId, data: { task_id: targetId }, + }) + + await vi.waitFor(() => expect(chat).toHaveBeenCalledTimes(1)) + expect(store.getState().chat.queuedBySession[targetId]).toBeUndefined() + await store.dispatch(cancelConversationLoad()) + prepared.resolve(activation(targetId, '/pending', false)) + await opening + + expect(chat).toHaveBeenCalledTimes(1) + }) + + it('does not drain a second queued turn when buffered agent_done commits', async () => { + mockFollowUpAPIs() + const targetId = 'session-agent-done-commit' + const prepared = deferred() + vi.spyOn(api, 'session').mockResolvedValue(history('snapshot')) + vi.spyOn(api, 'activateSession').mockImplementation((request) => request.focus + ? Promise.resolve(activation(targetId, '/pending', true)) + : prepared.promise) + const chat = vi.spyOn(api, 'chat').mockResolvedValue({ status: 'ok', session_id: targetId }) + + const opening = store.dispatch(openConversation({ uuid: targetId, project: '/pending' })) + await vi.waitFor(() => expect(store.getState().conversationLoad.historyStatus).toBe('ready')) + store.dispatch(chatActions.enqueueMessage({ + sessionId: targetId, + message: { id: 'queued-first', text: 'first queued turn' }, + })) + store.dispatch(chatActions.enqueueMessage({ + sessionId: targetId, + message: { id: 'queued-second', text: 'second queued turn' }, + })) + const handlers = createWSHandlers(() => store.getState(), store.dispatch) + handlers.onPendingTaskEvent?.({ + type: 'agent_done', taskId: targetId, data: { task_id: targetId }, + }) + + await vi.waitFor(() => expect(chat).toHaveBeenCalledTimes(1)) + prepared.resolve(activation(targetId, '/pending', false)) + await opening + + expect(chat).toHaveBeenCalledTimes(1) + expect(store.getState().chat.queuedBySession[targetId]).toEqual([ + { id: 'queued-second', text: 'second queued turn' }, + ]) + }) +}) diff --git a/web/src/app/remoteConnection.store.test.ts b/web/src/app/remoteConnection.store.test.ts new file mode 100644 index 00000000..f6e7e1d4 --- /dev/null +++ b/web/src/app/remoteConnection.store.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { remoteConnectionActions, store } from './store' + +afterEach(() => store.dispatch(remoteConnectionActions.reset())) + +describe('remote connection state', () => { + it('isolates task records, normalizes the compatibility delay, and guards stale clears', () => { + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-a', kind: 'ssh', status: 'waiting', attempt: 1, max_attempts: 8, retry_after_ms: 1_250, + })) + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-b', kind: 'docker', status: 'ready', attempt: 2, max_attempts: 3, + })) + + const first = store.getState().remoteConnection + expect(first.byTaskId['task-a'].retry_in_ms).toBe(1_250) + expect(first.byTaskId['task-a'].revision).toBe(1) + expect(first.byTaskId['task-b'].kind).toBe('docker') + + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-a', kind: 'ssh', status: 'reconnecting', attempt: 2, max_attempts: 8, + })) + store.dispatch(remoteConnectionActions.clear({ taskId: 'task-a', revision: 1 })) + expect(store.getState().remoteConnection.byTaskId['task-a'].revision).toBe(2) + }) +}) diff --git a/web/src/app/selectors.ts b/web/src/app/selectors.ts index f42d65b8..a4564aaa 100644 --- a/web/src/app/selectors.ts +++ b/web/src/app/selectors.ts @@ -15,6 +15,7 @@ export function selectShowSessionChrome(state: RootState): boolean { ) return ( + (state.conversationLoad?.phase ?? 'idle') !== 'idle' || state.chat.sessionLoading || state.chat.isRunning || state.chat.timeline.length > 0 || diff --git a/web/src/app/store.ts b/web/src/app/store.ts index b2a94a35..084b24d1 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -25,11 +25,13 @@ import type { QueuedMessage, AskUserQuestion, } from 'jcode-ui-core' -import { api } from '../lib/api' +import { api, isAPIError } from '../lib/api' import { extractToolDisplayInfo } from '../lib/toolInfo' -import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' +import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef, type RemoteConnectRequest, type RemoteHostKeyErrorPayload, type RemoteHostKeyErrorCode, type RemoteConnectionStatusData, type SessionActivationResponse } from '../lib/types' +import { parseRemoteLabel } from '../lib/remote' import { i18n, setLocale, SUPPORTED_LOCALES } from '../i18n' import { hydrateTheme } from '../lib/useTheme' +import { isTauri } from '../lib/useDesktop' import { mergeToolLifecycle, normalizeWireLifecycle, settleIncompleteImageTool } from './toolLifecycle' // ─── seq counter (stable DOM identity across streaming updates) ─── @@ -1014,6 +1016,212 @@ const sessionSlice = createSlice({ }, }) +// ═══════════════════════════════════════════════════════════════════════════ +// conversation-load slice — foreground navigation + recoverable remote setup +// ═══════════════════════════════════════════════════════════════════════════ + +export interface ConversationLoadTarget { + uuid: string + project: string + title?: string +} + +export type ConversationLoadPhase = + | 'idle' + | 'loading' + | 'connecting' + | 'awaiting_host_key' + | 'awaiting_auth' + | 'activating' + | 'error' + +export type ConversationLoadIssue = + | 'none' + | 'authentication' + | 'remote' + | 'history' + | 'host_key_unknown' + | 'host_key_changed' + | 'host_key_confirmation_mismatch' + +export interface ConversationLoadState { + requestId: string + target: ConversationLoadTarget | null + phase: ConversationLoadPhase + historyStatus: 'idle' | 'loading' | 'ready' | 'error' + environmentStatus: 'idle' | 'loading' | 'ready' | 'action_required' | 'error' + previewTimeline: ThreadItem[] + issue: ConversationLoadIssue + error: string + errorCode: string + retryable: boolean + hostKey: RemoteHostKeyErrorPayload | null +} + +const initialConversationLoad: ConversationLoadState = { + requestId: '', + target: null, + phase: 'idle', + historyStatus: 'idle', + environmentStatus: 'idle', + previewTimeline: [], + issue: 'none', + error: '', + errorCode: '', + retryable: true, + hostKey: null, +} + +const conversationLoadSlice = createSlice({ + name: 'conversationLoad', + initialState: initialConversationLoad, + reducers: { + begin(s, a: { payload: { requestId: string; target: ConversationLoadTarget } }) { + s.requestId = a.payload.requestId + s.target = a.payload.target + s.phase = 'loading' + s.historyStatus = 'loading' + s.environmentStatus = 'loading' + s.previewTimeline = [] + s.issue = 'none' + s.error = '' + s.errorCode = '' + s.retryable = true + s.hostKey = null + }, + historyReady(s, a: { payload: { requestId: string; timeline: ThreadItem[] } }) { + if (s.requestId !== a.payload.requestId) return + s.historyStatus = 'ready' + s.previewTimeline = a.payload.timeline + }, + historyLoading(s, a: { payload: { requestId: string } }) { + if (s.requestId !== a.payload.requestId) return + s.historyStatus = 'loading' + if (s.issue === 'history') { + s.issue = 'none' + s.error = '' + s.errorCode = '' + s.retryable = true + } + }, + historyFailed(s, a: { payload: { requestId: string; error: string } }) { + if (s.requestId !== a.payload.requestId) return + s.historyStatus = 'error' + // History may still arrive in the activation response, so this does not + // make the whole navigation terminal by itself. + if (!s.error) s.error = a.payload.error + }, + setPhase(s, a: { payload: { requestId: string; phase: ConversationLoadPhase; environmentStatus?: ConversationLoadState['environmentStatus'] } }) { + if (s.requestId !== a.payload.requestId) return + s.phase = a.payload.phase + if (a.payload.environmentStatus) s.environmentStatus = a.payload.environmentStatus + s.issue = 'none' + s.error = '' + s.errorCode = '' + s.retryable = true + s.hostKey = null + }, + requireHostKey(s, a: { payload: { requestId: string; prompt: RemoteHostKeyErrorPayload } }) { + if (s.requestId !== a.payload.requestId) return + s.phase = 'awaiting_host_key' + s.environmentStatus = 'action_required' + s.hostKey = a.payload.prompt + s.issue = hostKeyIssue(a.payload.prompt.code) + s.error = a.payload.prompt.error + s.errorCode = a.payload.prompt.code + s.retryable = a.payload.prompt.code !== 'ssh_host_key_changed' + }, + requireAuth(s, a: { payload: { requestId: string; error: string; code?: string; retryable?: boolean } }) { + if (s.requestId !== a.payload.requestId) return + s.phase = 'awaiting_auth' + s.environmentStatus = 'action_required' + s.issue = 'authentication' + s.error = a.payload.error + s.errorCode = a.payload.code || 'ssh_auth_required' + s.retryable = a.payload.retryable !== false + s.hostKey = null + }, + failed(s, a: { payload: { requestId: string; error: string; issue?: ConversationLoadIssue; code?: string; retryable?: boolean } }) { + if (s.requestId !== a.payload.requestId) return + s.phase = 'error' + s.issue = a.payload.issue || 'remote' + if (s.issue !== 'history' || s.environmentStatus !== 'ready') { + s.environmentStatus = 'error' + } + s.error = a.payload.error + s.errorCode = a.payload.code || '' + s.retryable = a.payload.retryable !== false + s.hostKey = null + }, + finish(s, a: { payload: { requestId: string } }) { + if (s.requestId !== a.payload.requestId) return + Object.assign(s, initialConversationLoad) + }, + reset(s) { + Object.assign(s, initialConversationLoad) + }, + }, +}) + +function hostKeyIssue(code: RemoteHostKeyErrorCode): ConversationLoadIssue { + if (code === 'ssh_host_key_changed') return 'host_key_changed' + if (code === 'ssh_host_key_confirmation_mismatch') return 'host_key_confirmation_mismatch' + return 'host_key_unknown' +} + +// ═══════════════════════════════════════════════════════════════════════════ +// remote connection slice — quiet, task-scoped SSH/Docker recovery notices +// ═══════════════════════════════════════════════════════════════════════════ + +export interface RemoteConnectionNotice extends RemoteConnectionStatusData { + task_id: string + /** Changes for every wire update so a ready-dismiss timer cannot clear a + * newer reconnect cycle for the same task. */ + revision: number +} + +interface RemoteConnectionState { + byTaskId: Record +} + +const initialRemoteConnection: RemoteConnectionState = { byTaskId: {} } + +const remoteConnectionSlice = createSlice({ + name: 'remoteConnection', + initialState: initialRemoteConnection, + reducers: { + statusReceived(s, a: { payload: RemoteConnectionStatusData }) { + const taskId = a.payload.task_id + if (!taskId) return + const previous = s.byTaskId[taskId] + const retryInMs = a.payload.retry_in_ms ?? a.payload.retry_after_ms + s.byTaskId[taskId] = { + ...a.payload, + task_id: taskId, + attempt: Math.max(0, a.payload.attempt || 0), + max_attempts: Math.max(0, a.payload.max_attempts || 0), + retry_in_ms: retryInMs === undefined ? undefined : Math.max(0, retryInMs), + revision: (previous?.revision || 0) + 1, + } + }, + clearTransient(s, a: { payload: string }) { + const current = s.byTaskId[a.payload] + if (current?.status === 'waiting' || current?.status === 'reconnecting') { + delete s.byTaskId[a.payload] + } + }, + clear(s, a: { payload: { taskId: string; revision?: number } }) { + const current = s.byTaskId[a.payload.taskId] + if (!current) return + if (a.payload.revision !== undefined && current.revision !== a.payload.revision) return + delete s.byTaskId[a.payload.taskId] + }, + reset(s) { + s.byTaskId = {} + }, + }, +}) + // ═══════════════════════════════════════════════════════════════════════════ // model slice — provider/model/mode/favorites // ═══════════════════════════════════════════════════════════════════════════ @@ -1234,6 +1442,8 @@ const uiSlice = createSlice({ export const chatActions = chatSlice.actions export const sessionActions = sessionSlice.actions +export const conversationLoadActions = conversationLoadSlice.actions +export const remoteConnectionActions = remoteConnectionSlice.actions export const modelActions = modelSlice.actions export const uiActions = uiSlice.actions @@ -1546,7 +1756,7 @@ export const loadConfig = createAsyncThunk('model/loadConfig', async (_, { dispa export const loadStatus = createAsyncThunk('app/loadStatus', async (_, { dispatch }) => { const status = await api.status() dispatch(chatActions.setRunning(!!status.running)) - dispatch(sessionActions.setProjectPath(status.pwd)) + dispatch(sessionActions.setProjectPath(status.project || status.workspace_key || status.pwd)) dispatch(modelActions.setProvider(status.provider)) dispatch(modelActions.setModel(status.model)) dispatch(modelActions.setAgent(status.agent || '')) @@ -1626,87 +1836,651 @@ export const loadWorkspaceState = createAsyncThunk('app/loadWorkspaceState', asy ]) }) -/** - * Load (replay) a session's history into the timeline. - * - * Fast path: a SINGLE POST /api/sessions round trip both resumes the session - * server-side and returns everything the view needs to repaint — the raw - * JSONL entries (the server reuses its own reconstructing read; the file is - * not read twice) plus goal/todos/status. The pane swaps to a skeleton the - * instant the click lands, so perceived latency is ~0 and the old flow's - * four serial follow-up GETs (status, ask/approval pending, goal, todos) - * are gone. Legacy fallback (older server): fetch the entries via GET and - * the rest individually, in parallel, without gating the repaint. - */ -export const loadSession = createAsyncThunk( - 'session/loadOne', - async (uuid: string, { dispatch, getState }) => { - // Immediate skeleton: the pane reacts to the click, not to the network. - dispatch(chatActions.setSessionLoading(true)) +export interface ConversationRemoteCredentials { + authMethod: 'key' | 'password' + password?: string + keyPath?: string + passphrase?: string +} + +type ConversationHistoryResult = { + entries?: SessionEntry[] + error?: string +} + +let activeConversationLoad: { + requestId: string + targetId: string + controller: AbortController + /** Ephemeral only: preserve an explicitly submitted credential across the + * host-key confirmation round trip without putting secrets in Redux. */ + credentials?: ConversationRemoteCredentials +} | null = null +let conversationNavigationGeneration = 0 +const conversationHistories = new Map>() +type PendingConversationEvent = () => void +const maxPendingConversationEvents = 256 +let pendingConversationEvents: { + requestId: string + targetId: string + events: PendingConversationEvent[] +} | null = null +export const conversationLoadTimeouts = { + historyMs: 15_000, + activationMs: 30_000, +} as const + +function deadlineSignal(parent: AbortSignal, timeoutMs: number) { + const controller = new AbortController() + let timedOut = false + let disposed = false + let timer: ReturnType | undefined + const dispose = () => { + if (disposed) return + disposed = true + if (timer !== undefined) clearTimeout(timer) + parent.removeEventListener('abort', onParentAbort) + } + const onParentAbort = () => { + dispose() + controller.abort(parent.reason) + } + if (parent.aborted) { + controller.abort(parent.reason) + } else { + parent.addEventListener('abort', onParentAbort, { once: true }) + timer = setTimeout(() => { + dispose() + timedOut = true + controller.abort(new DOMException('Request timed out', 'TimeoutError')) + }, timeoutMs) + } + return { + signal: controller.signal, + timedOut: () => timedOut, + dispose, + } +} + +function resetPendingConversationEvents(requestId: string, targetId: string) { + pendingConversationEvents = { requestId, targetId, events: [] } +} + +function clearPendingConversationEvents(requestId?: string) { + if (!requestId || pendingConversationEvents?.requestId === requestId) pendingConversationEvents = null +} + +/** Queue a task-scoped WS mutation while its durable transcript is visible in + * the loading preview but has not yet become the committed foreground. */ +export function bufferPendingConversationEvent( + requestId: string, + targetId: string, + apply: PendingConversationEvent, +): boolean { + if ( + activeConversationLoad?.requestId !== requestId || + activeConversationLoad.targetId !== targetId || + pendingConversationEvents?.requestId !== requestId || + pendingConversationEvents.targetId !== targetId + ) return false + pendingConversationEvents.events.push(apply) + if (pendingConversationEvents.events.length > maxPendingConversationEvents) { + pendingConversationEvents.events.shift() + } + return true +} + +function flushPendingConversationEvents(requestId: string, targetId: string) { + if ( + pendingConversationEvents?.requestId !== requestId || + pendingConversationEvents.targetId !== targetId + ) return + const events = pendingConversationEvents.events + pendingConversationEvents = null + for (const apply of events) apply() +} + +function isConversationLoadCurrent(getState: () => unknown, requestId: string): boolean { + return (getState() as RootState).conversationLoad.requestId === requestId +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === 'AbortError' + : error instanceof Error && error.name === 'AbortError' +} + +function remoteHostKeyPrompt(error: unknown): RemoteHostKeyErrorPayload | null { + if (!isAPIError(error) || error.status !== 409 || !error.body || typeof error.body !== 'object') return null + const body = error.body as Partial + if ( + body.code !== 'ssh_host_key_unknown' && + body.code !== 'ssh_host_key_changed' && + body.code !== 'ssh_host_key_confirmation_mismatch' + ) return null + if (!body.host || !body.fingerprint || !body.key_type) return null + return { + error: body.error || error.message, + code: body.code, + host: body.host, + fingerprint: body.fingerprint, + key_type: body.key_type, + old_fingerprint: body.old_fingerprint, + expected_fingerprint: body.expected_fingerprint, + } +} + +function looksLikeSSHAuthenticationError(error: unknown): boolean { + if (isAPIError(error) && error.body && typeof error.body === 'object') { + if ((error.body as { code?: unknown }).code === 'ssh_auth_required') return true + } + const message = error instanceof Error ? error.message : String(error) + return /auth|permission denied|private key|public key|passphrase|password/i.test(message) +} + +function conversationLoadError(error: unknown): { error: string; code?: string; retryable: boolean } { + const message = error instanceof Error ? error.message : String(error) + if (!isAPIError(error) || !error.body || typeof error.body !== 'object') { + return { error: message, retryable: true } + } + const body = error.body as { code?: unknown; retryable?: unknown } + return { + error: message, + code: typeof body.code === 'string' ? body.code : error.code, + retryable: body.retryable !== false, + } +} + +function beginConversationHistory( + requestId: string, + target: ConversationLoadTarget, + signal: AbortSignal, + dispatch: AppDispatch, + getState: () => unknown, +): Promise { + dispatch(conversationLoadActions.historyLoading({ requestId })) + const history = (async (): Promise => { + const deadline = deadlineSignal(signal, conversationLoadTimeouts.historyMs) try { - const resp = await api.newSession(uuid) - dispatch(sessionActions.setCurrentSession(resp.session_id || uuid)) - - // One-shot resume payload (entries + goal + todos + status). `provider` - // discriminates an older server without the one-shot payload at all; - // `entries` is omitted when the server could not read the session file - // — fall back to the dedicated endpoint then (a transient read failure - // must not blank a conversation that has history). - const oneShot = resp.provider !== undefined - let entries: SessionEntry[] | null | undefined = resp.entries - if (entries === undefined) { - // Older server (no one-shot payload) OR current server with an - // unreadable session file. A 404 means the session has no JSONL yet - // (fresh, never-used session) — return without rebuilding so the - // caller can fall back to a different session. - try { - entries = await api.session(uuid) - } catch { - return + const entries = await api.session(target.uuid, deadline.signal) + if (isConversationLoadCurrent(getState, requestId)) { + // The GET response is the durable barrier. Only WS frames received + // after this point need to be layered over the replay at commit. + resetPendingConversationEvents(requestId, target.uuid) + // A preview is deliberately never marked running: it is a read-only + // durable transcript while the environment catches up. + dispatch(conversationLoadActions.historyReady({ + requestId, + timeline: replayTimeline(entries, false), + })) + } + return { entries } + } catch (error) { + if (deadline.timedOut()) { + const message = i18n.t('conversationLoading.error.historyTimeout') + if (isConversationLoadCurrent(getState, requestId)) { + dispatch(conversationLoadActions.historyFailed({ requestId, error: message })) } + return { error: message } } + if (isAbortError(error)) return {} + const message = error instanceof Error ? error.message : String(error) + if (isConversationLoadCurrent(getState, requestId)) { + dispatch(conversationLoadActions.historyFailed({ requestId, error: message })) + } + return { error: message } + } finally { + deadline.dispose() + } + })() + conversationHistories.set(requestId, history) + return history +} - // Clear the UI before rebuilding. - dispatch(chatActions.clearChat()) +function sshConnectRequest( + target: ConversationLoadTarget, + credentials?: ConversationRemoteCredentials, + confirmedFingerprint?: string, +): RemoteConnectRequest { + const remote = parseRemoteLabel(target.project) + if (!remote || remote.kind !== 'ssh') throw new Error('Invalid SSH workspace address') + const authMethod = credentials?.authMethod + return { + type: 'ssh', + host: remote.host, + port: remote.port || 22, + user: remote.user || 'root', + // Omit authentication fields when the user only confirmed a host key. + // The backend then preserves its ssh-agent/default-key fallback chain. + auth_method: authMethod, + password: authMethod === 'password' ? credentials?.password : undefined, + key_path: authMethod === 'key' ? credentials?.keyPath : undefined, + passphrase: authMethod === 'key' ? credentials?.passphrase : undefined, + accept_host_key: confirmedFingerprint ? true : undefined, + host_key_fingerprint: confirmedFingerprint, + } +} - const resumedId = resp.session_id || uuid - const replayRunning = oneShot - ? !!resp.running - : !!(getState() as RootState).session.tasks.find((task) => task.uuid === resumedId)?.running - const timeline = replayTimeline(entries || [], replayRunning) - dispatch(chatActions.setTimeline(timeline)) - - if (oneShot) { - // Hydrate server-truth state from the SAME response — the old flow - // spent four extra serial round trips here (status, ask/approval - // pending, goal, todos). clearChat nulled tokenSnapshot, and no - // token_update arrives until the session's next LLM call — without - // this the context ring stays hidden after switching conversations. - dispatch(chatActions.setRunning(!!resp.running)) - if (resp.pwd) dispatch(sessionActions.setProjectPath(resp.pwd)) - dispatch(modelActions.setProvider(resp.provider || '')) - dispatch(modelActions.setModel(resp.model || '')) - dispatch(modelActions.setAgent(resp.agent || '')) - dispatch(modelActions.setMode(normalizeMode(resp.mode || ''))) - if (resp.token) dispatch(chatActions.setTokenSnapshot(resp.token)) - dispatch(chatActions.setGoal(resp.goal ?? null)) - dispatch(chatActions.setTodos(resp.todos ?? [])) - } else { - // Older server: seed isRunning from the task list (a resumed task may - // still be running), then fetch the rest individually — in parallel, - // and none of it gates the timeline repaint. - const state = getState() as RootState - const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running - dispatch(chatActions.setRunning(running)) - void dispatch(loadStatus()) - void dispatch(loadGoal()) - void dispatch(loadTodos()) +async function connectAndBindConversation( + target: ConversationLoadTarget, + credentials: ConversationRemoteCredentials | undefined, + confirmedFingerprint: string | undefined, + signal: AbortSignal, +): Promise { + const remote = parseRemoteLabel(target.project) + if (!remote || remote.kind !== 'ssh') throw new Error('Invalid SSH workspace address') + const request = sshConnectRequest(target, credentials, confirmedFingerprint) + let connectionId = '' + let bound = false + try { + const connection = await api.remoteConnect(request, signal) + connectionId = connection.connection_id + const bind = await api.remoteBind( + connection.connection_id, + remote.remotePath || connection.remote_pwd, + { session_id: target.uuid, focus: false }, + signal, + ) + bound = true + if (!bind.session_id) throw new Error('Remote workspace did not activate the requested conversation') + return { + status: bind.status, + session_id: bind.session_id, + kind: bind.kind || 'ssh', + pwd: bind.pwd, + project: bind.project || bind.label || target.project, + workspace_key: bind.workspace_key || bind.project || bind.label || target.project, + provider: bind.provider, + model: bind.model, + agent: bind.agent, + mode: bind.mode || 'approval', + running: !!bind.running, + activated: !!bind.activated, + focused: !!bind.focused, + } + } finally { + if (connectionId && !bound) void api.remoteCancel(connectionId).catch(() => undefined) + } +} + +async function commitConversation( + requestId: string, + target: ConversationLoadTarget, + response: SessionActivationResponse, + history: ConversationHistoryResult, + dispatch: AppDispatch, + getState: () => unknown, +): Promise { + if (!isConversationLoadCurrent(getState, requestId)) return + const timeline = history.entries ? replayTimeline(history.entries, !!response.running) : undefined + if (!timeline) throw new Error(history.error || 'Conversation history is unavailable') + + dispatch(chatActions.clearChat()) + dispatch(chatActions.setTimeline(timeline)) + const resumedId = response.session_id || target.uuid + dispatch(sessionActions.setCurrentSession(resumedId)) + dispatch(sessionActions.setProjectPath(response.project || response.workspace_key || target.project || response.pwd)) + dispatch(remoteConnectionActions.clear({ taskId: resumedId })) + dispatch(chatActions.setRunning(!!response.running)) + dispatch(modelActions.setProvider(response.provider || '')) + dispatch(modelActions.setModel(response.model || '')) + dispatch(modelActions.setAgent(response.agent || '')) + dispatch(modelActions.setMode(normalizeMode(response.mode || ''))) + flushPendingConversationEvents(requestId, resumedId) + // Activation deliberately has no transcript/task-detail payload. These + // task-owned details hydrate independently after the atomic foreground commit. + void dispatch(loadGoal()) + void dispatch(loadTodos()) + void dispatch(reconcilePendingInteractions()) + void dispatch(loadSessions()) + void dispatch(loadTasks()) + void dispatch(loadProjects()) + dispatch(conversationLoadActions.finish({ requestId })) + conversationHistories.delete(requestId) + clearPendingConversationEvents(requestId) + if (activeConversationLoad?.requestId === requestId) activeConversationLoad = null +} + +async function resumeConversationEnvironment( + requestId: string, + target: ConversationLoadTarget, + credentials: ConversationRemoteCredentials | undefined, + confirmedFingerprint: string | undefined, + dispatch: AppDispatch, + getState: () => unknown, +): Promise { + const active = activeConversationLoad + if (!active || active.requestId !== requestId || !isConversationLoadCurrent(getState, requestId)) return + if (credentials) active.credentials = credentials + const effectiveCredentials = credentials || active.credentials + const manualSSH = !!effectiveCredentials || !!confirmedFingerprint + dispatch(conversationLoadActions.setPhase({ + requestId, + phase: manualSSH ? 'connecting' : 'activating', + environmentStatus: 'loading', + })) + try { + const deadline = deadlineSignal(active.controller.signal, conversationLoadTimeouts.activationMs) + let response: SessionActivationResponse + try { + response = manualSSH + ? await connectAndBindConversation(target, effectiveCredentials, confirmedFingerprint, deadline.signal) + : await api.activateSession({ + session_id: target.uuid, + project_path: target.project || undefined, + focus: false, + }, deadline.signal) + } catch (error) { + if (deadline.timedOut()) throw new Error(i18n.t('conversationLoading.error.activationTimeout')) + throw error + } finally { + deadline.dispose() + } + if (!isConversationLoadCurrent(getState, requestId)) return + dispatch(conversationLoadActions.setPhase({ + requestId, + phase: 'loading', + environmentStatus: 'ready', + })) + const history = await ( + conversationHistories.get(requestId) || Promise.resolve({}) + ) + if (!isConversationLoadCurrent(getState, requestId)) return + if (!history.entries) throw new Error(history.error || 'Conversation history is unavailable') + + // Preparing an engine is deliberately non-focusing. Once the transcript is + // ready, focus the exact hydrated session and synchronously commit it to the + // foreground; Cancel before this point leaves the previous engine active. + dispatch(conversationLoadActions.setPhase({ + requestId, + phase: 'activating', + environmentStatus: 'ready', + })) + const focusDeadline = deadlineSignal(active.controller.signal, conversationLoadTimeouts.activationMs) + try { + response = await api.activateSession({ + session_id: response.session_id || target.uuid, + project_path: response.project || response.workspace_key || target.project || undefined, + focus: true, + }, focusDeadline.signal) + } catch (error) { + if (focusDeadline.timedOut()) throw new Error(i18n.t('conversationLoading.error.activationTimeout')) + throw error + } finally { + focusDeadline.dispose() + } + if (!isConversationLoadCurrent(getState, requestId)) return + await commitConversation(requestId, target, response, history, dispatch, getState) + } catch (error) { + if (isAbortError(error) || !isConversationLoadCurrent(getState, requestId)) return + const prompt = remoteHostKeyPrompt(error) + if (prompt) { + dispatch(conversationLoadActions.requireHostKey({ requestId, prompt })) + return + } + if (target.project.startsWith('ssh://') && looksLikeSSHAuthenticationError(error)) { + const issue = conversationLoadError(error) + dispatch(conversationLoadActions.requireAuth({ + requestId, + ...issue, + })) + return + } + const loadState = (getState() as RootState).conversationLoad + const historyOnly = loadState.environmentStatus === 'ready' && loadState.historyStatus === 'error' + const issue = conversationLoadError(error) + dispatch(conversationLoadActions.failed({ + requestId, + ...issue, + issue: historyOnly ? 'history' : 'remote', + })) + } +} + +/** Open an existing conversation. History and environment activation begin at + * the same time; only the newest request is allowed to commit. */ +export const openConversation = createAsyncThunk( + 'conversation/open', + async (input: ConversationLoadTarget, { dispatch, getState, requestId }) => { + conversationNavigationGeneration += 1 + const previousRequestId = activeConversationLoad?.requestId + activeConversationLoad?.controller.abort() + if (previousRequestId) { + conversationHistories.delete(previousRequestId) + clearPendingConversationEvents(previousRequestId) + } + const controller = new AbortController() + activeConversationLoad = { requestId, targetId: input.uuid, controller } + const state = getState() as RootState + const indexedTask = state.session.tasks.find((task) => task.uuid === input.uuid) + const indexedSession = state.session.sessions.find((session) => session.uuid === input.uuid) + const target: ConversationLoadTarget = { + uuid: input.uuid, + project: input.project || indexedTask?.project || state.session.projectPath, + title: input.title || indexedTask?.title || indexedSession?.title, + } + dispatch(uiActions.setView('chat')) + dispatch(conversationLoadActions.begin({ requestId, target })) + beginConversationHistory(requestId, target, controller.signal, dispatch as AppDispatch, getState) + await resumeConversationEnvironment( + requestId, + target, + undefined, + undefined, + dispatch as AppDispatch, + getState, + ) + }, +) + +/** Retry a task whose transparent remote recovery was exhausted. Ordinary + * transport failures are retried in place; credential/host-key problems move + * into the existing ConversationLoadingView's inline action flow (never the + * new-workspace RemoteConnectWizard modal). */ +export const retryRemoteConnection = createAsyncThunk( + 'remoteConnection/retry', + async (input: { taskId: string }, { dispatch, getState }) => { + const initial = getState() as RootState + const notice = initial.remoteConnection.byTaskId[input.taskId] + if (!notice) return + if (initial.session.currentSessionId === input.taskId && initial.chat.isRunning) return + const task = initial.session.tasks.find((candidate) => candidate.uuid === input.taskId) + const session = initial.session.sessions.find((candidate) => candidate.uuid === input.taskId) + const target: ConversationLoadTarget = { + uuid: input.taskId, + project: task?.project || (initial.session.currentSessionId === input.taskId ? initial.session.projectPath : ''), + title: task?.title || session?.title, + } + + if (notice.status === 'action_required') { + await dispatch(openConversation(target)) + return + } + + dispatch(remoteConnectionActions.statusReceived({ + ...notice, + status: 'reconnecting', + attempt: 0, + max_attempts: 0, + retry_in_ms: undefined, + retry_after_ms: undefined, + error: undefined, + code: undefined, + })) + + try { + // Repair without changing focus. Existing foreground runtimes retain + // their active pointer during the atomic swap; cold/background tasks are + // routed by task id. A second focus request would introduce a TOCTOU in + // which a late response could steal focus after the user switched tasks. + const response = await api.activateSession({ + session_id: input.taskId, + project_path: target.project || undefined, + source: isTauri ? 'desktop' : undefined, + focus: false, + }) + const current = getState() as RootState + if (current.session.currentSessionId !== input.taskId) { + dispatch(remoteConnectionActions.clear({ taskId: input.taskId })) + return } - // Pending approval/ask interactions only add interactive blocks — they - // never gate the repaint, so don't await them. - void dispatch(reconcilePendingInteractions()) + dispatch(sessionActions.setProjectPath(response.project || response.workspace_key || target.project || response.pwd)) + dispatch(remoteConnectionActions.statusReceived({ + task_id: input.taskId, + kind: response.kind === 'docker' ? 'docker' : 'ssh', + status: 'ready', + attempt: 0, + max_attempts: 0, + host: notice.host, + })) + } catch (error) { + if ((getState() as RootState).session.currentSessionId !== input.taskId) { + dispatch(remoteConnectionActions.clear({ taskId: input.taskId })) + return + } + const issue = remoteRetryIssue(error, notice.kind) + if (issue.actionRequired) { + // The dedicated load view owns host-key confirmation and credentials. + // Reusing it also preserves the conversation's session-aware bind. + dispatch(remoteConnectionActions.statusReceived({ + task_id: input.taskId, + kind: notice.kind, + status: 'action_required', + attempt: notice.attempt, + max_attempts: notice.max_attempts, + host: notice.host, + error: issue.error, + code: issue.code, + retryable: issue.retryable, + })) + await dispatch(openConversation(target)) + return + } + dispatch(remoteConnectionActions.statusReceived({ + task_id: input.taskId, + kind: notice.kind, + status: 'failed', + attempt: notice.attempt, + max_attempts: notice.max_attempts, + host: notice.host, + error: issue.error, + code: issue.code, + retryable: issue.retryable, + })) + } + }, +) + +function remoteRetryIssue(error: unknown, kind: RemoteConnectionStatusData['kind']): { + error: string + code?: string + retryable: boolean + actionRequired: boolean +} { + const body = isAPIError(error) && error.body && typeof error.body === 'object' + ? error.body as Record + : undefined + const code = isAPIError(error) ? error.code : undefined + const actionRequired = code === 'ssh_auth_required' || code === 'ssh_host_key_unknown' || + code === 'ssh_host_key_changed' || code === 'ssh_host_key_confirmation_mismatch' + return { + error: error instanceof Error + ? error.message + : i18n.t('remoteConnection.failed.title', { + transport: i18n.t(`remoteConnection.transport.${kind}`), + }), + code, + retryable: typeof body?.retryable === 'boolean' ? body.retryable : !actionRequired, + actionRequired, + } +} + +export const continueConversationLoad = createAsyncThunk( + 'conversation/continue', + async ( + input: { requestId: string; credentials?: ConversationRemoteCredentials; acceptHostKey?: boolean }, + { dispatch, getState }, + ) => { + const state = getState() as RootState + if (state.conversationLoad.requestId !== input.requestId || !state.conversationLoad.target) return + const target = state.conversationLoad.target + const confirmedFingerprint = input.acceptHostKey ? state.conversationLoad.hostKey?.fingerprint : undefined + if (!activeConversationLoad || activeConversationLoad.requestId !== input.requestId || activeConversationLoad.controller.signal.aborted) { + const retainedCredentials = activeConversationLoad?.requestId === input.requestId + ? activeConversationLoad.credentials + : undefined + activeConversationLoad = { + requestId: input.requestId, + targetId: target.uuid, + controller: new AbortController(), + credentials: retainedCredentials, + } + } + if (state.conversationLoad.historyStatus === 'error') { + beginConversationHistory( + input.requestId, + target, + activeConversationLoad.controller.signal, + dispatch as AppDispatch, + getState, + ) + } + await resumeConversationEnvironment( + input.requestId, + target, + input.credentials, + confirmedFingerprint, + dispatch as AppDispatch, + getState, + ) + }, +) + +export const cancelConversationLoad = createAsyncThunk( + 'conversation/cancel', + async (_, { dispatch, getState }) => { + conversationNavigationGeneration += 1 + const requestId = (getState() as RootState).conversationLoad.requestId + if (activeConversationLoad?.requestId === requestId) { + activeConversationLoad.controller.abort() + activeConversationLoad = null + } + conversationHistories.delete(requestId) + clearPendingConversationEvents(requestId) + dispatch(conversationLoadActions.reset()) + }, +) + +/** Replay the already-focused session without mutating backend focus. This is + * used to repair a missing WS tool lifecycle host, so it must be a pure history + * GET and must not outlive a foreground navigation that started after it. */ +export const loadSession = createAsyncThunk( + 'session/loadOne', + async (input: string | { uuid: string; background?: boolean }, { dispatch, getState }) => { + const uuid = typeof input === 'string' ? input : input.uuid + const background = typeof input === 'object' && !!input.background + const startState = getState() as RootState + if (startState.session.currentSessionId !== uuid) return + if (background && startState.conversationLoad.phase !== 'idle') return + const navigationRequestId = startState.conversationLoad.requestId + const navigationGeneration = conversationNavigationGeneration + // Immediate skeleton: the pane reacts to the click, not to the network. + if (!background) dispatch(chatActions.setSessionLoading(true)) + try { + const entries = await api.session(uuid) + const state = getState() as RootState + if ( + state.session.currentSessionId !== uuid || + conversationNavigationGeneration !== navigationGeneration || + state.conversationLoad.requestId !== navigationRequestId || + state.conversationLoad.phase !== 'idle' + ) return + const running = !!state.session.tasks.find((task) => task.uuid === uuid)?.running + dispatch(chatActions.clearChat()) + dispatch(chatActions.setTimeline(replayTimeline(entries, running))) + dispatch(chatActions.setRunning(running)) } finally { - dispatch(chatActions.setSessionLoading(false)) + if (!background) dispatch(chatActions.setSessionLoading(false)) } }, ) @@ -1718,6 +2492,7 @@ export const loadSession = createAsyncThunk( * the first user message (backend only indexes then). */ export const startNewChat = createAsyncThunk('session/startNew', async (_, { dispatch }) => { + await dispatch(cancelConversationLoad()) dispatch(chatActions.clearChat()) dispatch(sessionActions.setCurrentSession('')) dispatch(uiActions.setView('chat')) @@ -1764,6 +2539,8 @@ export const store = configureStore({ reducer: { chat: chatSlice.reducer, session: sessionSlice.reducer, + conversationLoad: conversationLoadSlice.reducer, + remoteConnection: remoteConnectionSlice.reducer, model: modelSlice.reducer, ui: uiSlice.reducer, }, diff --git a/web/src/app/wsBridge.remoteConnection.test.ts b/web/src/app/wsBridge.remoteConnection.test.ts new file mode 100644 index 00000000..c5959839 --- /dev/null +++ b/web/src/app/wsBridge.remoteConnection.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppDispatch, RootState } from './store' +import { createWSHandlers } from './wsBridge' + +function baseState(status: 'waiting' | 'failed'): RootState { + return { + session: { currentSessionId: 'task-1', tasks: [], sessions: [] }, + chat: { queuedBySession: {}, timeline: [] }, + conversationLoad: { phase: 'idle' }, + remoteConnection: { + byTaskId: { + 'task-1': { + task_id: 'task-1', + kind: 'ssh', + status, + attempt: 2, + max_attempts: 8, + revision: 1, + }, + }, + }, + } as unknown as RootState +} + +describe('remote connection WebSocket bridge', () => { + it('dispatches task-scoped status into the dedicated slice', () => { + const dispatch = vi.fn() + const handlers = createWSHandlers(() => baseState('waiting'), dispatch as unknown as AppDispatch) + + handlers.onRemoteConnectionStatus?.({ + task_id: 'task-1', kind: 'ssh', status: 'waiting', attempt: 2, max_attempts: 8, + }) + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: 'remoteConnection/statusReceived', + payload: expect.objectContaining({ task_id: 'task-1', status: 'waiting' }), + })) + }) + + it('clears only transient recovery when a foreground turn is stopped', () => { + const dispatch = vi.fn() + const handlers = createWSHandlers(() => baseState('waiting'), dispatch as unknown as AppDispatch) + + handlers.onAgentDone?.({ task_id: 'task-1', stopped: true }) + + expect(dispatch).toHaveBeenCalledWith({ type: 'remoteConnection/clearTransient', payload: 'task-1' }) + }) + + it('does not suppress an unstructured model error just because a failed notice remains', () => { + const state = baseState('failed') + const dispatch = vi.fn() + const handlers = createWSHandlers(() => state, dispatch as unknown as AppDispatch) + + handlers.onAgentDone?.({ task_id: 'task-1', error: 'model request failed' }) + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: 'chat/agentDone', payload: expect.objectContaining({ error: 'model request failed' }), + })) + }) + + it('uses structured agent_done metadata to suppress only remote transport errors', () => { + const state = baseState('failed') + const dispatch = vi.fn() + const handlers = createWSHandlers(() => state, dispatch as unknown as AppDispatch) + + handlers.onAgentDone?.({ + task_id: 'task-1', + error: 'connection exhausted', + code: 'ssh_connection_failed', + error_kind: 'remote_connection', + kind: 'ssh', + phase: 'before_dispatch', + retryable: true, + }) + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: 'chat/agentDone', payload: expect.objectContaining({ error: undefined }), + })) + }) + + it('overrides a recovered status when the remote command outcome is unknown', () => { + const state = baseState('failed') + state.remoteConnection.byTaskId['task-1'].status = 'ready' + const dispatch = vi.fn() + const handlers = createWSHandlers(() => state, dispatch as unknown as AppDispatch) + + handlers.onAgentDone?.({ + task_id: 'task-1', + error: 'remote command outcome is unknown', + detail: 'connection dropped after exec request was dispatched', + code: 'ssh_connection_failed', + error_kind: 'remote_connection', + kind: 'ssh', + phase: 'outcome_unknown', + retryable: true, + }) + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: 'remoteConnection/statusReceived', + payload: expect.objectContaining({ + task_id: 'task-1', status: 'action_required', code: 'remote_outcome_unknown', retryable: false, + }), + })) + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: 'chat/agentDone', payload: expect.objectContaining({ error: undefined }), + })) + }) +}) diff --git a/web/src/app/wsBridge.ts b/web/src/app/wsBridge.ts index f68da51b..d046f6bc 100644 --- a/web/src/app/wsBridge.ts +++ b/web/src/app/wsBridge.ts @@ -7,21 +7,23 @@ * dispatches the matching action for each event type. */ -import type { WSClient, WSHandlers } from '../lib/ws' +import { dispatchWSHandler, type WSClient, type WSHandlers } from '../lib/ws' import type { AppDispatch, RootState } from './store' import { chatActions, sessionActions, + remoteConnectionActions, modelActions, sendMessage, loadTasks, loadSessions, loadSession, hasToolLifecycleHost, + bufferPendingConversationEvent, } from './store' import { api } from '../lib/api' import type { Approval, Goal } from 'jcode-ui-core' -import { normalizeMode } from '../lib/types' +import { normalizeMode, type AgentDoneData } from '../lib/types' import { i18n } from '../i18n' import { normalizeWireLifecycle } from './toolLifecycle' @@ -33,6 +35,61 @@ export function createWSHandlers( getState: () => RootState, dispatch: AppDispatch, ): WSHandlers { + const applyForegroundAgentDone = (data?: AgentDoneData, taskId?: string) => { + const effectiveTaskId = taskId || getState().session.currentSessionId + // A remote transport failure has its own task-scoped, actionable status + // strip. Do not duplicate it as a permanent model-error card in the chat. + const structuredRemoteError = data?.error_kind === 'remote_connection' || (!!data?.code && ( + data.code.startsWith('ssh_') || data.code.startsWith('docker_') || data.code === 'remote_connection_failed' + )) + const suppressRemoteError = !!data?.error && structuredRemoteError + if (effectiveTaskId && suppressRemoteError) { + const previous = getState().remoteConnection.byTaskId[effectiveTaskId] + const outcomeUnknown = data?.phase === 'outcome_unknown' + const actionRequired = outcomeUnknown || data?.code === 'ssh_auth_required' || + data?.code === 'ssh_host_key_unknown' || data?.code === 'ssh_host_key_changed' || + data?.code === 'ssh_host_key_confirmation_mismatch' + dispatch(remoteConnectionActions.statusReceived({ + task_id: effectiveTaskId, + kind: data?.kind || previous?.kind || 'ssh', + status: actionRequired ? 'action_required' : 'failed', + attempt: previous?.attempt || 0, + max_attempts: previous?.max_attempts || 0, + host: previous?.host, + error: data?.detail || data?.error, + code: outcomeUnknown ? 'remote_outcome_unknown' : data?.code, + retryable: outcomeUnknown ? false : data?.retryable, + })) + } + if (effectiveTaskId && data?.stopped) { + dispatch(remoteConnectionActions.clearTransient(effectiveTaskId)) + } + dispatch(chatActions.agentDone(data + ? { + error: suppressRemoteError ? undefined : data.error, + detail: suppressRemoteError ? undefined : data.detail, + stopped: data.stopped, + } + : undefined)) + } + + const applyAgentDoneBackgroundEffects = (taskId: string | undefined, background: boolean) => { + // These effects belong to the completed task even when its foreground + // transcript is still pending or the user later cancels navigation. + void dispatch(loadTasks() as never) + void dispatch(loadSessions() as never) + const queued = taskId ? getState().chat.queuedBySession[taskId] : undefined + if (!taskId || !queued || queued.length === 0) return + const next = queued[0] + dispatch(chatActions.shiftQueued(taskId)) + void dispatch(sendMessage({ + text: next.text, + images: next.images, + sessionId: taskId, + background, + }) as never) + } + const refreshMissingLifecycleHost = ( toolCallID: string, operationID: string | undefined, @@ -44,7 +101,7 @@ export function createWSHandlers( const key = `${taskID}\u0000${toolCallID}\u0000${operationID || ''}` let refresh = pendingLifecycleRefreshes.get(key) if (!refresh) { - refresh = Promise.resolve(dispatch(loadSession(taskID))).then( + refresh = Promise.resolve(dispatch(loadSession({ uuid: taskID, background: true }))).then( () => undefined, () => undefined, ) @@ -66,8 +123,34 @@ export function createWSHandlers( }) } - return { + const handlers: WSHandlers = { activeTaskId: () => getState().session.currentSessionId || undefined, + pendingTaskId: () => { + const load = getState().conversationLoad + return load.phase !== 'idle' && load.historyStatus === 'ready' + ? load.target?.uuid + : undefined + }, + onPendingTaskEvent: (event) => { + const load = getState().conversationLoad + if (!load.target || load.target.uuid !== event.taskId || load.historyStatus !== 'ready') return + if (event.type === 'agent_done') { + const data = (event.data && typeof event.data === 'object' + ? event.data + : {}) as AgentDoneData + // Drain metadata/type-ahead now so Cancel cannot discard task-owned + // side effects. Buffer only the foreground timeline completion; using + // dispatchWSHandler here would drain the queue a second time on commit. + applyAgentDoneBackgroundEffects(event.taskId, true) + bufferPendingConversationEvent(load.requestId, event.taskId, () => { + applyForegroundAgentDone(data, event.taskId) + }) + return + } + bufferPendingConversationEvent(load.requestId, event.taskId, () => { + dispatchWSHandler(handlers, event.type, event.data) + }) + }, onConnectionChange: (connected) => dispatch(sessionActions.setWsConnected(connected)), onAgentStart: () => dispatch(chatActions.setRunning(true)), onAgentText: (d) => dispatch(chatActions.appendAgentText(d.text)), @@ -154,20 +237,10 @@ export function createWSHandlers( const activeId = getState().session.currentSessionId const isForeground = !taskId || taskId === activeId if (isForeground) { - dispatch(chatActions.agentDone(d ? { error: d.error, detail: d.detail, stopped: d.stopped } : undefined)) + applyForegroundAgentDone(d, taskId || activeId) } - // Refresh sidebar metadata (title / updated_at / running) after a turn. - void dispatch(loadTasks() as never) - void dispatch(loadSessions() as never) - // Drain one queued type-ahead message (terminal-style) from the session - // that just finished — wherever the user is currently looking. const key = taskId || activeId - const queued = key ? getState().chat.queuedBySession[key] : undefined - if (key && queued && queued.length > 0) { - const next = queued[0] - dispatch(chatActions.shiftQueued(key)) - void dispatch(sendMessage({ text: next.text, images: next.images, sessionId: key, background: !isForeground }) as never) - } + applyAgentDoneBackgroundEffects(key, !isForeground) }, onTodoUpdate: () => { void api.todos().then((todos) => dispatch(chatActions.setTodos(todos))) @@ -233,9 +306,16 @@ export function createWSHandlers( detail: d.detail, })), onUserMessage: (d) => { + // Local Web/Desktop sends are inserted optimistically by sendMessage. + // The backend still emits them for the durable Cloud relay, tagged as a + // local echo; replaying that frame here would show the turn twice. + if (d.local_echo) return dispatch(chatActions.addMessage({ role: 'user', content: d.content, source: d.source })) dispatch(chatActions.setRunning(true)) }, + onRemoteConnectionStatus: (d) => { + dispatch(remoteConnectionActions.statusReceived(d)) + }, onTaskStatus: (taskId, running, project, updatedAt) => { dispatch(sessionActions.setTaskRunning({ taskId, running })) // A status flip means real activity (a turn started/ended) — the server @@ -261,6 +341,7 @@ export function createWSHandlers( }, onSessionReset: () => dispatch(chatActions.clearChat()), } + return handlers } /** Wire a WSClient to the store. Returns the client (already connecting). */ diff --git a/web/src/app/wsBridge.userMessage.test.ts b/web/src/app/wsBridge.userMessage.test.ts new file mode 100644 index 00000000..1d203ace --- /dev/null +++ b/web/src/app/wsBridge.userMessage.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppDispatch, RootState } from './store' +import { createWSHandlers } from './wsBridge' + +describe('user-message WebSocket bridge', () => { + it('drops a local echo but preserves Cloud-originated user turns', () => { + const dispatch = vi.fn() + const handlers = createWSHandlers( + () => ({ session: { currentSessionId: 'task-1' } }) as RootState, + dispatch as unknown as AppDispatch, + ) + + handlers.onUserMessage?.({ content: 'local prompt', source: '', local_echo: true }) + expect(dispatch).not.toHaveBeenCalled() + + handlers.onUserMessage?.({ content: 'remote prompt', source: 'console' }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch.mock.calls[0][0]).toMatchObject({ + type: 'chat/addMessage', + payload: { role: 'user', content: 'remote prompt', source: 'console' }, + }) + expect(dispatch.mock.calls[1][0]).toMatchObject({ type: 'chat/setRunning', payload: true }) + }) +}) diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 1c68268c..4f467e43 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -17,6 +17,8 @@ import { useAppSelector } from '../app/hooks' import { useProductComposerHost } from '../app/composerHost' import kimiBackground from '../assets/kimi-light-background.webp' import zhipuBackground from '../assets/zhipu-light-background.webp' +import { ConversationLoadingView } from './ConversationLoadingView' +import { RemoteConnectionNotice } from './RemoteConnectionNotice' const MODEL_BACKGROUNDS = { kimi: kimiBackground, @@ -63,6 +65,7 @@ export function ChatView({ readOnly }: ChatViewProps) { const host = useProductComposerHost() const hasMessages = useAppSelector((s) => s.chat.timeline.length > 0) const sessionLoading = useAppSelector((s) => s.chat.sessionLoading) + const conversationLoadPhase = useAppSelector((s) => s.conversationLoad.phase) const pendingAskUser = useAppSelector((s) => { for (const item of s.chat.timeline) { if ( @@ -95,6 +98,8 @@ export function ChatView({ readOnly }: ChatViewProps) { ) } + if (conversationLoadPhase !== 'idle') return + // Resume in flight: swap to a skeleton the instant the click lands, so the // switch feels immediate instead of blank-until-ready (the old flow showed // nothing until the full history had fetched AND rebuilt). @@ -144,6 +149,7 @@ export function ChatView({ readOnly }: ChatViewProps) { {/* Centered elevated composer. z-[2] keeps its upward-opening menus (model picker, slash palette) above the welcome hero text. */}
+ { /* timeline auto-follows */ }} />
{/* Bottom half balances the center */} @@ -167,6 +173,7 @@ export function ChatView({ readOnly }: ChatViewProps) { {/* z-[2] keeps the composer’s upward-opening menus above the thread layer. */}
+ {/* Goal pill floats behind the composer; composer sits on top (higher z-index). */}
diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 4d959529..5290cb44 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -11,17 +11,14 @@ import { import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from '../app/hooks' import { - chatActions, - loadSession, loadTasks, - loadWorkspaceState, - modelActions, - sessionActions, + openConversation, + startNewChat, uiActions, } from '../app/store' import { api } from '../lib/api' -import { normalizeMode, type TaskItem } from '../lib/types' -import { isRemotePath, openRemoteConnect, parseRemoteLabel } from '../lib/remote' +import type { TaskItem } from '../lib/types' +import { openRemoteConnect } from '../lib/remote' interface PaletteItem { id: string @@ -36,7 +33,6 @@ export function CommandPalette() { const { t } = useTranslation() const dispatch = useAppDispatch() const tasks = useAppSelector((s) => s.session.tasks) - const activePath = useAppSelector((s) => s.session.projectPath) const [query, setQuery] = useState('') const [selectedIdx, setSelectedIdx] = useState(0) const [opening, setOpening] = useState(false) @@ -49,36 +45,22 @@ export function CommandPalette() { }, [dispatch]) async function newChat() { - dispatch(uiActions.setView('chat')) - dispatch(chatActions.clearChat()) - const resp = await api.newSession() - // Stay off the sidebar until the first user message (empty UUID rows look broken). - dispatch(sessionActions.setCurrentSession(resp.session_id)) - if (resp.provider !== undefined) dispatch(modelActions.setProvider(resp.provider)) - if (resp.model !== undefined) dispatch(modelActions.setModel(resp.model)) - if (resp.agent !== undefined) dispatch(modelActions.setAgent(resp.agent)) - if (resp.mode !== undefined) dispatch(modelActions.setMode(normalizeMode(resp.mode))) + close() + await dispatch(startNewChat()) } async function openTask(task: TaskItem) { if (opening) return setOpening(true) try { - if (task.unread) await api.updateTask(task.uuid, { unread: false }).catch(() => undefined) + if (task.unread) void api.updateTask(task.uuid, { unread: false }).catch(() => undefined) dispatch(uiActions.setView('chat')) - if (task.project && task.project !== activePath) { - if (isRemotePath(task.project)) { - const meta = parseRemoteLabel(task.project) - openRemoteConnect(meta ? { ...meta, loadTaskUuid: task.uuid } : undefined) - close() - return - } - const resp = await api.switchProject(task.project) - dispatch(sessionActions.setProjectPath(resp.pwd || task.project)) - await dispatch(loadWorkspaceState()) - } - await dispatch(loadSession(task.uuid)) close() + await dispatch(openConversation({ + uuid: task.uuid, + project: task.project || '', + title: task.title, + })) } finally { setOpening(false) } @@ -112,7 +94,7 @@ export function CommandPalette() { Icon: ChatBubbleLeftIcon, run: () => openTask(task), })), - [tasks, t, activePath, opening]) + [tasks, t, opening]) const results = useMemo(() => { const q = query.trim().toLowerCase() diff --git a/web/src/components/ConversationLoadingView.test.tsx b/web/src/components/ConversationLoadingView.test.tsx new file mode 100644 index 00000000..d8958aa5 --- /dev/null +++ b/web/src/components/ConversationLoadingView.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { Provider } from 'react-redux' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { conversationLoadActions, store } from '../app/store' +import { i18n } from '../i18n' +import { ConversationLoadingView } from './ConversationLoadingView' + +beforeEach(async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: () => {}, + }) + await i18n.changeLanguage('en') + store.dispatch(conversationLoadActions.reset()) +}) + +afterEach(() => { + cleanup() + store.dispatch(conversationLoadActions.reset()) +}) + +function begin() { + store.dispatch(conversationLoadActions.begin({ + requestId: 'loading-request', + target: { uuid: 'session-1', project: 'ssh://dev@example.com/workspace' }, + })) +} + +function renderView() { + return render() +} + +describe('ConversationLoadingView actions', () => { + it('hides Retry for a backend non-retryable failure', () => { + begin() + store.dispatch(conversationLoadActions.failed({ + requestId: 'loading-request', + error: 'Conversation no longer exists', + code: 'conversation_not_found', + retryable: false, + })) + + renderView() + + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy() + }) + + it('shows both expected and presented fingerprints after a confirmation mismatch', () => { + begin() + store.dispatch(conversationLoadActions.requireHostKey({ + requestId: 'loading-request', + prompt: { + code: 'ssh_host_key_confirmation_mismatch', + error: 'key changed during confirmation', + host: 'example.com', + key_type: 'ssh-ed25519', + fingerprint: 'SHA256:presented', + expected_fingerprint: 'SHA256:expected', + }, + })) + + renderView() + + expect(screen.getByText('SHA256:expected')).toBeTruthy() + expect(screen.getByText('SHA256:presented')).toBeTruthy() + }) + + it('marks history preview inert instead of exposing dead interaction controls', () => { + begin() + store.dispatch(conversationLoadActions.historyReady({ + requestId: 'loading-request', + timeline: [{ + kind: 'message', + seq: 1, + data: { id: 'message-1', role: 'user', content: 'saved turn', timestamp: 1 }, + }], + })) + + renderView() + + expect(screen.getByLabelText('Read-only conversation history').hasAttribute('inert')).toBe(true) + }) +}) diff --git a/web/src/components/ConversationLoadingView.tsx b/web/src/components/ConversationLoadingView.tsx new file mode 100644 index 00000000..582ad21d --- /dev/null +++ b/web/src/components/ConversationLoadingView.tsx @@ -0,0 +1,240 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ArrowPathIcon, + CheckCircleIcon, + CubeIcon, + ExclamationTriangleIcon, + KeyIcon, + ServerIcon, + ShieldCheckIcon, +} from '@heroicons/react/24/outline' +import { RuntimeProvider, Thread, createMockRuntime } from 'jcode-ui' +import { useTranslation } from 'react-i18next' +import { useAppDispatch, useAppSelector } from '../app/hooks' +import { + cancelConversationLoad, + continueConversationLoad, + type ConversationRemoteCredentials, +} from '../app/store' + +/** Dedicated foreground for opening an existing conversation. It keeps the + * previous committed chat untouched while a read-only history runtime can + * paint as soon as GET /api/sessions/{id} completes. */ +export function ConversationLoadingView() { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const load = useAppSelector((state) => state.conversationLoad) + const [authMethod, setAuthMethod] = useState<'key' | 'password'>('key') + const [password, setPassword] = useState('') + const [keyPath, setKeyPath] = useState('~/.ssh/id_rsa') + const [passphrase, setPassphrase] = useState('') + const previewRef = useRef(null) + + useEffect(() => { + setAuthMethod('key') + setPassword('') + setKeyPath('~/.ssh/id_rsa') + setPassphrase('') + }, [load.requestId]) + + useEffect(() => { + const preview = previewRef.current + if (!preview) return + // Native inert blocks pointer, keyboard and focus interaction in every + // package renderer without needing package-specific no-op callbacks. + preview.setAttribute('inert', '') + return () => preview.removeAttribute('inert') + }, [load.historyStatus]) + + const previewRuntime = useMemo( + // `isRunning` keeps jcode-ui from offering "Edit" on historical user + // turns. The pending slot is suppressed below: this runtime is a read-only + // preview, not an agent run. + () => createMockRuntime({ + items: load.previewTimeline.filter((item) => item.kind !== 'approval' || !!item.data.resolved), + isRunning: true, + }), + [load.previewTimeline], + ) + const remoteKind = load.target?.project.startsWith('docker://') ? 'docker' + : load.target?.project.startsWith('ssh://') ? 'ssh' : 'local' + const PhaseIcon = remoteKind === 'docker' ? CubeIcon : remoteKind === 'ssh' ? ServerIcon : ArrowPathIcon + const phaseLabel = load.phase === 'awaiting_host_key' || load.phase === 'awaiting_auth' + ? t('conversationLoading.phase.actionRequired') + : load.phase === 'error' + ? t('conversationLoading.phase.failed') + : load.phase === 'connecting' + ? t(`conversationLoading.phase.${remoteKind === 'docker' ? 'docker' : 'ssh'}`) + : load.phase === 'activating' + ? t('conversationLoading.phase.activating') + : t('conversationLoading.phase.history') + const credentials: ConversationRemoteCredentials = { + authMethod, + password: authMethod === 'password' ? password : undefined, + keyPath: authMethod === 'key' ? keyPath : undefined, + passphrase: authMethod === 'key' ? passphrase : undefined, + } + + function cancel() { + void dispatch(cancelConversationLoad()) + } + + function retry(options?: { acceptHostKey?: boolean; includeCredentials?: boolean }) { + void dispatch(continueConversationLoad({ + requestId: load.requestId, + acceptHostKey: options?.acceptHostKey, + credentials: options?.includeCredentials ? credentials : undefined, + })) + } + + const hostKey = load.hostKey + const unknownHost = hostKey?.code === 'ssh_host_key_unknown' + const changedHost = hostKey?.code === 'ssh_host_key_changed' + + return ( +
+
+
+ {load.phase === 'error' + ? + : } +
+
+

+ {load.target?.title || t('conversationLoading.title')} +

+

{phaseLabel}

+
+
+ + + +
+
+ + {(load.phase === 'awaiting_host_key' || load.phase === 'awaiting_auth' || load.phase === 'error') && ( +
+ {load.phase === 'awaiting_host_key' && hostKey ? ( + <> +
+ {unknownHost ? : } +
+

{unknownHost ? t('conversationLoading.hostKey.unknownTitle') : changedHost ? t('conversationLoading.hostKey.changedTitle') : t('conversationLoading.hostKey.mismatchTitle')}

+

{unknownHost ? t('conversationLoading.hostKey.unknownBody') : changedHost ? t('conversationLoading.hostKey.changedBody') : t('conversationLoading.hostKey.mismatchBody')}

+
+
+
+
{t('conversationLoading.hostKey.host')}
{hostKey.host}
+
{t('conversationLoading.hostKey.keyType')}
{hostKey.key_type}
+ {hostKey.old_fingerprint && ( +
{t('conversationLoading.hostKey.previous')}
{hostKey.old_fingerprint}
+ )} + {hostKey.expected_fingerprint && ( +
{t('conversationLoading.hostKey.expected')}
{hostKey.expected_fingerprint}
+ )} +
{t('conversationLoading.hostKey.presented')}
{hostKey.fingerprint}
+
+
+ + {unknownHost ? ( + + ) : !changedHost ? ( + + ) : null} +
+ + ) : load.phase === 'awaiting_auth' ? ( + <> +
+ +
+

{t('conversationLoading.auth.title')}

+

{load.error || t('conversationLoading.auth.body')}

+
+
+
+
+ + +
+ {authMethod === 'key' ? ( +
+ + +
+ ) : ( + + )} +
+
+ + +
+ + ) : ( + <> +
+ +

{t('conversationLoading.error.title')}

{load.error}

+
+
+ + {load.retryable && ( + + )} +
+ + )} +
+ )} + +
+ {load.historyStatus === 'ready' ? ( + <> +
+ {t('conversationLoading.historyReady')} +
+
+ + null} hidePendingAskUser /> + +
+ + ) : ( + + )} +
+
+ ) +} + +function StatusStep({ ready, active, label }: { ready: boolean; active: boolean; label: string }) { + return ( + + {ready ? : null} + {label} + + ) +} + +function ConversationHistorySkeleton({ label }: { label: string }) { + return ( +
+
+
+
+
+
+
+ {label} +
+ ) +} diff --git a/web/src/components/RemoteConnectWizard.contract.test.tsx b/web/src/components/RemoteConnectWizard.contract.test.tsx new file mode 100644 index 00000000..e51cdb27 --- /dev/null +++ b/web/src/components/RemoteConnectWizard.contract.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, render, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { store } from '../app/store' +import { api } from '../lib/api' +import { RemoteConnectWizard } from './RemoteConnectWizard' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('RemoteConnectWizard backend contract', () => { + it('focuses a newly bound workspace explicitly', async () => { + vi.spyOn(api, 'sshList').mockResolvedValue({ current: '', aliases: [] }) + vi.spyOn(api, 'remoteConnect').mockResolvedValue({ + connection_id: 'connection-new', + remote_pwd: '/workspace', + platform: 'linux', + }) + const bind = vi.spyOn(api, 'remoteBind').mockImplementation(() => new Promise(() => {})) + + render( + + {}} + /> + , + ) + + await waitFor(() => expect(bind).toHaveBeenCalledWith( + 'connection-new', + '/workspace', + { focus: true }, + )) + }) +}) diff --git a/web/src/components/RemoteConnectWizard.tsx b/web/src/components/RemoteConnectWizard.tsx index 90674e39..d6bd1b73 100644 --- a/web/src/components/RemoteConnectWizard.tsx +++ b/web/src/components/RemoteConnectWizard.tsx @@ -19,10 +19,10 @@ import { } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useAppDispatch } from '../app/hooks' -import { chatActions, loadSession, loadWorkspaceState, sessionActions } from '../app/store' -import { api } from '../lib/api' -import type { RemotePrefill } from '../lib/remote' -import type { DockerContainer, RemoteAuthMethod, RemoteKind, SSHAlias } from '../lib/types' +import { chatActions, loadWorkspaceState, sessionActions } from '../app/store' +import { api, isAPIError } from '../lib/api' +import { sshReconnectRequest, type RemotePrefill } from '../lib/remote' +import type { DockerContainer, RemoteAuthMethod, RemoteHostKeyErrorPayload, RemoteKind, SSHAlias } from '../lib/types' type Step = 'method' | 'config' | 'docker' | 'connecting' | 'dir' @@ -55,12 +55,12 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC const [dirLoading, setDirLoading] = useState(false) const [aliasName, setAliasName] = useState('') const [error, setError] = useState('') + const [hostKeyPrompt, setHostKeyPrompt] = useState(null) const [binding, setBinding] = useState(false) const [aliasMenuOpen, setAliasMenuOpen] = useState(false) - /** Keep prefill for bind-time loadTaskUuid without re-running open effect. */ - const prefillRef = useRef(null) const boundRef = useRef(false) const aliasMenuRef = useRef(null) + const hostKeyRetryRef = useRef<((fingerprint?: string) => Promise) | null>(null) const steps = useMemo( () => [ @@ -86,7 +86,6 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC useEffect(() => { if (!open) return boundRef.current = false - prefillRef.current = prefill ?? null resetForm() void loadAliases() if (prefill) { @@ -141,12 +140,13 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC setDirs([]) setAliasName('') setError('') + setHostKeyPrompt(null) + hostKeyRetryRef.current = null setBinding(false) } function applyPrefill(p: RemotePrefill) { - const colon = p.host.lastIndexOf(':') - setHost(colon >= 0 ? p.host.slice(0, colon) : p.host) + setHost(p.host) setPort(p.port || 22) setUser(p.user || 'root') setMethod(p.kind === 'docker' ? 'docker' : 'ssh') @@ -214,23 +214,12 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC } /** Seamless SSH reconnect with key/agent; fall back to prefilled form. */ - async function autoReconnectSSH(p: RemotePrefill) { + async function autoReconnectSSH(p: RemotePrefill, confirmedFingerprint?: string) { applyPrefill(p) setMethod('ssh') setStep('connecting') - const colon = p.host.lastIndexOf(':') - const h = colon >= 0 ? p.host.slice(0, colon) : p.host - const po = p.port || 22 - const u = p.user || 'root' try { - const res = await api.remoteConnect({ - type: 'ssh', - host: h.trim(), - port: po, - user: u.trim() || 'root', - auth_method: 'key', - key_path: '~/.ssh/id_rsa', - }) + const res = await api.remoteConnect(sshReconnectRequest(p, confirmedFingerprint)) setConnectionId(res.connection_id) const dir = p.remotePath && p.remotePath !== '/' ? p.remotePath : res.remote_pwd setCurrentDir(dir) @@ -243,7 +232,11 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC setStep('config') } } - } catch { + } catch (e) { + if (showHostKeyPrompt(e, (fingerprint) => autoReconnectSSH(p, fingerprint))) { + setStep('config') + return + } setError('') setStep('config') } @@ -274,13 +267,14 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC } } - async function connectSSH() { + async function connectSSH(confirmedFingerprint?: string) { if (!host.trim()) { setError('Host is required') return } await discardConnection() setError('') + setHostKeyPrompt(null) setStep('connecting') try { const res = await api.remoteConnect({ @@ -292,16 +286,48 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC password: authMethod === 'password' ? password : undefined, key_path: authMethod === 'key' ? keyPath.trim() : undefined, passphrase: authMethod === 'key' ? passphrase : undefined, + accept_host_key: confirmedFingerprint ? true : undefined, + host_key_fingerprint: confirmedFingerprint, }) setConnectionId(res.connection_id) await listDir(res.connection_id, res.remote_pwd) setStep('dir') } catch (e) { + if (showHostKeyPrompt(e, (fingerprint) => connectSSH(fingerprint))) { + setStep('config') + return + } setError(e instanceof Error ? e.message : 'Connection failed') setStep('config') } } + function showHostKeyPrompt( + errorValue: unknown, + retry: (fingerprint?: string) => Promise, + ): boolean { + if (!isAPIError(errorValue) || errorValue.status !== 409 || !errorValue.body || typeof errorValue.body !== 'object') return false + const body = errorValue.body as Partial + if ( + body.code !== 'ssh_host_key_unknown' && + body.code !== 'ssh_host_key_changed' && + body.code !== 'ssh_host_key_confirmation_mismatch' + ) return false + if (!body.host || !body.fingerprint || !body.key_type) return false + setHostKeyPrompt({ + error: body.error || errorValue.message, + code: body.code, + host: body.host, + fingerprint: body.fingerprint, + key_type: body.key_type, + old_fingerprint: body.old_fingerprint, + expected_fingerprint: body.expected_fingerprint, + }) + hostKeyRetryRef.current = retry + setError('') + return true + } + async function connectDocker(container: string) { if (!container) return await discardConnection() @@ -348,7 +374,7 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC setBinding(true) setError('') try { - const res = await api.remoteBind(connId, dir) + const res = await api.remoteBind(connId, dir, { focus: true }) if (res.kind === 'docker') { const name = aliasName.trim() || res.container || 'container' await api.remoteSaveDockerAlias(name, res.container || '', res.remote_path).catch(() => {}) @@ -359,13 +385,9 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC boundRef.current = true setConnectionId('') dispatch(chatActions.clearChat()) - dispatch(sessionActions.setProjectPath(res.label || res.pwd)) + dispatch(sessionActions.setProjectPath(res.project || res.workspace_key || res.label || res.pwd)) dispatch(sessionActions.setCurrentSession('')) await dispatch(loadWorkspaceState()) - const taskUuid = prefillRef.current?.loadTaskUuid - if (taskUuid) { - await dispatch(loadSession(taskUuid)) - } onBound?.() onClose() return true @@ -478,6 +500,42 @@ export function RemoteConnectWizard({ open, prefill, onClose, onBound }: RemoteC

{t('wizard.sshConnection')}

{t('wizard.sshDesc')}

{error &&
{error}
} + {hostKeyPrompt && ( +
+
+ {hostKeyPrompt.code === 'ssh_host_key_unknown' + ? t('conversationLoading.hostKey.unknownTitle') + : hostKeyPrompt.code === 'ssh_host_key_changed' + ? t('conversationLoading.hostKey.changedTitle') + : t('conversationLoading.hostKey.mismatchTitle')} +
+
+ {hostKeyPrompt.code === 'ssh_host_key_unknown' + ? t('conversationLoading.hostKey.unknownBody') + : hostKeyPrompt.code === 'ssh_host_key_changed' + ? t('conversationLoading.hostKey.changedBody') + : t('conversationLoading.hostKey.mismatchBody')} +
+
{hostKeyPrompt.fingerprint}
+ {hostKeyPrompt.old_fingerprint &&
{hostKeyPrompt.old_fingerprint}
} + {hostKeyPrompt.expected_fingerprint && ( +
+ {t('conversationLoading.hostKey.expected')}: {hostKeyPrompt.expected_fingerprint} +
+ )} +
+ + {hostKeyPrompt.code === 'ssh_host_key_unknown' && ( + + )} + {hostKeyPrompt.code === 'ssh_host_key_confirmation_mismatch' && ( + + )} +
+
+ )} {connecting && (
{t('wizard.connecting')} @@ -800,6 +858,46 @@ const RCW_CSS = ` border: 1px solid var(--color-error-fg, rgba(220, 38, 38, 0.3)); word-break: break-word; } +.rcw-host-key { + margin-bottom: 14px; + padding: 12px; + border: 1px solid var(--color-warning-fg); + border-radius: var(--radius-lg); + background: var(--color-warning-bg); +} +.rcw-host-key.is-danger { + border-color: var(--color-error-fg); + background: var(--color-error-bg); +} +.rcw-host-key-title { + color: var(--color-foreground); + font-size: 12px; + font-weight: 650; +} +.rcw-host-key-body { + margin-top: 4px; + color: var(--color-muted-foreground); + font-size: 11px; + line-height: 1.45; +} +.rcw-host-key-fingerprint, +.rcw-host-key-old { + margin-top: 8px; + overflow-wrap: anywhere; + color: var(--color-foreground); + font-family: var(--font-mono); + font-size: 10.5px; +} +.rcw-host-key-old { + color: var(--color-muted-foreground); + text-decoration: line-through; +} +.rcw-host-key-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} .rcw-methods { display: grid; grid-template-columns: 1fr 1fr; diff --git a/web/src/components/RemoteConnectionNotice.test.tsx b/web/src/components/RemoteConnectionNotice.test.tsx new file mode 100644 index 00000000..a7f6aade --- /dev/null +++ b/web/src/components/RemoteConnectionNotice.test.tsx @@ -0,0 +1,199 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { api } from '../lib/api' +import { i18n } from '../i18n' +import { + conversationLoadActions, + cancelConversationLoad, + remoteConnectionActions, + sessionActions, + store, +} from '../app/store' +import { RemoteConnectionNotice } from './RemoteConnectionNotice' + +beforeEach(async () => { + await i18n.changeLanguage('en') + store.dispatch(conversationLoadActions.reset()) + store.dispatch(remoteConnectionActions.reset()) + store.dispatch(sessionActions.setCurrentSession('task-active')) + store.dispatch(sessionActions.setProjectPath('ssh://dev@example.com/workspace')) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + store.dispatch(remoteConnectionActions.reset()) +}) + +function renderNotice() { + return render() +} + +describe('RemoteConnectionNotice', () => { + it('announces a bounded backoff with attempt and delay without replacing the conversation', () => { + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', + kind: 'ssh', + status: 'waiting', + attempt: 2, + max_attempts: 8, + retry_in_ms: 2_400, + host: 'dev@example.com', + })) + + renderNotice() + + expect(screen.getByRole('status', { name: 'Remote connection status' })).toBeTruthy() + expect(screen.getByText('Attempt 2/8')).toBeTruthy() + expect(screen.getByText('Retrying dev@example.com in about 3s.')).toBeTruthy() + }) + + it('does not expose a background task status in the foreground conversation', () => { + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-background', + kind: 'ssh', + status: 'reconnecting', + attempt: 1, + max_attempts: 8, + })) + + renderNotice() + + expect(screen.queryByRole('status')).toBeNull() + }) + + it('does not let an old recovered timer clear a newer status revision', () => { + vi.useFakeTimers() + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', kind: 'ssh', status: 'ready', attempt: 2, max_attempts: 8, + })) + renderNotice() + + act(() => vi.advanceTimersByTime(2_000)) + act(() => { + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', kind: 'ssh', status: 'ready', attempt: 3, max_attempts: 8, + })) + }) + act(() => vi.advanceTimersByTime(2_100)) + expect(screen.getByRole('status')).toBeTruthy() + + act(() => vi.advanceTimersByTime(2_000)) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('retries an exhausted connection in place and shows recovery', async () => { + const activate = vi.spyOn(api, 'activateSession').mockResolvedValue({ + status: 'ready', + session_id: 'task-active', + kind: 'ssh', + pwd: '/workspace', + project: 'ssh://dev@example.com/workspace', + workspace_key: 'ssh://dev@example.com/workspace', + mode: 'approval', + running: false, + activated: true, + focused: true, + }) + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', + kind: 'ssh', + status: 'failed', + attempt: 8, + max_attempts: 8, + host: 'dev@example.com', + error: 'connection reset', + retryable: true, + })) + renderNotice() + + fireEvent.click(screen.getByRole('button', { name: 'Retry now' })) + + await waitFor(() => expect(activate).toHaveBeenCalledTimes(1)) + expect(activate.mock.calls[0][0]).toMatchObject({ session_id: 'task-active', focus: false }) + await waitFor(() => expect(screen.getByText('SSH connection restored')).toBeTruthy()) + }) + + it('clears a manual retry notice when the user switches tasks before focus', async () => { + let release: ((value: Awaited>) => void) | undefined + vi.spyOn(api, 'activateSession').mockImplementation(() => new Promise((resolve) => { release = resolve })) + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', kind: 'ssh', status: 'failed', attempt: 8, max_attempts: 8, retryable: true, + })) + renderNotice() + + fireEvent.click(screen.getByRole('button', { name: 'Retry now' })) + await waitFor(() => expect(release).toBeTruthy()) + act(() => store.dispatch(sessionActions.setCurrentSession('task-other'))) + await act(async () => { + release?.({ + status: 'ready', session_id: 'task-active', kind: 'ssh', pwd: '/workspace', + project: 'ssh://dev@example.com/workspace', workspace_key: 'ssh://dev@example.com/workspace', + mode: 'approval', running: false, activated: true, focused: false, + }) + }) + + await waitFor(() => expect(store.getState().remoteConnection.byTaskId['task-active']).toBeUndefined()) + }) + + it('keeps backend detail collapsed and offers an executable review action', () => { + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', + kind: 'ssh', + status: 'action_required', + attempt: 2, + max_attempts: 8, + code: 'ssh_auth_required', + error: 'private diagnostic detail', + retryable: false, + })) + renderNotice() + + const details = screen.getByText('Technical details').closest('details') + expect(details?.hasAttribute('open')).toBe(false) + expect(screen.getByRole('button', { name: 'Review connection' })).toBeTruthy() + }) + + it('routes credential attention into the existing inline conversation load flow', async () => { + vi.spyOn(api, 'session').mockResolvedValue([]) + vi.spyOn(api, 'activateSession').mockRejectedValue(Object.assign(new Error('SSH authentication required'), { + status: 409, + code: 'ssh_auth_required', + body: { error: 'SSH authentication required', code: 'ssh_auth_required', retryable: true }, + })) + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', kind: 'ssh', status: 'action_required', attempt: 2, max_attempts: 8, + code: 'ssh_auth_required', retryable: false, + })) + renderNotice() + + fireEvent.click(screen.getByRole('button', { name: 'Review connection' })) + + await waitFor(() => expect(store.getState().conversationLoad.phase).toBe('awaiting_auth')) + expect(store.getState().conversationLoad.target?.uuid).toBe('task-active') + await store.dispatch(cancelConversationLoad()) + }) + + it('asks the user to verify an unknown command outcome instead of reconnecting again', () => { + const activate = vi.spyOn(api, 'activateSession') + store.dispatch(remoteConnectionActions.statusReceived({ + task_id: 'task-active', + kind: 'ssh', + status: 'action_required', + attempt: 2, + max_attempts: 8, + code: 'remote_outcome_unknown', + error: 'raw remote detail', + retryable: false, + })) + renderNotice() + + expect(screen.getByText('Connection restored · check the last command')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'I understand' })) + + expect(activate).not.toHaveBeenCalled() + expect(screen.queryByRole('status')).toBeNull() + }) +}) diff --git a/web/src/components/RemoteConnectionNotice.tsx b/web/src/components/RemoteConnectionNotice.tsx new file mode 100644 index 00000000..2a58564d --- /dev/null +++ b/web/src/components/RemoteConnectionNotice.tsx @@ -0,0 +1,175 @@ +import { + ArrowPathIcon, + CheckCircleIcon, + ClockIcon, + ExclamationTriangleIcon, + XMarkIcon, +} from '@heroicons/react/24/outline' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { remoteConnectionActions, retryRemoteConnection } from '../app/store' +import { useAppDispatch, useAppSelector } from '../app/hooks' +import type { RemoteConnectionNotice as RemoteConnectionNoticeState } from '../app/store' + +const READY_VISIBLE_MS = 4_000 + +/** A task-scoped operational notice for transparent SSH/Docker recovery. + * It deliberately lives beside the composer instead of replacing the thread: + * saved history stays readable and transport failures never masquerade as a + * model/agent error in the transcript. */ +export function RemoteConnectionNotice() { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const taskId = useAppSelector((s) => s.session.currentSessionId) + const taskRunning = useAppSelector((s) => s.chat.isRunning) + const notice = useAppSelector((s) => taskId ? s.remoteConnection.byTaskId[taskId] : undefined) + + useEffect(() => { + if (!notice || notice.status !== 'ready') return + const timer = window.setTimeout(() => { + dispatch(remoteConnectionActions.clear({ taskId: notice.task_id, revision: notice.revision })) + }, READY_VISIBLE_MS) + return () => window.clearTimeout(timer) + }, [dispatch, notice]) + + if (!notice) return null + + const copy = noticeCopy(notice, t) + const showProgress = notice.status === 'waiting' || notice.status === 'reconnecting' + const showAttempt = notice.attempt > 0 && notice.max_attempts > 0 + const outcomeUnknown = notice.code === 'remote_outcome_unknown' + const progress = showAttempt + ? Math.min(100, Math.max(8, (notice.attempt / notice.max_attempts) * 100)) + : 8 + const Icon = notice.status === 'ready' + ? CheckCircleIcon + : notice.status === 'waiting' + ? ClockIcon + : notice.status === 'reconnecting' + ? ArrowPathIcon + : ExclamationTriangleIcon + + return ( +
+ {showProgress && ( + + )} + +
+
+ {copy.title} + {showAttempt && ( + + {t('remoteConnection.attempt', { attempt: notice.attempt, max: notice.max_attempts })} + + )} +
+

{copy.detail}

+ {notice.error && (notice.status === 'failed' || notice.status === 'action_required') && ( +
+ {t('remoteConnection.details')} +

{notice.error}

+
+ )} +
+ {(notice.status === 'ready' || notice.status === 'failed' || notice.status === 'action_required') && ( +
+ {(notice.status === 'action_required' || (notice.status === 'failed' && notice.retryable !== false)) && ( + + )} + +
+ )} +
+ ) +} + +type Translate = (key: string, options?: Record) => string + +function noticeCopy(notice: RemoteConnectionNoticeState, t: Translate): { title: string; detail: string } { + const detailOptions = { + host: notice.host || t('remoteConnection.remoteHost'), + transport: t(`remoteConnection.transport.${notice.kind}`), + seconds: Math.max(1, Math.ceil((notice.retry_in_ms || 0) / 1_000)), + } + if (notice.status === 'waiting') { + return { + title: t('remoteConnection.waiting.title', detailOptions), + detail: notice.retry_in_ms + ? t('remoteConnection.waiting.withDelay', detailOptions) + : t('remoteConnection.waiting.detail', detailOptions), + } + } + if (notice.status === 'reconnecting') { + return { + title: t('remoteConnection.reconnecting.title', detailOptions), + detail: t('remoteConnection.reconnecting.detail', detailOptions), + } + } + if (notice.status === 'ready') { + return { + title: t('remoteConnection.ready.title', detailOptions), + detail: t('remoteConnection.ready.detail', detailOptions), + } + } + if (notice.status === 'action_required') { + const codeKey = actionRequiredKey(notice.code) + return { + title: t(`remoteConnection.actionRequired.${codeKey}.title`, detailOptions), + detail: t(`remoteConnection.actionRequired.${codeKey}.detail`, detailOptions), + } + } + return { + title: t('remoteConnection.failed.title', detailOptions), + detail: t('remoteConnection.failed.detail', detailOptions), + } +} + +function actionRequiredKey(code?: string): 'auth' | 'unknownHost' | 'changedHost' | 'outcomeUnknown' | 'generic' { + if (code === 'remote_outcome_unknown') return 'outcomeUnknown' + if (code === 'ssh_auth_required') return 'auth' + if (code === 'ssh_host_key_unknown') return 'unknownHost' + if (code === 'ssh_host_key_changed' || code === 'ssh_host_key_confirmation_mismatch') return 'changedHost' + return 'generic' +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 2d3f191e..691d86a3 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -32,7 +32,7 @@ import { } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from '../app/hooks' -import { uiActions, sessionActions, chatActions, loadSession, loadWorkspaceState, startNewChat } from '../app/store' +import { uiActions, sessionActions, chatActions, remoteConnectionActions, loadWorkspaceState, openConversation, startNewChat } from '../app/store' import { api } from '../lib/api' import type { TaskItem } from '../lib/types' import { ThemeToggle } from './ThemeToggle' @@ -344,27 +344,11 @@ export function Sidebar() { // on the resume critical path (an awaited PATCH here delayed the replay // by a full round trip on every unread open). if (row.unread) void patchTask(row.uuid, { unread: false }) - if (row.project && activePath && row.project !== activePath) { - // Remote workspaces need the wizard (prefill + optional load task). - if (isRemotePath(row.project)) { - const meta = parseRemoteLabel(row.project) - openRemoteConnect(meta ? { ...meta, loadTaskUuid: row.uuid } : undefined) - return - } - try { - const resp = await api.switchProject(row.project) - dispatch(sessionActions.setProjectPath(resp.pwd || row.project)) - await dispatch(loadWorkspaceState()) - } catch { - dispatch(chatActions.addMessage({ - role: 'system', - content: t('sidebar.switchProjectFailed'), - level: 'error', - })) - return - } - } - await dispatch(loadSession(row.uuid)) + await dispatch(openConversation({ + uuid: row.uuid, + project: row.project, + title: row.title, + })) } // Apply a task metadata patch via the API and reflect it in the store. @@ -404,6 +388,7 @@ export function Sidebar() { // stale after the await (a WS update or refresh may have landed since), // and a whole-list setTasks would clobber it. dispatch(sessionActions.removeSession(row.uuid)) + dispatch(remoteConnectionActions.clear({ taskId: row.uuid })) if (!wasActive) { dispatch(chatActions.dropSessionQueue(row.uuid)) return diff --git a/web/src/i18n/conversationLoading.test.ts b/web/src/i18n/conversationLoading.test.ts new file mode 100644 index 00000000..55908fac --- /dev/null +++ b/web/src/i18n/conversationLoading.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en' +import ja from './locales/ja' +import ko from './locales/ko' +import zhHans from './locales/zh-Hans' +import zhHant from './locales/zh-Hant' + +function leafKeys(value: unknown, prefix = ''): string[] { + if (!value || typeof value !== 'object') return [prefix] + return Object.entries(value as Record) + .flatMap(([key, child]) => leafKeys(child, prefix ? `${prefix}.${key}` : key)) + .sort() +} + +describe('conversation-loading translations', () => { + it('keeps the dedicated page complete in all five locales', () => { + const expected = leafKeys(en.conversationLoading) + for (const locale of [zhHans, zhHant, ja, ko]) { + expect(leafKeys(locale.conversationLoading)).toEqual(expected) + } + }) + + it('keeps remote recovery status complete in all five locales', () => { + const expected = leafKeys(en.remoteConnection) + for (const locale of [zhHans, zhHant, ja, ko]) { + expect(leafKeys(locale.remoteConnection)).toEqual(expected) + } + }) +}) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 6209d753..59a35560 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -1043,6 +1043,85 @@ export default { headerValue: 'Value', }, + conversationLoading: { + title: 'Opening conversation', + progress: 'Conversation restore progress', + phase: { + history: 'Loading the conversation history…', + ssh: 'Reconnecting to the SSH workspace…', + docker: 'Reconnecting to the Docker workspace…', + activating: 'Activating the conversation…', + actionRequired: 'Your confirmation is required to continue', + failed: 'The conversation needs attention', + }, + steps: { history: 'History', environment: 'Workspace', session: 'Session' }, + historyReady: 'History ready · read-only until the workspace reconnects', + loadingHistory: 'Loading conversation history…', + readOnlyHistory: 'Read-only conversation history', + hostKey: { + unknownTitle: 'Verify this SSH host', + unknownBody: 'This host has not been trusted before. Compare the fingerprint with a trusted source before continuing.', + changedTitle: 'SSH host key changed', + changedBody: 'The saved key does not match the host. This can indicate a rebuilt server or an interception attempt. The connection is blocked.', + mismatchTitle: 'The SSH key changed during confirmation', + mismatchBody: 'The fingerprint no longer matches the one you approved. Retry to inspect the newly presented key.', + host: 'Host', + keyType: 'Key type', + previous: 'Saved fingerprint', + expected: 'Previously approved fingerprint', + presented: 'Presented fingerprint', + accept: 'Trust and continue', + }, + auth: { + title: 'SSH authentication required', + body: 'Choose a key or password to reconnect this workspace.', + reconnect: 'Reconnect', + }, + error: { + title: 'Conversation could not be opened', + historyTimeout: 'Conversation history took too long to load. Retry to try again.', + activationTimeout: 'The workspace took too long to reconnect. Check the connection and retry.', + }, + }, + + remoteConnection: { + label: 'Remote connection status', + remoteHost: 'the remote host', + transport: { ssh: 'SSH', docker: 'Docker' }, + attempt: 'Attempt {attempt}/{max}', + waiting: { + title: '{transport} connection interrupted', + detail: 'Waiting before reconnecting to {host}.', + withDelay: 'Retrying {host} in about {seconds}s.', + }, + reconnecting: { + title: 'Reconnecting to {transport}', + detail: 'Checking the connection to {host}. Your task remains open.', + }, + ready: { + title: '{transport} connection restored', + detail: 'Connected to {host}. Your task can continue.', + }, + failed: { + title: '{transport} could not reconnect', + detail: 'Check the network or connection settings, then retry {host}.', + }, + actionRequired: { + auth: { title: 'SSH credentials need attention', detail: 'Authenticate again to reconnect to {host}.' }, + unknownHost: { title: 'Verify this SSH host', detail: 'Confirm the identity of {host} before reconnecting.' }, + changedHost: { title: 'SSH host identity changed', detail: 'The connection to {host} is blocked until its identity is verified.' }, + outcomeUnknown: { title: 'Connection restored · check the last command', detail: 'The connection dropped after the remote command began. JCode did not replay it; verify the remote state before trying again.' }, + generic: { title: '{transport} connection needs attention', detail: 'Review the connection to {host} to continue.' }, + }, + retry: 'Retry now', + retrying: 'Retrying…', + waitForTurn: 'Available when the turn ends', + resolve: 'Review connection', + acknowledge: 'I understand', + details: 'Technical details', + dismiss: 'Dismiss connection status', + }, + wizard: { title: 'Remote connect', chooseMethod: 'Choose a connection method', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index b3ef4cdc..b10d5a6e 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -930,6 +930,85 @@ export default { headerValue: '値', }, + conversationLoading: { + title: '会話を開いています', + progress: '会話の復元状況', + phase: { + history: '会話履歴を読み込んでいます…', + ssh: 'SSH ワークスペースに再接続しています…', + docker: 'Docker ワークスペースに再接続しています…', + activating: '会話を有効化しています…', + actionRequired: '続行するには確認が必要です', + failed: 'この会話には対応が必要です', + }, + steps: { history: '履歴', environment: 'ワークスペース', session: 'セッション' }, + historyReady: '履歴の準備完了 · 再接続までは読み取り専用です', + loadingHistory: '会話履歴を読み込んでいます…', + readOnlyHistory: '読み取り専用の会話履歴', + hostKey: { + unknownTitle: 'SSH ホストを確認', + unknownBody: 'このホストはまだ信頼されていません。続行前に、信頼できる手段でフィンガープリントを照合してください。', + changedTitle: 'SSH ホストキーが変更されました', + changedBody: '保存済みのキーとホストが一致しません。サーバーの再構築または傍受の可能性があるため、接続をブロックしました。', + mismatchTitle: '確認中に SSH キーが変更されました', + mismatchBody: '現在のフィンガープリントは承認したものと一致しません。再試行して新しいキーを確認してください。', + host: 'ホスト', + keyType: 'キーの種類', + previous: '保存済みフィンガープリント', + expected: '直前に承認したフィンガープリント', + presented: '提示されたフィンガープリント', + accept: '信頼して続行', + }, + auth: { + title: 'SSH 認証が必要です', + body: 'キーまたはパスワードを選択して、このワークスペースに再接続してください。', + reconnect: '再接続', + }, + error: { + title: '会話を開けませんでした', + historyTimeout: '会話履歴の読み込みがタイムアウトしました。再試行してください。', + activationTimeout: 'ワークスペースへの再接続がタイムアウトしました。接続を確認して再試行してください。', + }, + }, + + remoteConnection: { + label: 'リモート接続の状態', + remoteHost: 'リモートホスト', + transport: { ssh: 'SSH', docker: 'Docker' }, + attempt: '{attempt}/{max} 回目', + waiting: { + title: '{transport} 接続が一時的に切れました', + detail: '{host} への再接続を待機しています。', + withDelay: '約 {seconds} 秒後に {host} へ再接続します。', + }, + reconnecting: { + title: '{transport} に再接続しています', + detail: '{host} への接続を確認中です。タスクはそのまま保持されます。', + }, + ready: { + title: '{transport} 接続が復旧しました', + detail: '{host} に再接続しました。タスクを続行できます。', + }, + failed: { + title: '{transport} に再接続できませんでした', + detail: 'ネットワークまたは接続設定を確認し、{host} への接続を再試行してください。', + }, + actionRequired: { + auth: { title: 'SSH 認証情報の確認が必要です', detail: '{host} に再接続するには、もう一度認証してください。' }, + unknownHost: { title: 'SSH ホストを確認してください', detail: '再接続する前に {host} の識別情報を確認してください。' }, + changedHost: { title: 'SSH ホストの識別情報が変わりました', detail: '{host} の識別情報を確認するまで接続はブロックされます。' }, + outcomeUnknown: { title: '接続が復旧しました · 直前のコマンドを確認してください', detail: 'リモートコマンドの開始後に接続が切れました。JCode はコマンドを再実行していません。再試行前にリモート側の状態を確認してください。' }, + generic: { title: '{transport} 接続の確認が必要です', detail: '{host} への接続を確認してから続行してください。' }, + }, + retry: '今すぐ再試行', + retrying: '再試行中…', + waitForTurn: '現在のターン終了後に利用できます', + resolve: '接続を確認', + acknowledge: '確認しました', + details: '技術的な詳細', + dismiss: '接続状態を閉じる', + }, + wizard: { title: 'リモート接続', chooseMethod: '接続方法を選択', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index bbb49972..560b6231 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -929,6 +929,85 @@ export default { headerValue: '값', }, + conversationLoading: { + title: '대화 여는 중', + progress: '대화 복원 진행률', + phase: { + history: '대화 기록을 불러오는 중…', + ssh: 'SSH 작업 공간에 다시 연결하는 중…', + docker: 'Docker 작업 공간에 다시 연결하는 중…', + activating: '대화를 활성화하는 중…', + actionRequired: '계속하려면 확인이 필요합니다', + failed: '이 대화에 확인이 필요합니다', + }, + steps: { history: '기록', environment: '작업 공간', session: '세션' }, + historyReady: '기록 준비 완료 · 작업 공간 재연결 전까지 읽기 전용', + loadingHistory: '대화 기록을 불러오는 중…', + readOnlyHistory: '읽기 전용 대화 기록', + hostKey: { + unknownTitle: 'SSH 호스트 확인', + unknownBody: '이 호스트는 이전에 신뢰되지 않았습니다. 계속하기 전에 신뢰할 수 있는 경로로 지문을 비교하세요.', + changedTitle: 'SSH 호스트 키가 변경됨', + changedBody: '저장된 키가 호스트와 일치하지 않습니다. 서버 재구축 또는 가로채기 위험이 있어 연결을 차단했습니다.', + mismatchTitle: '확인 중 SSH 키가 변경됨', + mismatchBody: '현재 지문이 승인한 지문과 일치하지 않습니다. 다시 시도하여 새 키를 확인하세요.', + host: '호스트', + keyType: '키 유형', + previous: '저장된 지문', + expected: '직전에 승인한 지문', + presented: '제시된 지문', + accept: '신뢰하고 계속', + }, + auth: { + title: 'SSH 인증 필요', + body: '키 또는 비밀번호를 선택하여 이 작업 공간에 다시 연결하세요.', + reconnect: '다시 연결', + }, + error: { + title: '대화를 열 수 없음', + historyTimeout: '대화 기록 불러오기 시간이 초과되었습니다. 다시 시도하세요.', + activationTimeout: '작업 공간 재연결 시간이 초과되었습니다. 연결을 확인하고 다시 시도하세요.', + }, + }, + + remoteConnection: { + label: '원격 연결 상태', + remoteHost: '원격 호스트', + transport: { ssh: 'SSH', docker: 'Docker' }, + attempt: '{attempt}/{max}번째 시도', + waiting: { + title: '{transport} 연결이 일시적으로 끊겼습니다', + detail: '{host}에 다시 연결하기 위해 기다리는 중입니다.', + withDelay: '약 {seconds}초 후 {host} 연결을 다시 시도합니다.', + }, + reconnecting: { + title: '{transport}에 다시 연결하는 중', + detail: '{host} 연결을 확인하고 있습니다. 현재 작업은 그대로 유지됩니다.', + }, + ready: { + title: '{transport} 연결이 복구되었습니다', + detail: '{host}에 다시 연결했습니다. 작업을 계속할 수 있습니다.', + }, + failed: { + title: '{transport}에 다시 연결하지 못했습니다', + detail: '네트워크 또는 연결 설정을 확인한 뒤 {host} 연결을 다시 시도하세요.', + }, + actionRequired: { + auth: { title: 'SSH 인증 정보 확인 필요', detail: '{host}에 다시 연결하려면 다시 인증하세요.' }, + unknownHost: { title: 'SSH 호스트 확인 필요', detail: '다시 연결하기 전에 {host}의 신원을 확인하세요.' }, + changedHost: { title: 'SSH 호스트 신원이 변경됨', detail: '{host}의 신원을 확인할 때까지 연결이 차단됩니다.' }, + outcomeUnknown: { title: '연결 복구됨 · 이전 명령 확인 필요', detail: '원격 명령이 시작된 후 연결이 끊겼습니다. JCode는 명령을 다시 실행하지 않았으니 재시도 전에 원격 상태를 확인하세요.' }, + generic: { title: '{transport} 연결 확인 필요', detail: '{host} 연결을 검토한 후 계속하세요.' }, + }, + retry: '지금 다시 시도', + retrying: '다시 시도 중…', + waitForTurn: '현재 작업이 끝나면 사용할 수 있습니다', + resolve: '연결 확인', + acknowledge: '확인했습니다', + details: '기술 세부 정보', + dismiss: '연결 상태 닫기', + }, + wizard: { title: '원격 연결', chooseMethod: '연결 방법 선택', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 54616fe1..c99dba9b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1020,6 +1020,85 @@ export default { headerValue: '值', }, + conversationLoading: { + title: '正在打开会话', + progress: '会话恢复进度', + phase: { + history: '正在加载会话历史…', + ssh: '正在重新连接 SSH 工作区…', + docker: '正在重新连接 Docker 工作区…', + activating: '正在激活会话…', + actionRequired: '需要你的确认才能继续', + failed: '此会话需要处理', + }, + steps: { history: '历史', environment: '工作区', session: '会话' }, + historyReady: '历史已就绪 · 工作区重连前为只读', + loadingHistory: '正在加载会话历史…', + readOnlyHistory: '只读会话历史', + hostKey: { + unknownTitle: '验证此 SSH 主机', + unknownBody: '此前未信任过此主机。继续前,请通过可信渠道核对指纹。', + changedTitle: 'SSH 主机密钥已变化', + changedBody: '保存的密钥与主机不匹配。这可能是服务器重建,也可能是拦截风险,连接已被阻止。', + mismatchTitle: '确认期间 SSH 密钥发生变化', + mismatchBody: '当前指纹已不是刚才确认的指纹。请重试并检查新提供的密钥。', + host: '主机', + keyType: '密钥类型', + previous: '已保存的指纹', + expected: '刚才确认的指纹', + presented: '当前指纹', + accept: '信任并继续', + }, + auth: { + title: '需要 SSH 认证', + body: '请选择密钥或密码以重新连接此工作区。', + reconnect: '重新连接', + }, + error: { + title: '无法打开会话', + historyTimeout: '会话历史加载超时,请重试。', + activationTimeout: '工作区重连超时,请检查连接后重试。', + }, + }, + + remoteConnection: { + label: '远程连接状态', + remoteHost: '远程主机', + transport: { ssh: 'SSH', docker: 'Docker' }, + attempt: '第 {attempt}/{max} 次', + waiting: { + title: '{transport} 连接暂时中断', + detail: '正在等待重新连接 {host}。', + withDelay: '约 {seconds} 秒后重试连接 {host}。', + }, + reconnecting: { + title: '正在重新连接 {transport}', + detail: '正在检查与 {host} 的连接,当前任务仍会保留。', + }, + ready: { + title: '{transport} 连接已恢复', + detail: '已重新连接 {host},当前任务可以继续。', + }, + failed: { + title: '{transport} 自动重连未成功', + detail: '请检查网络或连接设置,然后手动重试 {host}。', + }, + actionRequired: { + auth: { title: 'SSH 凭据需要处理', detail: '请重新认证以连接 {host}。' }, + unknownHost: { title: '需要验证 SSH 主机', detail: '重新连接前,请确认 {host} 的身份。' }, + changedHost: { title: 'SSH 主机身份已变化', detail: '验证 {host} 的身份前,连接会保持阻止。' }, + outcomeUnknown: { title: '连接已恢复 · 请检查上一条命令', detail: '远程命令开始后连接中断。JCode 没有重放该命令;再次操作前请先核对远端状态。' }, + generic: { title: '{transport} 连接需要处理', detail: '请检查与 {host} 的连接后继续。' }, + }, + retry: '立即重试', + retrying: '正在重试…', + waitForTurn: '当前任务结束后可用', + resolve: '检查连接', + acknowledge: '我知道了', + details: '技术详情', + dismiss: '关闭连接状态', + }, + wizard: { title: '远程连接', chooseMethod: '选择连接方式', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 620106ac..830a814b 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -924,6 +924,85 @@ export default { headerValue: '值', }, + conversationLoading: { + title: '正在開啟對話', + progress: '對話恢復進度', + phase: { + history: '正在載入對話記錄…', + ssh: '正在重新連線 SSH 工作區…', + docker: '正在重新連線 Docker 工作區…', + activating: '正在啟用對話…', + actionRequired: '需要你的確認才能繼續', + failed: '此對話需要處理', + }, + steps: { history: '記錄', environment: '工作區', session: '對話' }, + historyReady: '記錄已就緒 · 工作區重新連線前為唯讀', + loadingHistory: '正在載入對話記錄…', + readOnlyHistory: '唯讀對話記錄', + hostKey: { + unknownTitle: '驗證此 SSH 主機', + unknownBody: '此前未信任過此主機。繼續前,請透過可信管道核對指紋。', + changedTitle: 'SSH 主機金鑰已變更', + changedBody: '儲存的金鑰與主機不符。這可能是伺服器重建,也可能是攔截風險,連線已被阻止。', + mismatchTitle: '確認期間 SSH 金鑰發生變更', + mismatchBody: '目前指紋已不是剛才確認的指紋。請重試並檢查新提供的金鑰。', + host: '主機', + keyType: '金鑰類型', + previous: '已儲存的指紋', + expected: '剛才確認的指紋', + presented: '目前指紋', + accept: '信任並繼續', + }, + auth: { + title: '需要 SSH 驗證', + body: '請選擇金鑰或密碼以重新連線此工作區。', + reconnect: '重新連線', + }, + error: { + title: '無法開啟對話', + historyTimeout: '對話記錄載入逾時,請重試。', + activationTimeout: '工作區重新連線逾時,請檢查連線後重試。', + }, + }, + + remoteConnection: { + label: '遠端連線狀態', + remoteHost: '遠端主機', + transport: { ssh: 'SSH', docker: 'Docker' }, + attempt: '第 {attempt}/{max} 次', + waiting: { + title: '{transport} 連線暫時中斷', + detail: '正在等待重新連線 {host}。', + withDelay: '約 {seconds} 秒後重試連線 {host}。', + }, + reconnecting: { + title: '正在重新連線 {transport}', + detail: '正在檢查與 {host} 的連線,目前工作仍會保留。', + }, + ready: { + title: '{transport} 連線已恢復', + detail: '已重新連線 {host},目前工作可以繼續。', + }, + failed: { + title: '{transport} 自動重新連線未成功', + detail: '請檢查網路或連線設定,然後手動重試 {host}。', + }, + actionRequired: { + auth: { title: 'SSH 憑證需要處理', detail: '請重新驗證以連線 {host}。' }, + unknownHost: { title: '需要驗證 SSH 主機', detail: '重新連線前,請確認 {host} 的身分。' }, + changedHost: { title: 'SSH 主機身分已變更', detail: '驗證 {host} 的身分前,連線會維持封鎖。' }, + outcomeUnknown: { title: '連線已恢復 · 請檢查上一個命令', detail: '遠端命令開始後連線中斷。JCode 沒有重放該命令;再次操作前請先核對遠端狀態。' }, + generic: { title: '{transport} 連線需要處理', detail: '請檢查與 {host} 的連線後繼續。' }, + }, + retry: '立即重試', + retrying: '正在重試…', + waitForTurn: '目前工作結束後可用', + resolve: '檢查連線', + acknowledge: '我知道了', + details: '技術詳細資訊', + dismiss: '關閉連線狀態', + }, + wizard: { title: '遠端連線', chooseMethod: '選擇連線方式', diff --git a/web/src/lib/api.remote.test.ts b/web/src/lib/api.remote.test.ts new file mode 100644 index 00000000..f2d73a6e --- /dev/null +++ b/web/src/lib/api.remote.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api, isAPIError } from './api' + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('remote API errors', () => { + it('preserves structured SSH host-key evidence on a 409', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + error: 'host key is unknown', + code: 'ssh_host_key_unknown', + host: 'example.com', + fingerprint: 'SHA256:abc', + key_type: 'ssh-ed25519', + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }))) + + const error = await api.remoteConnect({ + type: 'ssh', host: 'example.com', user: 'root', auth_method: 'key', + }).catch((value: unknown) => value) + + expect(isAPIError(error)).toBe(true) + expect(error).toMatchObject({ + status: 409, + code: 'ssh_host_key_unknown', + body: { fingerprint: 'SHA256:abc', key_type: 'ssh-ed25519' }, + }) + }) +}) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e87e1ad3..510d10a1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,5 @@ // API client for jcode backend — ported from web/src/composables/api.ts. -import type { ModelsResponse, AgentMode, CustomAgentInfo, ExecResponse, DiffResponse, WorkspaceInfo, GitBranchesResponse, GitCheckoutResponse, TaskItem, TaskMetaPatch, ProjectInfo, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ProviderAdvanced, ProviderToolPolicy, ImageEndpointConfig, CustomModelDetail, ValidateResult, CatalogModel, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData, ApprovalRequestData, RemoteConnectRequest, RemoteConnectResponse, RemoteListDirResponse, RemoteBindResponse, DockerContainersResponse, UsageStats, TaskStats, TokenUpdateData, ApprovalReviewConfig, ApprovalReviewConfigResponse, ArtifactRecord, ArtifactShareResult, ArtifactShareSummary } from './types' +import type { ModelsResponse, AgentMode, CustomAgentInfo, ExecResponse, DiffResponse, WorkspaceInfo, GitBranchesResponse, GitCheckoutResponse, TaskItem, TaskMetaPatch, ProjectInfo, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ProviderAdvanced, ProviderToolPolicy, ImageEndpointConfig, CustomModelDetail, ValidateResult, CatalogModel, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData, ApprovalRequestData, RemoteConnectRequest, RemoteConnectResponse, RemoteListDirResponse, RemoteBindResponse, DockerContainersResponse, UsageStats, TaskStats, TokenUpdateData, ApprovalReviewConfig, ApprovalReviewConfigResponse, ArtifactRecord, ArtifactShareResult, ArtifactShareSummary, SessionActivationResponse } from './types' import type { AuthMethod, ProviderAuthBinding, ProviderAuthFlow, ProviderAuthPollResult, ProviderAuthStatus } from './types' import type { AutomationItem, AutomationRun, AutomationTemplate, AutomationCreate, Automation } from './automation' import { apiBase } from './apiBase' @@ -14,6 +14,16 @@ interface RequestOptions extends RequestInit { skipAuth?: boolean } +export interface APIError extends Error { + status?: number + code?: string + body?: unknown +} + +export function isAPIError(error: unknown): error is APIError { + return error instanceof Error && ('status' in error || 'body' in error) +} + async function request(path: string, options?: RequestOptions): Promise { const token = getAuthToken() // Normalize to a Headers instance so every HeadersInit form (plain object, @@ -31,8 +41,10 @@ async function request(path: string, options?: RequestOptions): Promise { const body = await resp.json().catch(() => ({ error: resp.statusText })) // Attach the status so callers can distinguish 401 (bad token) from // transport/5xx failures and react differently. - const err = new Error(body.error || `HTTP ${resp.status}`) as Error & { status?: number } + const err = new Error(body.error || `HTTP ${resp.status}`) as APIError err.status = resp.status + err.code = typeof body.code === 'string' ? body.code : undefined + err.body = body throw err } return resp.json() @@ -71,7 +83,7 @@ export const api = { body: JSON.stringify({ before_user_message: beforeUserMessage }), }), health: () => - request<{ status: string; version: string; pwd: string; provider: string; model: string; agent?: string; mode: string; session_id: string; running: boolean; image_support?: boolean; needs_setup?: boolean; auth_required?: boolean }>( + request<{ status: string; version: string; pwd: string; project?: string; workspace_key?: string; provider: string; model: string; agent?: string; mode: string; session_id: string; recent_project?: string; recent_session_id?: string; running: boolean; image_support?: boolean; needs_setup?: boolean; auth_required?: boolean }>( '/api/health', ), // authVerify validates a token typed into the login gate. skipAuth keeps a 401 @@ -87,6 +99,8 @@ export const api = { running: boolean ws_clients: number pwd: string + project?: string + workspace_key?: string provider: string model: string agent?: string @@ -110,10 +124,17 @@ export const api = { }), clearGoal: () => request<{ status: string }>('/api/goal', { method: 'DELETE' }), sessions: () => request('/api/sessions'), - session: (id: string) => request(`/api/sessions/${encodeURIComponent(id)}`), + session: (id: string, signal?: AbortSignal) => + request(`/api/sessions/${encodeURIComponent(id)}`, { signal }), + activateSession: (data: { session_id: string; project_path?: string; source?: string; focus?: boolean }, signal?: AbortSignal) => + request('/api/sessions/activate', { + method: 'POST', + body: JSON.stringify(data), + signal, + }), deleteSession: (id: string) => request<{ status: string }>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }), - newSession: (sessionId?: string) => + newSession: (sessionId?: string, signal?: AbortSignal) => request<{ status: string session_id: string @@ -135,6 +156,7 @@ export const api = { }>('/api/sessions', { method: 'POST', body: sessionId ? JSON.stringify({ session_id: sessionId }) : undefined, + signal, }), files: (path?: string) => { const q = path ? `?path=${encodeURIComponent(path)}` : '' @@ -277,10 +299,11 @@ export const api = { const q = path ? `?path=${encodeURIComponent(path)}` : '' return request(`/api/browse${q}`) }, - switchProject: (path: string) => + switchProject: (path: string, signal?: AbortSignal) => request<{ status: string; pwd: string }>('/api/project/switch', { method: 'POST', body: JSON.stringify({ path }), + signal, }), // Returns the subset of the given local paths that no longer exist on disk, so // the workspace picker can hide dead entries. Send local paths only — ssh:// @@ -314,20 +337,27 @@ export const api = { request('/api/docker/containers'), // Remote connection wizard (SSH) - remoteConnect: (data: RemoteConnectRequest) => + remoteConnect: (data: RemoteConnectRequest, signal?: AbortSignal) => request('/api/remote/connect', { method: 'POST', body: JSON.stringify(data), + signal, }), remoteListDir: (connectionId: string, path: string) => request('/api/remote/list-dir', { method: 'POST', body: JSON.stringify({ connection_id: connectionId, path }), }), - remoteBind: (connectionId: string, path: string) => + remoteBind: ( + connectionId: string, + path: string, + options?: { session_id?: string; focus?: boolean }, + signal?: AbortSignal, + ) => request('/api/remote/bind', { method: 'POST', - body: JSON.stringify({ connection_id: connectionId, path }), + body: JSON.stringify({ connection_id: connectionId, path, ...options }), + signal, }), remoteCancel: (connectionId: string) => request<{ status: string }>('/api/remote/cancel', { diff --git a/web/src/lib/remote.reconnect.test.ts b/web/src/lib/remote.reconnect.test.ts new file mode 100644 index 00000000..ecb8d707 --- /dev/null +++ b/web/src/lib/remote.reconnect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { sshReconnectRequest } from './remote' + +describe('SSH workspace reconnect request', () => { + it('keeps agent/default-key authentication when confirming a host key', () => { + const request = sshReconnectRequest({ + kind: 'ssh', + host: 'example.com', + port: 2222, + user: 'dev', + remotePath: '/workspace', + }, 'SHA256:trusted') + + expect(request).toEqual({ + type: 'ssh', + host: 'example.com', + port: 2222, + user: 'dev', + accept_host_key: true, + host_key_fingerprint: 'SHA256:trusted', + }) + expect(request).not.toHaveProperty('auth_method') + expect(request).not.toHaveProperty('key_path') + }) +}) diff --git a/web/src/lib/remote.ts b/web/src/lib/remote.ts index 339ae8c1..46c019f6 100644 --- a/web/src/lib/remote.ts +++ b/web/src/lib/remote.ts @@ -3,9 +3,26 @@ * Mirrors web/src/stores/project.ts parseRemoteLabel / isRemotePath. */ -import type { RemoteMeta } from './types' +import type { RemoteConnectRequest, RemoteMeta } from './types' -export type RemotePrefill = RemoteMeta & { loadTaskUuid?: string } +export type RemotePrefill = RemoteMeta + +/** Build the credential-free reconnect request used for an existing SSH + * workspace. Omitting auth fields preserves the backend's agent/default-key + * fallback, including when retrying a host-key confirmation. */ +export function sshReconnectRequest( + prefill: RemotePrefill, + confirmedFingerprint?: string, +): RemoteConnectRequest { + return { + type: 'ssh', + host: prefill.host.trim(), + port: prefill.port || 22, + user: prefill.user.trim() || 'root', + accept_host_key: confirmedFingerprint ? true : undefined, + host_key_fingerprint: confirmedFingerprint, + } +} export function isRemotePath(path: string): boolean { return path.startsWith('ssh://') || path.startsWith('docker://') @@ -29,9 +46,24 @@ export function parseRemoteLabel(label: string): RemoteMeta | null { const slash = afterUser.indexOf('/') const hostPort = slash < 0 ? afterUser : afterUser.slice(0, slash) const remotePath = slash < 0 ? '/' : afterUser.slice(slash) - const colon = hostPort.lastIndexOf(':') - const port = colon >= 0 ? parseInt(hostPort.slice(colon + 1), 10) || 22 : 22 - return { kind: 'ssh', host: hostPort, user, port, remotePath } + let host = hostPort + let port = 22 + if (hostPort.startsWith('[')) { + const bracket = hostPort.indexOf(']') + if (bracket > 0) { + host = hostPort.slice(1, bracket) + if (hostPort[bracket + 1] === ':') port = parseInt(hostPort.slice(bracket + 2), 10) || 22 + } + } else { + const colon = hostPort.lastIndexOf(':') + // A single colon with a numeric suffix is host:port. Multiple colons are + // an unbracketed IPv6 literal and must remain intact. + if (colon > 0 && hostPort.indexOf(':') === colon && /^\d+$/.test(hostPort.slice(colon + 1))) { + host = hostPort.slice(0, colon) + port = parseInt(hostPort.slice(colon + 1), 10) || 22 + } + } + return { kind: 'ssh', host, user, port, remotePath } } /** Open RemoteConnectWizard (optionally prefilled for reconnect). */ diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b4cf4c59..48c35ba1 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -392,6 +392,27 @@ export interface RemoteConnectRequest { key_path?: string passphrase?: string container?: string // docker: container id or name + /** Confirm and persist a previously unknown SSH host key. A changed key is + * always rejected by the backend, even when this flag is present. */ + accept_host_key?: boolean + /** Fingerprint shown to the user. Required with accept_host_key so the + * backend can reject a key swap between prompt and retry. */ + host_key_fingerprint?: string +} + +export type RemoteHostKeyErrorCode = + | 'ssh_host_key_unknown' + | 'ssh_host_key_changed' + | 'ssh_host_key_confirmation_mismatch' + +export interface RemoteHostKeyErrorPayload { + error: string + code: RemoteHostKeyErrorCode + host: string + fingerprint: string + key_type: string + old_fingerprint?: string + expected_fingerprint?: string } export interface RemoteConnectResponse { @@ -419,6 +440,69 @@ export interface RemoteBindResponse { port: number container?: string remote_path: string + session_id?: string + project?: string + provider?: string + model?: string + agent?: string + mode?: string + running?: boolean + activated?: boolean + focused?: boolean + workspace_key?: string +} + +export interface SessionActivationResponse { + status: string + session_id: string + kind: 'local' | RemoteKind + pwd: string + project: string + workspace_key: string + provider?: string + model?: string + agent?: string + mode: string + running: boolean + activated: boolean + focused: boolean +} + +/** Task-scoped SSH/Docker recovery status emitted by the local control plane. + * The envelope task_id is merged into the payload by WSClient before it reaches + * Redux. `retry_after_ms` is accepted temporarily for compatibility with early + * backend builds; new emitters use `retry_in_ms`. */ +export type RemoteConnectionStatus = + | 'waiting' + | 'reconnecting' + | 'ready' + | 'action_required' + | 'failed' + +export interface RemoteConnectionStatusData { + task_id?: string + kind: RemoteKind + status: RemoteConnectionStatus + attempt: number + max_attempts: number + retry_in_ms?: number + retry_after_ms?: number + host?: string + code?: string + error?: string + retryable?: boolean +} + +export interface AgentDoneData { + error?: string + detail?: string + code?: string + error_kind?: 'remote_connection' | string + kind?: RemoteKind + phase?: 'before_dispatch' | 'outcome_unknown' | string + retryable?: boolean + stopped?: boolean + task_id?: string } export interface DockerContainer { @@ -519,10 +603,6 @@ export interface TokenUpdateData { model_context_limit: number } -export interface AgentDoneData { - error?: string -} - // --- Usage statistics --- export interface UsageDayBucket { diff --git a/web/src/lib/ws.artifact.test.ts b/web/src/lib/ws.artifact.test.ts index e6836a2d..920fa553 100644 --- a/web/src/lib/ws.artifact.test.ts +++ b/web/src/lib/ws.artifact.test.ts @@ -42,4 +42,31 @@ describe('WSClient artifact routing', () => { }) client.disconnect() }) + + it('buffers foreground mutations for a pending conversation instead of dropping them', () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + const onAgentText = vi.fn() + const onPendingTaskEvent = vi.fn() + const client = new WSClient({ + activeTaskId: () => 'task-active', + pendingTaskId: () => 'task-pending', + onPendingTaskEvent, + onAgentText, + }) + client.connect() + + FakeWebSocket.instances[0].onmessage?.({ data: JSON.stringify({ + type: 'agent_text', + task_id: 'task-pending', + data: { text: 'arrived during navigation' }, + }) }) + + expect(onAgentText).not.toHaveBeenCalled() + expect(onPendingTaskEvent).toHaveBeenCalledWith({ + type: 'agent_text', + taskId: 'task-pending', + data: { text: 'arrived during navigation' }, + }) + client.disconnect() + }) }) diff --git a/web/src/lib/ws.remoteConnection.test.ts b/web/src/lib/ws.remoteConnection.test.ts new file mode 100644 index 00000000..a92f6b73 --- /dev/null +++ b/web/src/lib/ws.remoteConnection.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WSClient } from './ws' + +class FakeWebSocket { + static OPEN = 1 + static instances: FakeWebSocket[] = [] + readyState = FakeWebSocket.OPEN + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onerror: (() => void) | null = null + onclose: (() => void) | null = null + constructor(_url: string, _protocols?: string[]) { FakeWebSocket.instances.push(this) } + send() {} + close() {} +} + +afterEach(() => { + FakeWebSocket.instances = [] + vi.unstubAllGlobals() +}) + +describe('WSClient remote connection routing', () => { + it('merges the envelope task id into an active-task status', () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + const onRemoteConnectionStatus = vi.fn() + const client = new WSClient({ activeTaskId: () => 'task-active', onRemoteConnectionStatus }) + client.connect() + + FakeWebSocket.instances[0].onmessage?.({ data: JSON.stringify({ + type: 'remote_connection_status', + task_id: 'task-active', + data: { kind: 'ssh', status: 'waiting', attempt: 2, max_attempts: 8, retry_in_ms: 500 }, + }) }) + + expect(onRemoteConnectionStatus).toHaveBeenCalledWith({ + task_id: 'task-active', kind: 'ssh', status: 'waiting', attempt: 2, max_attempts: 8, retry_in_ms: 500, + }) + client.disconnect() + }) + + it('buffers a pending target status and preserves unrelated task-scoped status', () => { + vi.stubGlobal('WebSocket', FakeWebSocket) + const onPendingTaskEvent = vi.fn() + const onRemoteConnectionStatus = vi.fn() + const client = new WSClient({ + activeTaskId: () => 'task-active', + pendingTaskId: () => 'task-pending', + onPendingTaskEvent, + onRemoteConnectionStatus, + }) + client.connect() + + const socket = FakeWebSocket.instances[0] + socket.onmessage?.({ data: JSON.stringify({ + type: 'remote_connection_status', + task_id: 'task-pending', + data: { kind: 'ssh', status: 'reconnecting', attempt: 3, max_attempts: 8 }, + }) }) + socket.onmessage?.({ data: JSON.stringify({ + type: 'remote_connection_status', + task_id: 'task-background', + data: { kind: 'ssh', status: 'failed', attempt: 8, max_attempts: 8 }, + }) }) + + expect(onPendingTaskEvent).toHaveBeenCalledWith({ + type: 'remote_connection_status', + taskId: 'task-pending', + data: { task_id: 'task-pending', kind: 'ssh', status: 'reconnecting', attempt: 3, max_attempts: 8 }, + }) + expect(onRemoteConnectionStatus).toHaveBeenCalledTimes(1) + expect(onRemoteConnectionStatus).toHaveBeenCalledWith({ + task_id: 'task-background', kind: 'ssh', status: 'failed', attempt: 8, max_attempts: 8, + }) + client.disconnect() + }) +}) diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index fb1ce2e9..48b7a03d 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -66,7 +66,7 @@ export interface WSHandlers { artifacts?: import('jcode-ui-core').ArtifactRef[] }) => void onTokenUpdate?: (data: import('./types').TokenUpdateData) => void - onAgentDone?: (data: { error?: string; detail?: string; stopped?: boolean; task_id?: string }) => void + onAgentDone?: (data: import('./types').AgentDoneData) => void onTodoUpdate?: () => void onGoalUpdate?: (data: import('jcode-ui-core').Goal | null) => void onApprovalRequest?: (data: import('./types').ApprovalRequestData) => void @@ -78,7 +78,8 @@ export interface WSHandlers { onApprovalModeChanged?: (data: { auto_approve: boolean }) => void onSubagentEvent?: (data: import('./types').SubagentEventData) => void onSubagentProgress?: (data: import('./types').SubagentProgressData) => void - onUserMessage?: (data: { content: string; source: string }) => void + onUserMessage?: (data: { content: string; source: string; local_echo?: boolean }) => void + onRemoteConnectionStatus?: (data: import('./types').RemoteConnectionStatusData) => void onTaskStatus?: (taskId: string, running: boolean, project?: string, updatedAt?: string) => void onArtifactUpserted?: (data: { task_id: string @@ -91,6 +92,16 @@ export interface WSHandlers { /** Returns the task currently shown in the foreground. Events tagged with a * DIFFERENT task id are dropped so they don't pollute the active view. */ activeTaskId?: () => string | undefined + /** Existing conversation whose history snapshot is ready but has not yet + * become foreground. Its task-scoped events are buffered by the bridge. */ + pendingTaskId?: () => string | undefined + onPendingTaskEvent?: (event: PendingTaskEvent) => void +} + +export interface PendingTaskEvent { + type: string + taskId: string + data: unknown } interface WSMessage { @@ -100,8 +111,36 @@ interface WSMessage { } /** Event types whose data payload gets the envelope task_id merged in. */ -const TASK_ID_DATA_TYPES = new Set(['approval_request', 'ask_user_request', 'agent_done', 'artifact_upserted']) -const BACKGROUND_EVENT_TYPES = new Set(['agent_done', 'artifact_upserted']) +const TASK_ID_DATA_TYPES = new Set([ + 'approval_request', + 'ask_user_request', + 'agent_done', + 'artifact_upserted', + 'remote_connection_status', +]) +const BACKGROUND_EVENT_TYPES = new Set(['agent_done', 'artifact_upserted', 'remote_connection_status']) +const PENDING_FOREGROUND_EVENT_TYPES = new Set([ + 'agent_start', + 'agent_text', + 'tool_call', + 'tool_progress', + 'tool_result', + 'token_update', + 'agent_done', + 'todo_update', + 'goal_update', + 'approval_request', + 'ask_user_request', + 'session_reset', + 'model_changed', + 'agent_changed', + 'mode_changed', + 'approval_mode_changed', + 'subagent_event', + 'subagent_progress', + 'user_message', + 'remote_connection_status', +]) export class WSClient { private ws: WebSocket | null = null @@ -123,7 +162,6 @@ export class WSClient { /** Update the handler set (e.g. when the active task changes). */ setHandlers(handlers: WSHandlers): void { this.handlers = handlers - this.handlerMap = null } /** True when the WS is open. */ @@ -156,18 +194,25 @@ export class WSClient { try { const msg: WSMessage = JSON.parse(event.data) const active = this.handlers.activeTaskId?.() + let data = msg.data + if (msg.task_id && TASK_ID_DATA_TYPES.has(msg.type)) { + data = { ...((data && typeof data === 'object' ? data : {}) as Record), task_id: msg.task_id } + } + const pending = this.handlers.pendingTaskId?.() + if ( + msg.task_id && + pending === msg.task_id && + msg.task_id !== active && + PENDING_FOREGROUND_EVENT_TYPES.has(msg.type) + ) { + this.handlers.onPendingTaskEvent?.({ type: msg.type, taskId: msg.task_id, data }) + return + } // Events tagged with a different task id are dropped so they don't // pollute the active view — EXCEPT agent_done, which the bridge needs // for every session to drain that session's type-ahead queue. if (msg.task_id && active && msg.task_id !== active && !BACKGROUND_EVENT_TYPES.has(msg.type)) return - const handler = this.handlerFor(msg.type) - if (handler) { - let data = msg.data - if (msg.task_id && TASK_ID_DATA_TYPES.has(msg.type)) { - data = { ...((data && typeof data === 'object' ? data : {}) as Record), task_id: msg.task_id } - } - handler(data) - } + dispatchWSHandler(this.handlers, msg.type, data) } catch { // parse error — drop } @@ -243,37 +288,37 @@ export class WSClient { } } +} + +/** Apply one already-routed event to a handler set. Pending-conversation + * buffers use this after the target becomes foreground. */ +export function dispatchWSHandler(handlers: WSHandlers, type: string, data: unknown): void { + // The wire contract is heterogeneous by event type; narrowing happens at + // the strongly typed handler boundary below. // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handlerMap: Record void> | null = null - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handlerFor(type: string): ((data: any) => void) | undefined { - if (!this.handlerMap) { - const h = this.handlers - this.handlerMap = { - agent_start: () => h.onAgentStart?.(), - agent_text: (d) => h.onAgentText?.(d), - tool_call: (d) => h.onToolCall?.(d), - tool_progress: (d) => h.onToolProgress?.(d), - tool_result: (d) => h.onToolResult?.(d), - token_update: (d) => h.onTokenUpdate?.(d), - agent_done: (d) => h.onAgentDone?.(d), - todo_update: () => h.onTodoUpdate?.(), - goal_update: (d) => h.onGoalUpdate?.(d), - approval_request: (d) => h.onApprovalRequest?.(d), - ask_user_request: (d) => h.onAskUserRequest?.(d), - session_reset: (d) => h.onSessionReset?.(d), - model_changed: (d) => h.onModelChanged?.(d), - agent_changed: (d) => h.onAgentChanged?.(d), - mode_changed: (d) => h.onModeChanged?.(d), - approval_mode_changed: (d) => h.onApprovalModeChanged?.(d), - subagent_event: (d) => h.onSubagentEvent?.(d), - subagent_progress: (d) => h.onSubagentProgress?.(d), - user_message: (d) => h.onUserMessage?.(d), - task_status: (d) => h.onTaskStatus?.(d?.task_id, !!d?.running, d?.project, d?.updated_at), - artifact_upserted: (d) => h.onArtifactUpserted?.(d), - pong: () => {}, - } - } - return this.handlerMap[type] + const d = data as any + switch (type) { + case 'agent_start': handlers.onAgentStart?.(); break + case 'agent_text': handlers.onAgentText?.(d); break + case 'tool_call': handlers.onToolCall?.(d); break + case 'tool_progress': handlers.onToolProgress?.(d); break + case 'tool_result': handlers.onToolResult?.(d); break + case 'token_update': handlers.onTokenUpdate?.(d); break + case 'agent_done': handlers.onAgentDone?.(d); break + case 'todo_update': handlers.onTodoUpdate?.(); break + case 'goal_update': handlers.onGoalUpdate?.(d); break + case 'approval_request': handlers.onApprovalRequest?.(d); break + case 'ask_user_request': handlers.onAskUserRequest?.(d); break + case 'session_reset': handlers.onSessionReset?.(d); break + case 'model_changed': handlers.onModelChanged?.(d); break + case 'agent_changed': handlers.onAgentChanged?.(d); break + case 'mode_changed': handlers.onModeChanged?.(d); break + case 'approval_mode_changed': handlers.onApprovalModeChanged?.(d); break + case 'subagent_event': handlers.onSubagentEvent?.(d); break + case 'subagent_progress': handlers.onSubagentProgress?.(d); break + case 'user_message': handlers.onUserMessage?.(d); break + case 'remote_connection_status': handlers.onRemoteConnectionStatus?.(d); break + case 'task_status': handlers.onTaskStatus?.(d?.task_id, !!d?.running, d?.project, d?.updated_at); break + case 'artifact_upserted': handlers.onArtifactUpserted?.(d); break } } diff --git a/web/src/styles.css b/web/src/styles.css index cfc46990..27366e73 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -381,6 +381,290 @@ select:focus-visible { color: var(--color-muted-foreground); opacity: 0.8; } + +/* Existing-conversation restore. This is a first-class page state inside the + chat surface (not a modal): history can become readable while remote + environment activation waits for authentication or host-key confirmation. */ +.conversation-loading { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; +} +.conversation-loading__header { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 12px; + min-height: 76px; + padding: 14px 22px; + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-surface) 94%, var(--color-muted)); +} +.conversation-loading__phase-icon { + display: grid; + width: 38px; + height: 38px; + flex-shrink: 0; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border)); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)); + color: var(--color-primary); +} +.conversation-loading__phase-icon.is-error { + border-color: color-mix(in srgb, var(--color-error-fg) 34%, var(--color-border)); + background: var(--color-error-bg); + color: var(--color-error-fg); +} +.conversation-loading__title { + overflow: hidden; + color: var(--color-foreground); + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} +.conversation-loading__phase { + margin-top: 3px; + color: var(--color-muted-foreground); + font-size: 11px; +} +.conversation-loading__steps { + display: flex; + align-items: center; + gap: 14px; + margin-left: auto; +} +.conversation-loading__step { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--color-muted-foreground); + font-size: 10.5px; + white-space: nowrap; +} +.conversation-loading__step-dot { + display: grid; + width: 16px; + height: 16px; + place-items: center; + border: 1px solid var(--color-border); + border-radius: 50%; +} +.conversation-loading__step.is-active { + color: var(--color-foreground); +} +.conversation-loading__step.is-active .conversation-loading__step-dot { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 10%, transparent); +} +.conversation-loading__step.is-ready { + color: var(--color-success-fg); +} +.conversation-loading__step.is-ready .conversation-loading__step-dot { + border-color: transparent; +} +.conversation-loading__action { + width: min(720px, calc(100% - 36px)); + flex-shrink: 0; + align-self: center; + margin: 18px 18px 0; + padding: 16px; + border: 1px solid color-mix(in srgb, var(--color-warning-fg) 28%, var(--color-border)); + border-radius: var(--radius-xl); + background: color-mix(in srgb, var(--color-warning-bg) 62%, var(--color-surface)); + box-shadow: var(--shadow-sm); +} +.conversation-loading__action.is-danger { + border-color: color-mix(in srgb, var(--color-error-fg) 34%, var(--color-border)); + background: color-mix(in srgb, var(--color-error-bg) 70%, var(--color-surface)); +} +.conversation-loading__action-heading { + display: flex; + align-items: flex-start; + gap: 10px; + color: var(--color-warning-fg); +} +.conversation-loading__action.is-danger .conversation-loading__action-heading { + color: var(--color-error-fg); +} +.conversation-loading__action-heading > svg { + flex-shrink: 0; + margin-top: 1px; +} +.conversation-loading__action-heading h2 { + color: var(--color-foreground); + font-size: 13px; + font-weight: 650; +} +.conversation-loading__action-heading p { + margin-top: 3px; + color: var(--color-muted-foreground); + font-size: 11px; + line-height: 1.5; +} +.conversation-loading__fingerprint { + display: grid; + gap: 6px; + margin: 13px 0 0 30px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-background) 72%, transparent); +} +.conversation-loading__fingerprint > div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 10px; +} +.conversation-loading__fingerprint dt { + color: var(--color-muted-foreground); + font-size: 10.5px; +} +.conversation-loading__fingerprint dd { + overflow-wrap: anywhere; + color: var(--color-foreground); + font-family: var(--font-mono); + font-size: 10.5px; +} +.conversation-loading__buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} +.conversation-loading__primary, +.conversation-loading__secondary { + display: inline-flex; + min-height: 32px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: 11px; + font-weight: 600; + transition: background 120ms ease, border-color 120ms ease; +} +.conversation-loading__primary { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-background); +} +.conversation-loading__primary:hover { + background: color-mix(in srgb, var(--color-primary) 88%, var(--color-foreground)); +} +.conversation-loading__secondary { + background: var(--color-surface); + color: var(--color-foreground); +} +.conversation-loading__secondary:hover { + background: var(--color-muted); +} +.conversation-loading__auth { + display: grid; + gap: 10px; + margin: 13px 0 0 30px; +} +.conversation-loading__segmented { + display: inline-flex; + width: fit-content; + gap: 2px; + padding: 2px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-muted); +} +.conversation-loading__segmented button { + padding: 5px 9px; + border-radius: calc(var(--radius-md) - 2px); + color: var(--color-muted-foreground); + font-size: 10.5px; +} +.conversation-loading__segmented button.is-active { + background: var(--color-surface); + box-shadow: var(--shadow-sm); + color: var(--color-foreground); +} +.conversation-loading__auth label { + display: grid; + gap: 5px; + color: var(--color-muted-foreground); + font-size: 10.5px; +} +.conversation-loading__auth input { + height: 34px; + min-width: 0; + padding: 0 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + outline: none; + background: var(--color-background); + color: var(--color-foreground); + font-family: var(--font-mono); + font-size: 11px; +} +.conversation-loading__auth input:focus { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 16%, transparent); +} +.conversation-loading__field-row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 10px; +} +.conversation-loading__history { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + position: relative; +} +.conversation-loading__history-label { + display: inline-flex; + flex-shrink: 0; + align-items: center; + align-self: center; + gap: 5px; + margin: 12px 0 2px; + padding: 4px 9px; + border-radius: 999px; + background: var(--color-success-bg); + color: var(--color-success-fg); + font-size: 10px; + font-weight: 600; +} +.conversation-loading__preview { + min-height: 0; + flex: 1; + opacity: 0.82; +} +.conversation-loading__preview::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 48px; + pointer-events: none; + background: linear-gradient(to bottom, transparent, var(--color-surface)); + content: ''; +} +@media (max-width: 800px) { + .conversation-loading__header { + align-items: flex-start; + flex-wrap: wrap; + } + .conversation-loading__steps { + width: 100%; + margin-left: 50px; + } + .conversation-loading__field-row { + grid-template-columns: 1fr; + } +} @media (prefers-reduced-motion: reduce) { .rs-bar { animation: none; } } @@ -922,3 +1206,170 @@ html.is-tauri-macos .titlebar-drag { padding-left: 0.8rem; color: var(--color-muted-foreground); } + +/* ─── Task-scoped remote recovery ─── + A quiet operational layer above the composer. It deliberately does not use + chat error styling: an SSH transport interruption is not a model failure. */ +.remote-connection-notice { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + margin: 0 0 0.55rem; + padding: 0.62rem 0.7rem; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-sm); + color: var(--color-foreground); +} +.remote-connection-notice[data-status='waiting'], +.remote-connection-notice[data-status='reconnecting'], +.remote-connection-notice[data-status='action_required'] { + border-color: var(--color-warning-fg); + background: var(--color-warning-bg); +} +.remote-connection-notice[data-status='ready'] { + border-color: var(--color-success-fg); + background: var(--color-success-bg); +} +.remote-connection-notice[data-status='failed'] { + border-color: var(--color-error-fg); + background: var(--color-error-bg); +} +.remote-connection-notice__track { + position: absolute; + inset: 0 0 auto; + height: 2px; + background: var(--color-border); +} +.remote-connection-notice__track > span { + display: block; + height: 100%; + background: var(--color-warning-fg); + transition: width var(--duration-normal); +} +.remote-connection-notice__icon { + display: inline-flex; + width: 1.75rem; + height: 1.75rem; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + background: var(--color-muted); + color: var(--color-muted-foreground); +} +.remote-connection-notice[data-status='waiting'] .remote-connection-notice__icon, +.remote-connection-notice[data-status='reconnecting'] .remote-connection-notice__icon, +.remote-connection-notice[data-status='action_required'] .remote-connection-notice__icon { + color: var(--color-warning-fg); +} +.remote-connection-notice[data-status='ready'] .remote-connection-notice__icon { + color: var(--color-success-fg); +} +.remote-connection-notice[data-status='failed'] .remote-connection-notice__icon { + color: var(--color-error-fg); +} +.remote-connection-notice__copy { + min-width: 0; +} +.remote-connection-notice__heading { + display: flex; + min-width: 0; + align-items: baseline; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25; +} +.remote-connection-notice__attempt { + flex: none; + color: var(--color-muted-foreground); + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 500; +} +.remote-connection-notice__copy p { + margin: 0.16rem 0 0; + overflow: hidden; + color: var(--color-muted-foreground); + font-size: 0.6875rem; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.remote-connection-notice__details { + margin-top: 0.2rem; + color: var(--color-muted-foreground); + font-size: 0.625rem; +} +.remote-connection-notice__details summary { + width: max-content; + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--color-border-active); + text-underline-offset: 2px; +} +.remote-connection-notice__details > p { + margin-top: 0.25rem; + white-space: normal; + overflow-wrap: anywhere; +} +.remote-connection-notice__actions { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.remote-connection-notice__retry { + display: inline-flex; + min-height: 1.75rem; + align-items: center; + gap: 0.3rem; + padding: 0 0.55rem; + border: 1px solid var(--color-border-active); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-foreground); + font-size: 0.6875rem; + font-weight: 600; + transition: border-color var(--duration-fast), background-color var(--duration-fast); +} +.remote-connection-notice__retry:hover { + border-color: var(--color-ring); + background: var(--color-muted); +} +.remote-connection-notice__retry:disabled { + cursor: default; + opacity: 0.6; +} +.remote-connection-notice__retry:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: 2px; +} +.remote-connection-notice__dismiss { + display: inline-flex; + width: 1.65rem; + height: 1.65rem; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + color: var(--color-muted-foreground); + transition: color var(--duration-fast), background-color var(--duration-fast); +} +.remote-connection-notice__dismiss:hover { + background: var(--color-muted); + color: var(--color-foreground); +} +.remote-connection-notice__dismiss:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: -2px; +} +@media (prefers-reduced-motion: reduce) { + .remote-connection-notice__track > span, + .remote-connection-notice__retry, + .remote-connection-notice__dismiss { + transition: none; + } +}