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
1 change: 1 addition & 0 deletions crates/protocol/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ pub enum FirstRunGateKind {
ClaudeSignIn,
CodexDirectoryTrust,
CodexSignIn,
CopilotFolderTrust,
}

/// Why a settled run resumed to `working`. The two causes map exactly
Expand Down
107 changes: 98 additions & 9 deletions crates/runtime/src/adapter/event_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,23 +449,51 @@ impl DomainAdapterEventSink {
/// every word here is a fixed template plus a closed-enum-derived phrase,
/// never vendor or captured content, so it may cross the redaction
/// boundary without going through [`Redactor`]. Wording is pinned by
/// `first_run_gate_question_names_the_vendor_and_the_gate` below --
/// `first_run_gate_detected_journals_the_kind_and_raises_a_populated_escalation`
/// and `a_copilot_folder_trust_gate_names_the_one_time_step` below --
/// changing it is a deliberate edit, not a refactor side effect.
fn first_run_gate_question(kind: crew_protocol::FirstRunGateKind) -> crew_protocol::Redacted {
use crew_protocol::FirstRunGateKind::{
ClaudeSignIn, ClaudeThemePicker, ClaudeWorkspaceTrust, CodexDirectoryTrust, CodexSignIn,
CopilotFolderTrust,
};
let (vendor, phrase) = match kind {
ClaudeWorkspaceTrust => ("Claude", "workspace-trust prompt"),
ClaudeThemePicker => ("Claude", "theme-selection prompt"),
ClaudeSignIn => ("Claude", "sign-in prompt"),
CodexDirectoryTrust => ("Codex", "directory-trust prompt"),
CodexSignIn => ("Codex", "sign-in prompt"),
// `config_file` names the vendor's own trust-config file for a gate
// that has a one-time pre-empting step: trusting the repository once
// with the vendor in its own session, or adding an entry to that
// file (the exact entry per vendor is `docs/compatibility.md`'s "TUI
// First-Run Gate Detection" table -- naming the file here, not the
// JSON/TOML syntax, points a human somewhere real without the
// message drifting if that syntax ever changes). `None` for a gate
// with no such equivalent: signing in or choosing a theme still
// needs an interactive answer regardless of any config file, so
// there is nothing to point at beyond the pane.
let (vendor, phrase, config_file) = match kind {
ClaudeWorkspaceTrust => ("Claude", "workspace-trust prompt", Some("~/.claude.json")),
ClaudeThemePicker => ("Claude", "theme-selection prompt", None),
ClaudeSignIn => ("Claude", "sign-in prompt", None),
CodexDirectoryTrust => (
"Codex",
"directory-trust prompt",
Some("~/.codex/config.toml"),
),
CodexSignIn => ("Codex", "sign-in prompt", None),
CopilotFolderTrust => (
"Copilot",
"folder-trust prompt",
Some("~/.copilot/config.json"),
),
};
let one_time_sentence = match config_file {
Some(file) => format!(
" To avoid this next time, trust the repository once with {vendor} in its own \
session, or add its documented entry to {file}."
),
None => String::new(),
};
crew_protocol::Redacted::assert_runtime_authored(format!(
"The {vendor} CLI is waiting on its first-run {phrase} and cannot proceed until it is \
answered. Answer it in the worker's pane, or cancel the run. Crew will not answer it \
for you."
answered. Answer it in the worker's pane, or cancel the run.{one_time_sentence} Crew \
will not answer it for you."
))
}

Expand Down Expand Up @@ -2327,6 +2355,67 @@ mod first_run_gate_tests {
question.contains("Codex") && question.contains("sign-in prompt"),
"unexpected question for CodexSignIn: {question}"
);
assert!(
!question.contains("avoid this next time"),
"a sign-in gate has no config entry that pre-empts it -- the one-time-step \
sentence must not appear: {question}"
);
}
other => panic!("expected EscalationRaised, got {other:?}"),
}

db.shutdown().await.expect("shutdown database");
}

/// `CopilotFolderTrust` names copilot's own dialog phrase, and -- unlike
/// `CodexSignIn` above -- is a trust gate, so its question also carries
/// the one-time step that pre-empts it on future runs: trusting the
/// repository once with copilot in its own session, or adding its
/// documented config entry. This is the wording new to this slice;
/// `first_run_gate_detected_journals_the_kind_and_raises_a_populated_escalation`
/// above still pins the original template's closing sentence, unchanged.
#[tokio::test]
async fn a_copilot_folder_trust_gate_names_the_one_time_step() {
let (_dir, db) = open_db().await;
let project_id = ProjectId::new();
let (task_id, worker_id, run_id) = seed_working_run(&db, project_id).await;
let (events_tx, mut events_rx) = broadcast::channel(16);
let sink = sink(Arc::clone(&db), project_id, events_tx);

sink.emit(AdapterEvent {
run_id,
task_id,
worker_id,
payload: AdapterEventPayload::FirstRunGateDetected {
kind: FirstRunGateKind::CopilotFolderTrust,
},
cursor: None,
})
.await
.expect("emit");

let _ = events_rx.try_recv().expect("FirstRunGateDetected");
let escalation = events_rx.try_recv().expect("EscalationRaised");
match &escalation.event {
RuntimeEvent::EscalationRaised { question, .. } => {
let question = question.as_ref().expect("question").as_str();
assert!(
question.contains("Copilot") && question.contains("folder-trust prompt"),
"unexpected question for CopilotFolderTrust: {question}"
);
assert!(
question.contains(
"trust the repository once with Copilot in its own session, or add its \
documented entry to ~/.copilot/config.json"
),
"a trust gate's question must name the one-time step and the actual file: \
{question}"
);
assert!(
question.contains("Crew will not answer it for you."),
"the one-time step is an addition, not a replacement for the closing \
sentence: {question}"
);
}
other => panic!("expected EscalationRaised, got {other:?}"),
}
Expand Down
131 changes: 125 additions & 6 deletions crates/runtime/src/adapter/tui/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2049,6 +2049,7 @@ fn protocol_gate_kind(gate: GateKind) -> crew_protocol::FirstRunGateKind {
GateKind::ClaudeSignIn => crew_protocol::FirstRunGateKind::ClaudeSignIn,
GateKind::CodexDirectoryTrust => crew_protocol::FirstRunGateKind::CodexDirectoryTrust,
GateKind::CodexSignIn => crew_protocol::FirstRunGateKind::CodexSignIn,
GateKind::CopilotFolderTrust => crew_protocol::FirstRunGateKind::CopilotFolderTrust,
}
}

Expand Down Expand Up @@ -3308,19 +3309,24 @@ mod tests {
}

/// A vendor with no real predicate yet (`classify_surface` returns
/// `None`) must keep the pre-classification behavior: this slice
/// cannot recognize a gate for copilot/omp, so it must not withhold
/// their Enter on the strength of a classification it never made.
/// `None`) must keep the pre-classification behavior: it must not
/// withhold Enter on the strength of a classification it never made.
/// Copilot had this exact shape until this slice gave it a real
/// predicate (see `wait_for_readiness_escalates_and_then_fails_when_
/// the_process_exits_under_a_replayed_copilot_gate_capture`, which
/// proves the opposite for copilot now); omp is the vendor still in
/// this state -- its own predicate is the second half of this slice,
/// not yet built.
#[test]
fn enter_precondition_proceeds_unconditionally_for_a_vendor_with_no_predicate() {
use crate::adapter::tui::CopilotTuiVendor;
use crate::adapter::tui::OmpTuiVendor;

let copilot = CopilotTuiVendor::new(PathBuf::from("/w"), vec![]);
let omp = OmpTuiVendor::new(PathBuf::from("/w"), vec![]);
// Content is irrelevant here -- even a screen showing a claude
// gate must not affect a vendor that has no predicate to read it
// with.
assert_eq!(
enter_precondition(&copilot, &grid_from("claude-workspace-trust.raw")),
enter_precondition(&omp, &grid_from("claude-workspace-trust.raw")),
EnterPrecondition::Proceed
);
}
Expand Down Expand Up @@ -3450,6 +3456,119 @@ mod tests {
}
}

/// Slice 5's own instance of the fixture-driven proof above, extended
/// to copilot's gate: the same replay-and-force-exit shape, against
/// `copilot-folder-trust.raw` (a REAL capture of copilot's dialog, not
/// a synthetic double) and `CopilotTuiVendor`. Proves the wiring this
/// slice adds -- `CopilotTuiVendor::classify_surface` ->
/// `classify_copilot_surface` -> the park-and-escalate path slice 4
/// built -- end to end, not just that `classify_copilot_surface`
/// itself returns the right `Surface` (already covered in
/// `classify.rs`'s own tests).
#[tokio::test]
async fn wait_for_readiness_escalates_and_then_fails_when_the_process_exits_under_a_replayed_copilot_gate_capture()
{
use crate::adapter::tui::CopilotTuiVendor;

let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/adapters/tui-screens/copilot-folder-trust.raw");
let pty = Arc::new(
PtyProcess::spawn(
&crate::supervisor::SpawnSpec {
program: PathBuf::from("/bin/sh"),
args: vec![
"-c".to_string(),
format!("cat '{}' && sleep 30", fixture_path.display()),
],
..crate::supervisor::SpawnSpec::minimal()
},
EscalationTimings::default(),
)
.expect("spawn the replay double"),
);

let mut readiness_rx = pty.subscribe_output();
let grid = Arc::new(StdMutex::new(TerminalGrid::new()));
{
let mut grid_rx = pty.subscribe_output();
let grid = Arc::clone(&grid);
tokio::spawn(async move {
loop {
match grid_rx.recv().await {
Ok(bytes) => grid
.lock()
.expect("terminal-grid mutex never poisoned")
.push(&bytes),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
});
}

let vendor = CopilotTuiVendor::new(PathBuf::from("/w"), vec![]);
let sink = GateRecordingSink::new();
let dyn_sink: Arc<dyn AdapterEventSink> = sink.clone();
let cancel_token = CancellationToken::new();
let run_id = RunId::new();
let task_id = TaskId::new();
let worker_id = WorkerId::new();

let pty_for_wait = Arc::clone(&pty);
let wait = tokio::spawn(async move {
wait_for_readiness(
&mut readiness_rx,
"copilot",
&vendor,
&grid,
Duration::from_millis(50),
Duration::from_secs(30),
&pty_for_wait,
Some(PromptInjection {
text: "this must never be written",
write_timeout: Duration::from_secs(1),
}),
tokio::time::Instant::now(),
run_id,
task_id,
worker_id,
&dyn_sink,
&cancel_token,
)
.await
});

assert!(
wait_until(|| !sink.payloads().is_empty(), Duration::from_secs(5)).await,
"a replayed copilot gate capture must escalate, not silently fail closed or hang"
);
match &sink.payloads()[..] {
[AdapterEventPayload::FirstRunGateDetected { kind }] => {
assert_eq!(*kind, crew_protocol::FirstRunGateKind::CopilotFolderTrust);
}
other => panic!("expected exactly one FirstRunGateDetected, got {other:?}"),
}

let _ = pty.terminate().await;
let result = tokio::time::timeout(Duration::from_secs(5), wait)
.await
.expect("must return promptly once the process exits")
.expect("wait_for_readiness task must not panic");

match result {
Err(err) => {
let message = err.to_string();
assert!(
message.contains("CopilotFolderTrust"),
"the error must name the gate it saw: {message}"
);
}
Ok(()) => {
panic!("a replayed gate capture must never be classified as ready to paste into")
}
}
}

/// A truly unrecognized surface must fail closed once `cap` elapses --
/// not loop forever. This is the regression case for a real bug the
/// test above forced out during development: the `Undecided` poll
Expand Down
Loading
Loading