From c81efd4ff2883f0eacdb55f22f0460edc1a9e92f Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 22 May 2026 14:53:08 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20node=20CLI=20adapter=20SSE=20=EB=B0=8F?= =?UTF-8?q?=20persistent=20execution=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/node/internal/adapters/cli/cli.go | 15 ++ .../internal/adapters/cli/opencode_sse.go | 70 +++++++++- .../cli/opencode_sse_blackbox_test.go | 131 ++++++++++++++++++ .../cli/opencode_sse_internal_test.go | 5 +- apps/node/internal/adapters/cli/persistent.go | 115 ++++++++++----- .../cli/persistent_execute_blackbox_test.go | 107 ++++++++++++++ 6 files changed, 409 insertions(+), 34 deletions(-) diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index 03da330..9f7f11f 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -54,6 +54,21 @@ type profileSession struct { done <-chan error closeFn func() error mu sync.Mutex + + tailMu sync.Mutex + tail strings.Builder +} + +func (s *profileSession) appendTail(text string) { + s.tailMu.Lock() + defer s.tailMu.Unlock() + appendBounded(&s.tail, text, 2048) +} + +func (s *profileSession) getTail() string { + s.tailMu.Lock() + defer s.tailMu.Unlock() + return s.tail.String() } type codexExecSession struct { diff --git a/apps/node/internal/adapters/cli/opencode_sse.go b/apps/node/internal/adapters/cli/opencode_sse.go index 9120a3f..53d3a4b 100644 --- a/apps/node/internal/adapters/cli/opencode_sse.go +++ b/apps/node/internal/adapters/cli/opencode_sse.go @@ -300,6 +300,13 @@ func opencodeEventEnvelope(data []byte) (opencodeEnvelope, bool) { return *global.Payload, true } +// sseEvtTrace summarises one received SSE event for timeout/cancel diagnostics. +type sseEvtTrace struct { + Type string `json:"t"` + SID string `json:"sid,omitempty"` // "ok", "mm" (mismatch), or "" + Outcome string `json:"out,omitempty"` // "delta", "idle", "error", "skip", "filtered" +} + func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, sess *opencodeSSESession, opts opencodeRunOpts, body io.Reader, sink runtime.EventSink) error { type evt struct { ev opencodeEnvelope @@ -335,12 +342,45 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, partTypes = map[string]string{} ) + // Bounded ring buffer for cancel/timeout diagnostics. + const traceSize = 8 + var ( + traceRing [traceSize]sseEvtTrace + traceHead int + traceSeen int + ) + addTrace := func(entry sseEvtTrace) { + traceRing[traceHead%traceSize] = entry + traceHead++ + traceSeen++ + } + recentTrace := func() []sseEvtTrace { + n := traceHead + if n > traceSize { + n = traceSize + } + out := make([]sseEvtTrace, n) + start := traceHead - n + for i := 0; i < n; i++ { + out[i] = traceRing[(start+i)%traceSize] + } + return out + } + finalize := func(reason error) error { bg, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if sess.sessionID != "" { _ = opencodeAbort(bg, sess.serverURL, sess.sessionID) } + if c.logger != nil { + c.logger.Warn("opencode sse run cancelled without completion", + zap.String("run_id", spec.RunID), + zap.String("reason", reason.Error()), + zap.Int("total_events", traceSeen), + zap.Any("recent_events", recentTrace()), + ) + } _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, @@ -373,8 +413,13 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, // Filter events that carry a sessionID mismatching ours, when present. if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" && sess.sessionID != "" && sid != sess.sessionID { + addTrace(sseEvtTrace{Type: e.ev.Type, SID: "mm", Outcome: "filtered"}) continue } + sidStatus := "" + if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" { + sidStatus = "ok" + } switch e.ev.Type { case "session.next.text.delta": @@ -387,6 +432,9 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, Delta: delta, Timestamp: time.Now(), }) + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "delta"}) + } else { + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"}) } case "message.part.updated": if partID, partType, ok := opencodePartInfo(e.ev.Properties); ok { @@ -400,6 +448,7 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, outputTokens = out } } + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"}) case "message.part.delta": delta := opencodeMessagePartDeltaText(e.ev.Properties, partTypes) if delta != "" { @@ -410,6 +459,9 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, Delta: delta, Timestamp: time.Now(), }) + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "delta"}) + } else { + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"}) } case "session.next.step.ended": if in, out, ok := opencodeStepTokens(e.ev.Properties); ok { @@ -420,14 +472,19 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, outputTokens = out } } + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"}) case "session.idle": + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "idle"}) return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens) case "session.status": if opencodeStatusIdle(e.ev.Properties) { + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "idle"}) return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens) } + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"}) case "session.error": msg := opencodeErrorMessage(e.ev.Properties) + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "error"}) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeError, @@ -449,6 +506,9 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, _ = opencodePermissionReply(bg, sess.serverURL, permID, reply) cancel() } + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"}) + default: + addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"}) } } } @@ -506,8 +566,14 @@ func opencodeMessagePartDeltaText(props map[string]any, partTypes map[string]str return "" } partID, _ := props["partID"].(string) - if partID == "" || partTypes[partID] != "text" { - return "" + // Only reject the delta when we positively know the part is not text type. + // If message.part.updated was missed (e.g. second SSE connection opened after + // the server already emitted it), partTypes has no entry and we allow the + // delta rather than silently dropping it. + if partID != "" { + if pt, known := partTypes[partID]; known && pt != "text" { + return "" + } } delta, _ := props["delta"].(string) return delta diff --git a/apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go b/apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go index 96e1e01..6cc4d87 100644 --- a/apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go +++ b/apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go @@ -583,6 +583,137 @@ func TestCLIExecuteOpencodeSSE_SessionErrorEmitsRuntimeError(t *testing.T) { } } +func TestCLIExecuteOpencodeSSE_ConsecutiveExecutesReuseSession(t *testing.T) { + fake, srv := newOpencodeFakeServer(t, "ses_reuse") + + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "opencode": opencodeSSEProfile(srv.URL), + }, + } + c := clipkg.New(cfg, zap.NewNop()) + + waitPromptCalls := func(t *testing.T, want int32) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for fake.promptCalls.Load() < want && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if fake.promptCalls.Load() < want { + t.Fatalf("prompt_async call count: got %d, want >= %d", fake.promptCalls.Load(), want) + } + } + + // --- First Execute --- + sink1 := &testutil.FakeSink{} + done1 := make(chan error, 1) + go func() { + done1 <- c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-seq1", + Target: "opencode", + Input: map[string]any{"prompt": "first"}, + }, sink1) + }() + + waitPromptCalls(t, 1) + fake.push(map[string]any{ + "type": "session.next.text.delta", + "properties": map[string]any{ + "sessionID": "ses_reuse", + "delta": "IOP_E2E_OPENCODE_ONE", + }, + }) + fake.push(map[string]any{ + "type": "session.idle", + "properties": map[string]any{"sessionID": "ses_reuse"}, + }) + + select { + case err := <-done1: + if err != nil { + t.Fatalf("first execute: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("first execute did not return") + } + + if combined := testutil.CollectDeltas(sink1.Events()); combined != "IOP_E2E_OPENCODE_ONE" { + t.Errorf("run1 deltas: got %q, want %q", combined, "IOP_E2E_OPENCODE_ONE") + } + if fake.createCalls.Load() != 1 { + t.Errorf("after run1: expected 1 createSession call, got %d", fake.createCalls.Load()) + } + + // --- Second Execute on same CLI / same session --- + sink2 := &testutil.FakeSink{} + done2 := make(chan error, 1) + go func() { + done2 <- c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-seq2", + Target: "opencode", + Input: map[string]any{"prompt": "second"}, + }, sink2) + }() + + waitPromptCalls(t, 2) + fake.push(map[string]any{ + "type": "session.next.text.delta", + "properties": map[string]any{ + "sessionID": "ses_reuse", + "delta": "IOP_E2E_OPENCODE_TWO", + }, + }) + fake.push(map[string]any{ + "type": "session.idle", + "properties": map[string]any{"sessionID": "ses_reuse"}, + }) + + select { + case err := <-done2: + if err != nil { + t.Fatalf("second execute: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("second execute did not return") + } + + // Session was reused: still 1 create call, 2 prompt calls. + if fake.createCalls.Load() != 1 { + t.Errorf("after run2: expected 1 createSession call (reused), got %d", fake.createCalls.Load()) + } + if fake.promptCalls.Load() != 2 { + t.Errorf("expected 2 prompt_async calls, got %d", fake.promptCalls.Load()) + } + + // run2 sink must have the second delta and a complete, not the first delta. + if combined := testutil.CollectDeltas(sink2.Events()); combined != "IOP_E2E_OPENCODE_TWO" { + t.Errorf("run2 deltas: got %q, want %q", combined, "IOP_E2E_OPENCODE_TWO") + } + for _, e := range sink2.Events() { + if e.Type == noderuntime.EventTypeDelta && e.Delta == "IOP_E2E_OPENCODE_ONE" { + t.Error("run2 sink must not contain run1 delta") + } + } + var run1Complete, run2Complete bool + for _, e := range sink1.Events() { + if e.Type == noderuntime.EventTypeComplete { + run1Complete = true + } + } + for _, e := range sink2.Events() { + if e.Type == noderuntime.EventTypeComplete { + run2Complete = true + } + } + if !run1Complete { + t.Error("run1: expected complete event") + } + if !run2Complete { + t.Error("run2: expected complete event") + } +} + func TestCLIExecuteOpencodeSSE_ContextCancelAbortsSession(t *testing.T) { fake, srv := newOpencodeFakeServer(t, "ses_1") diff --git a/apps/node/internal/adapters/cli/opencode_sse_internal_test.go b/apps/node/internal/adapters/cli/opencode_sse_internal_test.go index 5146abc..a37628a 100644 --- a/apps/node/internal/adapters/cli/opencode_sse_internal_test.go +++ b/apps/node/internal/adapters/cli/opencode_sse_internal_test.go @@ -154,8 +154,11 @@ func TestOpencodeMessagePartDeltaText(t *testing.T) { }{ {"text part", map[string]any{"partID": "prt_text", "field": "text", "delta": "OK"}, "OK"}, {"reasoning part ignored", map[string]any{"partID": "prt_reasoning", "field": "text", "delta": "thinking"}, ""}, - {"unknown part ignored", map[string]any{"partID": "prt_missing", "field": "text", "delta": "lost"}, ""}, + // Unknown part: message.part.updated may have been missed on a new SSE + // connection; allow the delta rather than silently dropping it. + {"unknown part allowed when field=text", map[string]any{"partID": "prt_missing", "field": "text", "delta": "allowed"}, "allowed"}, {"non text field ignored", map[string]any{"partID": "prt_text", "field": "metadata", "delta": "lost"}, ""}, + {"empty partID field=text allowed", map[string]any{"partID": "", "field": "text", "delta": "bare"}, "bare"}, } for _, tc := range cases { if got := opencodeMessagePartDeltaText(tc.props, partTypes); got != tc.want { diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index 6f2dd57..265677a 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -119,13 +119,8 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, return runtime.ErrRunCancelled case out, ok := <-sess.output: if !ok { - // Process exited — remove the dead session. - c.mu.Lock() - if s, found := c.sessions[sess.key]; found && s == sess { - delete(c.sessions, sess.key) - } - c.mu.Unlock() - return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly") + err := drainPersistentDone(sess) + return c.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err) } if delta := outputFilter.Filter(out.text); delta != "" { outputTokens += len(strings.Fields(delta)) @@ -203,16 +198,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, Timestamp: time.Now(), }) case err := <-sess.done: - c.mu.Lock() - if s, found := c.sessions[sess.key]; found && s == sess { - delete(c.sessions, sess.key) - } - c.mu.Unlock() - msg := "persistent session process exited" - if err != nil { - msg = fmt.Sprintf("persistent session process exited: %v", err) - } - return emitRuntimeError(ctx, sink, spec.RunID, msg) + return c.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err) } } } @@ -309,6 +295,14 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr outputCh := make(chan cliOutput, 1024) doneCh := make(chan error, 1) + sess := &profileSession{ + key: key, + name: key.target, + profile: profile, + output: outputCh, + done: doneCh, + } + var input io.Writer var closeFn func() error var cmd *exec.Cmd @@ -327,12 +321,18 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr } input = ptmx closeFn = ptmx.Close + sess.cmd = cmd + sess.input = ptmx + sess.closeFn = ptmx.Close + go func() { buf := make([]byte, 4096) for { n, err := ptmx.Read(buf) if n > 0 { - outputCh <- cliOutput{text: string(buf[:n])} + text := string(buf[:n]) + sess.appendTail(text) + outputCh <- cliOutput{text: text} } if err != nil { doneCh <- cmd.Wait() @@ -361,11 +361,17 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr } input = stdin closeFn = stdin.Close + sess.cmd = cmd + sess.input = stdin + sess.closeFn = stdin.Close + go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() - outputCh <- cliOutput{text: line + "\n", markerLine: line} + text := line + "\n" + sess.appendTail(text) + outputCh <- cliOutput{text: text, markerLine: line} } doneCh <- cmd.Wait() close(outputCh) @@ -382,29 +388,28 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr if cmd.Process != nil { _ = cmd.Process.Kill() } + tail := sess.getTail() if err == nil { + if tail != "" { + return nil, fmt.Errorf("process exited during startup: %s", tail) + } return nil, fmt.Errorf("process exited during startup") } + if tail != "" { + return nil, fmt.Errorf("process exited during startup: %w (recent output: %q)", err, tail) + } return nil, fmt.Errorf("process exited during startup: %w", err) default: } - return &profileSession{ - key: key, - name: key.target, - profile: profile, - cmd: cmd, - input: input, - output: outputCh, - done: doneCh, - closeFn: closeFn, - }, nil + return sess, nil } func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) { timer := time.NewTimer(timeout) defer timer.Stop() var startupBuf strings.Builder + acceptedClaudeWorkspaceTrust := false acceptedClaudeBypassWarning := false for { select { @@ -412,9 +417,19 @@ func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Dur if !ok { return } - if profile.Terminal && !acceptedClaudeBypassWarning && out.text != "" { + if profile.Terminal && out.text != "" { appendBounded(&startupBuf, out.text, 8192) - if shouldAcceptClaudeBypassWarning(startupBuf.String()) { + rawStr := startupBuf.String() + + if !acceptedClaudeWorkspaceTrust && shouldAcceptClaudeWorkspaceTrust(rawStr) { + if _, err := io.WriteString(input, "\r"); err != nil { + logger.Warn("cli adapter: accept claude workspace trust", zap.String("target", key.target), zap.Error(err)) + } else { + acceptedClaudeWorkspaceTrust = true + logger.Info("cli adapter: accepted claude workspace trust", zap.String("target", key.target)) + startupBuf.Reset() + } + } else if !acceptedClaudeBypassWarning && shouldAcceptClaudeBypassWarning(rawStr) { if _, err := io.WriteString(input, "\x1b[B\r"); err != nil { logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err)) } else { @@ -447,6 +462,12 @@ func appendBounded(buf *strings.Builder, s string, max int) { buf.WriteString(raw[len(raw)-max:]) } +func shouldAcceptClaudeWorkspaceTrust(raw string) bool { + compact := compactTerminalText(raw) + return strings.Contains(compact, "quicksafetycheckisthisaproject") && + strings.Contains(compact, "yesitrustthisfolder") +} + func shouldAcceptClaudeBypassWarning(raw string) bool { compact := compactTerminalText(raw) return strings.Contains(compact, "claudecoderunninginbypasspermissionsmode") && @@ -496,3 +517,35 @@ func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, log } } } + +func drainPersistentDone(sess *profileSession) error { + select { + case err := <-sess.done: + return err + default: + return nil + } +} + +func (c *CLI) emitPersistentExit(ctx context.Context, sink runtime.EventSink, runID, targetName string, profile config.CLIProfileConf, sess *profileSession, err error) error { + c.mu.Lock() + if s, found := c.sessions[sess.key]; found && s == sess { + delete(c.sessions, sess.key) + } + c.mu.Unlock() + + cmdSummary := fmt.Sprintf("%s %s", profile.Command, strings.Join(profile.Args, " ")) + tail := sess.getTail() + + var msg string + if err != nil { + msg = fmt.Sprintf("persistent session process exited: %s, target=%s, session=%s, command=%q", err.Error(), targetName, sess.key.sessionID, cmdSummary) + } else { + msg = fmt.Sprintf("persistent session process exited unexpectedly: target=%s, session=%s, command=%q", targetName, sess.key.sessionID, cmdSummary) + } + + if tail != "" { + msg = fmt.Sprintf("%s, recent output: %q", msg, tail) + } + return emitRuntimeError(ctx, sink, runID, msg) +} diff --git a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go index 321823f..c0a0ae0 100644 --- a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go +++ b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go @@ -97,16 +97,33 @@ func TestCLIExecutePersistentProcessExitReturnsError(t *testing.T) { t.Fatal("expected non-nil error when persistent process exits") } + errStr := err.Error() + t.Logf("Got error: %q", errStr) + if !strings.Contains(errStr, "before-exit") { + t.Errorf("expected error to contain recent output 'before-exit', got %q", errStr) + } + if !strings.Contains(errStr, "exit status 2") { + t.Errorf("expected error to contain exit status 'exit status 2', got %q", errStr) + } + if !strings.Contains(errStr, "target=exit-on-input") { + t.Errorf("expected error to contain target=exit-on-input, got %q", errStr) + } + events := sink.Events() + var errorEvent noderuntime.RuntimeEvent var hasError bool for _, e := range events { if e.Type == noderuntime.EventTypeError { hasError = true + errorEvent = e } } if !hasError { t.Fatal("expected error event in emitted events") } + if !strings.Contains(errorEvent.Error, "before-exit") { + t.Errorf("expected error event to contain recent output, got %q", errorEvent.Error) + } if len(events) > 0 && events[len(events)-1].Type == noderuntime.EventTypeComplete { t.Fatal("last event should not be complete when process exits unexpectedly") } @@ -263,12 +280,60 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) { } } +func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning(t *testing.T) { + testutil.RequirePTYSupport(t) + if _, err := osexec.LookPath("stty"); err != nil { + t.Skip("stty required") + } + + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "claude-trust-warning": { + Command: os.Args[0], + Args: []string{"-test.run=TestRawTUIHelperProcess", "--"}, + Env: []string{"IOP_CLAUDE_TRUST_WARNING_HELPER=1"}, + Persistent: true, + Terminal: true, + ResponseIdleTimeoutMS: 100, + StartupIdleTimeoutMS: 50, + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + + ctx := context.Background() + if err := c.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + defer func() { _ = c.Stop(ctx) }() + + execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + + sink := &testutil.FakeSink{} + err := c.Execute(execCtx, noderuntime.ExecutionSpec{ + RunID: "run-claude-trust-warning", + Target: "claude-trust-warning", + Input: map[string]any{"prompt": "hello"}, + }, sink) + if err != nil { + t.Fatalf("execute: %v", err) + } + + if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") { + t.Fatalf("expected reply:hello in deltas, got %q", combined) + } +} + func TestRawTUIHelperProcess(t *testing.T) { switch { case os.Getenv("IOP_RAW_TUI_HELPER") == "1": runRawTUIHelper() case os.Getenv("IOP_CLAUDE_WARNING_HELPER") == "1": runClaudeWarningHelper() + case os.Getenv("IOP_CLAUDE_TRUST_WARNING_HELPER") == "1": + runClaudeTrustAndWarningHelper() default: return } @@ -319,6 +384,48 @@ func runClaudeWarningHelper() { readPromptAndReply() } +func runClaudeTrustAndWarningHelper() { + setRawTerminal() + fmt.Fprint(os.Stdout, "Quick safety check: Is this a project you created or one you trust?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n") + _ = os.Stdout.Sync() + + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if n > 0 && buf[0] == '\r' { + break + } + if err != nil { + os.Exit(0) + } + } + + fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n") + _ = os.Stdout.Sync() + + want := []byte{'\x1b', '[', 'B', '\r'} + seen := make([]byte, 0, len(want)) + for { + n, err := os.Stdin.Read(buf) + if n > 0 { + seen = append(seen, buf[0]) + if len(seen) > len(want) { + seen = seen[len(seen)-len(want):] + } + if string(seen) == string(want) { + fmt.Fprint(os.Stdout, "ready\r\n") + _ = os.Stdout.Sync() + break + } + } + if err != nil { + os.Exit(0) + } + } + + readPromptAndReply() +} + func readPromptAndReply() { var prompt []byte buf := make([]byte, 1)