Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1416,8 +1416,10 @@ The separate Debug path starts with a user-owned terminal. Desktop can request a
15-second-to-15-minute process-local input lease, but the bearer never crosses the
Go boundary. Only a root Code/Deliver Supervisor with the current Debug permission
and installed runtime adapter sees `debug_terminal`. Schema v113 admits that tool to
the durable Supervisor call ledger with a data-preserving table migration. Each write rechecks all durable
bindings and Shell Policy; each read is cursor-addressed, bounded, and clamped to the
the durable Supervisor call ledger with a data-preserving table migration. Each input rechecks all durable
bindings and host Policy; command writes also pass Shell Policy. The fixed `interrupt`
action sends only Ctrl+C through the same lease, audit, deduplication and native authorization.
Each read is cursor-addressed, bounded, and clamped to the
output watermark captured when the lease was granted. Canonical model commands and
sanitized, explicitly untrusted post-grant results are durable Supervisor evidence, while user input,
raw PTY bytes, process environment, root paths, and bearer tokens stay in memory. A
Expand Down
2 changes: 1 addition & 1 deletion docs/technical-reference.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Traverse Board invokes real Git and operating-system shells; it is not a command

`command_runtime` does not share a session or ownership with the persistent user terminal. Its installed host and sandbox adapters share the same Job protocol. Schema v116 writes an immutable launch intent under the current Supervisor generation lease, then keeps a background Job alive under a separate expiring process-owner heartbeat. A later turn can continue reading or writing stdin, while a second process cannot adopt the Job from SQLite. On crash, a creation-time Windows Job Object or POSIX guardian/process group reaps the owned process tree; restart only converges an expired owner record to `interrupted` and never re-executes a persisted PID. Deliberate POSIX daemonization into a new session outside the inherited process group remains an unsandboxed host-execution residual risk. stdout/stderr retain channel and time under a monotonic cursor, and bounded overflow becomes a SHA-256-addressed Artifact. Model-facing bytes are UTF-8 repaired, secret-redacted, and stripped of ANSI/C1/Unicode controls.

Every `debug_terminal` write still passes Shell Policy; commands that require separate per-command approval cannot bypass it through a Debug lease. Grant establishes an output watermark, so the model cannot read terminal scrollback from before the lease. For resumability, the canonical model command and sanitized bounded results after that watermark enter the Supervisor tool transcript; schema v113 admits this tool to the same durable call ledger without losing existing calls. A process-local Workspace-root digest and exact mode revision prevent an old lease from reviving after root or phase drift. User keystrokes, raw PTY bytes, the root path, and the lease bearer are not persisted. Restart terminates the session and invalidates every lease. These host-shell paths are not exposed on the Cyber Surface. See the [Usage Guide](usage.md), [ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md), and [ADR 0117](adr/0117-run-owned-command-runtime.md).
Every `debug_terminal` command write still passes Shell Policy; commands that require separate per-command approval cannot bypass it through a Debug lease. The fixed `interrupt` action sends only Ctrl+C under the same lease, host Policy, audit, operation deduplication and native authorization. Grant establishes an output watermark, so the model cannot read terminal scrollback from before the lease. For resumability, the canonical model call and sanitized bounded results after that watermark enter the Supervisor tool transcript; schema v113 admits this tool to the same durable call ledger without losing existing calls. A process-local Workspace-root digest and exact mode revision prevent an old lease from reviving after root or phase drift. User keystrokes, raw PTY bytes, the root path, and the lease bearer are not persisted. Restart terminates the session and invalidates every lease. These host-shell paths are not exposed on the Cyber Surface. See the [Usage Guide](usage.md), [ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md), and [ADR 0117](adr/0117-run-owned-command-runtime.md).

### Security boundaries

Expand Down
2 changes: 1 addition & 1 deletion docs/technical-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Desktop、认证 OpenAPI 与只读/导出 CLI 共用同一份不可变 Attempt

`command_runtime` 与用户持久终端不共享 session 或所有权。Schema v116 先以当前 Supervisor generation lease 写入不可变启动意图;schema v131 再把 exact adapter kind/backend/identity/generation/isolation/network/credential policy 固定到广告、调用、Job 与回执。独立、可过期的进程所有者心跳维持后台 Job,另一进程不能凭数据库记录收养它;旧 v116 行只读投影为不可执行的 `legacy_unbound`。宿主、Windows Local 与固定 Docker adapter 都复用同一套有界 stdin 生命周期;Local 把 manager-owned pipe 复制到 AppContainer 的唯一 stdin handle,Docker 则以 runner v2 + schema v132 生命周期 WAL 把进程内输入绑定到精确 owned container。输入字节不持久化,重启也不会收养旧管道。崩溃时 backend 必须回收 owned 进程树,重启只把所有者已过期的记录收敛为 `interrupted`,绝不按持久 PID 重新执行。stdout/stderr 以单调 cursor 保留通道与时间,内联窗口溢出后仍可生成有 SHA-256 的有界 Artifact;所有返回模型的内容统一去除 ANSI/C1/Unicode 控制序列、修复 UTF-8 并脱敏。

`debug_terminal` 每次写入仍经过 Shell Policy;需要另行逐条审批的命令不会借 Debug 租约绕过审批。授权瞬间会固定输出水位,模型不能读取租约授予前的终端滚动内容。为支持 Run 恢复,模型提交的规范化命令和水位之后脱敏、有界的结果会进入 Supervisor 工具记录;schema v113 让该工具进入同一持久调用账本并保留既有记录。进程内 Workspace 根目录摘要和 mode revision 会阻止目录或阶段漂移后旧租约复活;用户键盘输入、原始 PTY 字节、根目录路径和租约 bearer 均不持久化。应用重启会终止会话并使租约失效。Cyber Surface 不公开这些宿主 Shell 路径。完整边界见[使用手册](usage.md)、[ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md)和 [ADR 0117](adr/0117-run-owned-command-runtime.md)。
`debug_terminal` 每次命令写入仍经过 Shell Policy;需要另行逐条审批的命令不会借 Debug 租约绕过审批。固定的 `interrupt` 动作只发送 Ctrl+C,沿用相同租约、宿主 Policy、审计、操作去重和原生授权。授权瞬间会固定输出水位,模型不能读取租约授予前的终端滚动内容。为支持 Run 恢复,模型提交的规范化调用和水位之后脱敏、有界的结果会进入 Supervisor 工具记录;schema v113 让该工具进入同一持久调用账本并保留既有记录。进程内 Workspace 根目录摘要和 mode revision 会阻止目录或阶段漂移后旧租约复活;用户键盘输入、原始 PTY 字节、根目录路径和租约 bearer 均不持久化。应用重启会终止会话并使租约失效。Cyber Surface 不公开这些宿主 Shell 路径。完整边界见[使用手册](usage.md)、[ADR 0114](adr/0114-real-shell-transports-and-supervised-debug-terminal.md)和 [ADR 0117](adr/0117-run-owned-command-runtime.md)。

### 安全边界

Expand Down
4 changes: 3 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2132,7 +2132,9 @@ The persistent Debug terminal is user-owned by default and default-off at proces
--enable-user-terminal
```

Windows uses PowerShell in a ConPTY assigned to a creation-time kill-on-close Job Object. macOS uses Bash with `--noprofile --norc` in a PTY and an owned process group. Ordinary POSIX background jobs share that group, but a command that deliberately creates a new session/daemon can escape group cleanup; The Debug interaction is a host terminal, not a permission tier or containment boundary. Each Run may have one active process-local terminal and one active Agent-input binding; the binding lasts 15 seconds to 15 minutes, is capped and immediately revocable, and is invalid after restart. The model tool is advertised only to the root Supervisor in Code/Deliver with modern Full, current process activation, the Debug interaction and a live terminal-input lease. It supports one complete policy-checked command line or one bounded cursor read; commands that Policy classifies as denied or requiring separate per-command approval do not reach the PTY. Grant records the current output watermark, and every later read is clamped to it, so pre-grant user scrollback cannot enter model context. Output pages are at most 64 KiB, explicitly marked untrusted, stripped of terminal controls, repaired to UTF-8, and secret-redacted.
Windows uses PowerShell in a ConPTY assigned to a creation-time kill-on-close Job Object. macOS uses Bash with `--noprofile --norc` in a PTY and an owned process group. Ordinary POSIX background jobs share that group, but a command that deliberately creates a new session/daemon can escape group cleanup; The Debug interaction is a host terminal, not a permission tier or containment boundary. Each Run may have one active process-local terminal and one active Agent-input binding; the binding lasts 15 seconds to 15 minutes, is capped and immediately revocable, and is invalid after restart. The model tool is advertised only to the root Supervisor in Code/Deliver with modern Full, current process activation, the Debug interaction and a live terminal-input lease. It supports one complete policy-checked command line, one bounded cursor read, or `action: "interrupt"` to send Ctrl+C to the foreground program. Commands that Policy classifies as denied or requiring separate per-command approval do not reach the PTY. Grant records the current output watermark, and every later read is clamped to it, so pre-grant user scrollback cannot enter model context. Output pages are at most 64 KiB, explicitly marked untrusted, stripped of terminal controls, repaired to UTF-8, and secret-redacted.

Interrupt accepts no command or cursor and sends exactly one `0x03` byte without a newline. It uses the same live input lease, Run/Workspace binding, host Policy checks, operation-key deduplication, audit, and native authorization as command input. It leaves the persistent shell available when the foreground program handles Ctrl+C; a successful tool result confirms the byte was submitted, so read subsequent output to verify the program stopped. Ambiguous writes are never automatically retried.

The canonical model command and sanitized bounded post-grant result are stored in the resumable Supervisor tool transcript. Schema v113 transactionally widens that durable call ledger for `debug_terminal` while preserving earlier calls. The process-local binding also pins the exact mode revision and a canonical Workspace-root digest; a Plan round trip or Workspace re-registration invalidates it instead of reviving authority. The bearer, user keystrokes, raw PTY stream, pre-grant scrollback, root path, environment, and process identity are not persisted. Schema v108 defines a `terminal_sessions` metadata ledger contract, but the current Desktop lifecycle remains process-local and does not use that table to restore a process or authority; no database row can revive a terminal or lease. Agent-input grant/write/revoke events store only bounded identities, sizes, and digests.

Expand Down
20 changes: 18 additions & 2 deletions internal/application/debug_terminal_agent_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ type WriteDebugTerminalAgentInputRequest struct {
BindingID string
OperationKey string
Data []byte
// Interrupt selects a fixed Ctrl+C frame and requires Data to be empty.
Interrupt bool
}

type ReadDebugTerminalAgentOutputRequest struct {
Expand Down Expand Up @@ -433,9 +435,15 @@ func (s *DebugTerminalAgentInputService) Write(
if err := s.revalidateBinding(ctx, entry); err != nil {
return DebugTerminalAgentInputWriteResult{}, err
}
policyDecision := s.checker.CheckToolCall(tools.Call{
policyCall := tools.Call{
Name: "shell", Args: map[string]string{"command": command},
})
}
if normalized.Interrupt {
policyCall = tools.Call{
Name: "debug_terminal", Args: map[string]string{"action": "interrupt"},
}
}
policyDecision := s.checker.CheckToolCall(policyCall)
if !policyDecision.Allowed || policyDecision.NeedsApproval {
return DebugTerminalAgentInputWriteResult{}, apperror.New(
apperror.CodePolicyDenied, policyDecision.Reason)
Expand Down Expand Up @@ -939,6 +947,14 @@ func normalizeDebugTerminalWrite(
}
}
request.OperationKey = operationKey
if request.Interrupt {
if len(request.Data) != 0 {
return WriteDebugTerminalAgentInputRequest{}, "", errors.New(
"debug terminal interrupt cannot include input data")
}
request.Data = []byte{0x03}
return request, "", nil
}
if len(request.Data) == 0 ||
len(request.Data) > terminalruntime.MaxTerminalInputBytes ||
!utf8.Valid(request.Data) {
Expand Down
17 changes: 15 additions & 2 deletions internal/application/debug_terminal_agent_input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,12 @@ func TestDebugTerminalAgentInputRevokesOnWorkspaceRootDrift(t *testing.T) {

func newDebugTerminalAgentFixture(t *testing.T,
failWrite bool,
) debugTerminalAgentFixture {
return newDebugTerminalAgentFixtureWithManager(t, failWrite, false)
}

func newDebugTerminalAgentFixtureWithManager(t *testing.T,
failWrite, native bool,
) debugTerminalAgentFixture {
t.Helper()
ctx := context.Background()
Expand Down Expand Up @@ -715,7 +721,12 @@ func newDebugTerminalAgentFixture(t *testing.T,
}
broker := executionauth.NewTerminalInputBroker()
backend := &debugTerminalBackendStub{}
manager, err := terminalruntime.NewManager(backend, broker)
var manager *terminalruntime.Manager
if native {
manager, err = terminalruntime.NewPlatformManager(broker)
} else {
manager, err = terminalruntime.NewManager(backend, broker)
}
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -763,7 +774,9 @@ func newDebugTerminalAgentFixture(t *testing.T,
}); err != nil {
t.Fatal(err)
}
backend.process.failWrite = failWrite
if backend.process != nil {
backend.process.failWrite = failWrite
}
bridge, err := terminalruntime.NewAgentInputBridge(manager, broker)
if err != nil {
t.Fatal(err)
Expand Down
139 changes: 139 additions & 0 deletions internal/application/debug_terminal_interrupt_native_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package application

import (
"fmt"
"os"
"os/signal"
"runtime"
"strings"
"testing"
"time"

"cyberagent-workbench/internal/outputsafe"
terminalruntime "cyberagent-workbench/internal/terminal"
"cyberagent-workbench/internal/toolgateway"
)

func TestDebugTerminalInterruptForegroundHelper(t *testing.T) {
if os.Args[len(os.Args)-1] != "uc-terminal-interrupt-helper" {
t.Skip("foreground subprocess helper")
}
interrupts := make(chan os.Signal, 1)
signal.Notify(interrupts, os.Interrupt)
defer signal.Stop(interrupts)
fmt.Println("UC_INTERRUPT_READY")
select {
case <-interrupts:
fmt.Println("UC_INTERRUPT_RECEIVED")
case <-time.After(30 * time.Second):
t.Fatal("foreground subprocess received no interrupt")
}
}

func TestDebugTerminalInterruptNativeForegroundPreservesShell(t *testing.T) {
switch runtime.GOOS {
case "windows":
if os.Getenv("CYBERAGENT_TEST_WINDOWS_CONPTY") != "1" {
t.Skip("set CYBERAGENT_TEST_WINDOWS_CONPTY=1 to run the real ConPTY interrupt test")
}
case "darwin", "linux":
default:
t.Skip("native terminal is unavailable on this platform")
}
f := newDebugTerminalAgentFixtureWithManager(t, false, true)
binding := grantDebugTerminalInterruptFixture(t, f)
executor, err := NewDebugTerminalToolExecutor(f.service)
if err != nil {
t.Fatal(err)
}
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
quotedExecutable := "'" + strings.ReplaceAll(executable, "'", "'\"'\"'") + "'"
command := "PS1='UC_INTERRUPT_'\"PROMPT> \"; " + quotedExecutable + " '-test.run=^TestDebugTerminalInterruptForegroundHelper$' '--' 'uc-terminal-interrupt-helper'"
shellCheck := "printf '%s%s\\n' 'UC_INTERRUPT_' 'SHELL_USABLE'"
if runtime.GOOS == "windows" {
command = "function prompt { 'UC_INTERRUPT_' + 'PROMPT> ' }; & '" + strings.ReplaceAll(executable, "'", "''") + "' '-test.run=^TestDebugTerminalInterruptForegroundHelper$' '--' 'uc-terminal-interrupt-helper'"
shellCheck = "Write-Output ('UC_INTERRUPT_' + 'SHELL_USABLE')"
}
runInput := func(action, command, operation string) toolgateway.DebugTerminalExecutionResult {
t.Helper()
result, err := executor.ExecuteDebugTerminal(t.Context(), toolgateway.DebugTerminalContext{
RunID: f.run.ID, OperationKey: operation,
}, toolgateway.DebugTerminalInput{
Version: toolgateway.DebugTerminalProtocolVersion, Action: action,
Command: command, MaxBytes: 4096,
})
if err != nil {
t.Fatal(err)
}
return result
}
waitForMarker := func(result toolgateway.DebugTerminalExecutionResult, marker string) {
t.Helper()
output, cursor := string(result.Output), result.NextCursor
deadline := time.Now().Add(10 * time.Second)
for !strings.Contains(output, marker) && time.Now().Before(deadline) {
page, err := f.service.Read(t.Context(), ReadDebugTerminalAgentOutputRequest{
ProtocolVersion: DebugTerminalAgentInputProtocolVersion,
BindingID: binding.ID, Cursor: cursor, MaxBytes: 4096,
})
if err != nil {
t.Fatal(err)
}
output += string(page.Data)
cursor = page.NextCursor
time.Sleep(10 * time.Millisecond)
}
if !strings.Contains(output, marker) {
t.Fatalf("terminal omitted %s: %q", marker, output)
}
}
waitForMarker(runInput(toolgateway.DebugTerminalActionWrite, command, "native-start-foreground"), "UC_INTERRUPT_READY")
interrupted := runInput(toolgateway.DebugTerminalActionInterrupt, "", "native-interrupt-foreground")
if !interrupted.InputSubmitted || interrupted.BytesWritten != 1 || interrupted.TerminalSessionID != f.sessionID {
t.Fatalf("unexpected native interrupt result: %+v", interrupted)
}
waitForMarker(interrupted, "UC_INTERRUPT_RECEIVED")
// Shell prompts have no newline and intentionally stay out of the model's
// line-buffered stream. Wait for the real prompt before sending another command.
var promptOutput []byte
var promptCursor uint64
deadline := time.Now().Add(10 * time.Second)
for !strings.Contains(outputsafe.Sanitize(promptOutput), "UC_INTERRUPT_PROMPT> ") && time.Now().Before(deadline) {
page, err := f.manager.Read(f.sessionID, promptCursor, 4096)
if err != nil {
t.Fatal(err)
}
promptOutput = append(promptOutput, page.Data...)
promptCursor = page.NextCursor
time.Sleep(10 * time.Millisecond)
}
if !strings.Contains(outputsafe.Sanitize(promptOutput), "UC_INTERRUPT_PROMPT> ") {
t.Fatalf("shell prompt did not return after interrupt: %q", outputsafe.Sanitize(promptOutput))
}
// Both pieces are separate in the command, so terminal echo cannot satisfy this check.
waitForMarker(runInput(toolgateway.DebugTerminalActionWrite, shellCheck, "native-check-shell"), "UC_INTERRUPT_SHELL_USABLE")
current, err := f.manager.Get(f.sessionID)
if err != nil || current.State != terminalruntime.SessionRunning {
t.Fatalf("shell did not survive foreground interrupt: %+v err=%v", current, err)
}
if _, err := f.manager.WriteUser(t.Context(), terminalruntime.UserInputRequest{
SessionID: f.sessionID, Data: []byte("exit\r"), RequestedBy: "test_operator", UserConfirmed: true,
}); err != nil {
t.Fatal(err)
}
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
current, err = f.manager.Get(f.sessionID)
if err != nil {
t.Fatal(err)
}
if current.State == terminalruntime.SessionExited {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("test shell did not exit")
}
Loading
Loading