Compare commits

..
Author SHA1 Message Date
jknapp 292fc907fb Shared login: press Enter separately from the pasted code (#64)
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m45s
Build App / build-windows (push) Successful in 5m35s
Build App / build-linux (push) Successful in 7m33s
Build App / create-tag (push) Successful in 10s
Build App / sync-to-github (push) Successful in 1m13s
2026-09-27 13:29:29 +00:00
jknappandClaude Opus 5.5 0d117e97fe Add Auto permission mode (#63)
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m58s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m4s
Build App / build-windows (push) Successful in 5m48s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 2m27s
Adds Claude Code's auto permission mode as a fifth option between Accept Edits and Bypass, across terminals, resumed sessions, the tab badge, the scheduler task runner and docs. Warns that Auto falls back to prompting when unavailable for the model/backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:02:06 +00:00
13 changed files with 156 additions and 23 deletions
+16 -4
View File
@@ -469,6 +469,7 @@ replaces the old Full Permissions on/off switch.
| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` | | **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` |
| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* | | **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* |
| **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` | | **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` |
| **Auto** | A safety classifier approves routine actions and blocks risky ones, without prompting | `--permission-mode auto` |
| **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` | | **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` |
New projects start in **Default**. Projects created before permission modes existed keep behaving New projects start in **Default**. Projects created before permission modes existed keep behaving
@@ -480,12 +481,19 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
> has Docker socket access or reaches services on your network. The Overview tab tells you whether > has Docker socket access or reaches services on your network. The Overview tab tells you whether
> the in-container sandbox is also on. > the in-container sandbox is also on.
**Auto** sits between Accept Edits and Bypass: Claude Code's own classifier reviews each action,
lets routine work through and blocks things that look risky (such as destructive or
exfiltrating commands) — no prompts either way. Whether it is available depends on your Claude
Code account, model and backend (local and OpenAI-compatible backends usually won't
qualify). When it isn't available, Claude Code quietly starts in its normal prompting mode
instead.
### When a change takes effect ### When a change takes effect
- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open - **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open
from then on. A Claude session that is already running keeps the permissions it started with; from then on. A Claude session that is already running keeps the permissions it started with;
close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode
that terminal was launched with (`plan`, `ask`, `edits`, `bypass`). that terminal was launched with (`plan`, `ask`, `edits`, `auto`, `bypass`).
- **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current - **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current
mode. mode.
- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with - **Scheduled tasks** — these now honour the permission mode too (they previously always ran with
@@ -494,8 +502,10 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
mode change to reach the scheduler. mode change to reach the scheduler.
> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode > Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode
> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run > other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for
> log records which mode it used. > approval. In **Auto**, actions the classifier blocks are denied and the run carries on without
> them — but if Auto isn't available for the project's model or backend, Claude Code falls back
> to prompting and the task can stall the same way. Its run log records which mode it used.
--- ---
@@ -1349,7 +1359,9 @@ Scheduled runs use the project's [permission mode](#permission-modes) — they n
with `--dangerously-skip-permissions`. Because the mode travels into the container as an with `--dangerously-skip-permissions`. Because the mode travels into the container as an
environment variable, **stop and start the project** after changing it for the scheduler to see the environment variable, **stop and start the project** after changing it for the scheduler to see the
change. Remember that a headless run cannot answer a permission prompt, so in any mode other than change. Remember that a headless run cannot answer a permission prompt, so in any mode other than
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode **Auto** or **Bypass** a task may stop early when Claude Code asks for approval (in Auto, blocked
actions are denied instead, unless Auto is unavailable and Claude Code falls back to
prompting); the run log records the mode
that was used. that was used.
### Creating Tasks ### Creating Tasks
+3 -2
View File
@@ -114,7 +114,7 @@ progress modal.
## Permission Modes ## Permission Modes
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states, `PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states,
mapped to CLI flags by `PermissionMode::cli_args()`: mapped to CLI flags by `PermissionMode::cli_args()`:
| Mode | Serialized | CLI args passed to `claude` | | Mode | Serialized | CLI args passed to `claude` |
@@ -122,6 +122,7 @@ mapped to CLI flags by `PermissionMode::cli_args()`:
| **Plan** | `plan` | `--permission-mode plan` | | **Plan** | `plan` | `--permission-mode plan` |
| **Default** | `default` | *(none)* | | **Default** | `default` | *(none)* |
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` | | **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
| **Auto** | `auto` | `--permission-mode auto` |
| **Bypass** | `bypass` | `--dangerously-skip-permissions` | | **Bypass** | `bypass` | `--dangerously-skip-permissions` |
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to `Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
@@ -531,7 +532,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic | | `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls | | `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control | | `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Auto / Bypass segmented control |
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` | | `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` | | `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines | | `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
+3 -2
View File
@@ -186,7 +186,7 @@ host keychain secrets.
### Permission Modes ### Permission Modes
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions` `PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions`
boolean. It reaches Claude Code by two different routes: boolean. It reaches Claude Code by two different routes:
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler | | Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
@@ -194,6 +194,7 @@ boolean. It reaches Claude Code by two different routes:
| `Plan` | `--permission-mode plan` | `plan` | | `Plan` | `--permission-mode plan` | `plan` |
| `Default` | *(no flag)* | `default` | | `Default` | *(no flag)* | `default` |
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` | | `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
| `Auto` | `--permission-mode auto` | `auto` |
| `Bypass` | `--dangerously-skip-permissions` | `bypass` | | `Bypass` | `--dangerously-skip-permissions` | `bypass` |
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves `Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
@@ -474,7 +475,7 @@ triple-c/
│ │ ├── ProjectRow.tsx # Select-only sidebar row │ │ ├── ProjectRow.tsx # Select-only sidebar row
│ │ ├── ProjectList.tsx # Sidebar project list │ │ ├── ProjectList.tsx # Sidebar project list
│ │ ├── AddProjectDialog.tsx # New-project dialog │ │ ├── AddProjectDialog.tsx # New-project dialog
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass │ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation │ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings, │ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
│ │ # ClaudeInstructions, ClaudeCodeSettings — │ │ # ClaudeInstructions, ClaudeCodeSettings —
@@ -106,6 +106,16 @@ const CODE_REJECTED_EVENT: &str = "claude-token-code-rejected";
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task. /// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60); const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Pause between typing the pasted code and pressing Enter.
///
/// The CLI's prompt reads a multi-byte chunk as a paste, and a `\r` inside a
/// paste is swallowed with it rather than submitting. Code and Enter in one
/// write therefore fill the prompt and submit nothing, and the flow waits out
/// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart
/// already submits reliably; this leaves headroom for the extra hops through
/// Docker's exec socket, which can merge writes that arrive close together.
const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250);
/// Documented shape of a `setup-token` credential. /// Documented shape of a `setup-token` credential.
const TOKEN_PREFIX: &str = "sk-ant-oat01-"; const TOKEN_PREFIX: &str = "sk-ant-oat01-";
@@ -837,9 +847,23 @@ unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_B
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
exec claude setup-token"#; exec claude setup-token"#;
/// Type `code` into the CLI's prompt, then press Enter as a separate keystroke.
///
/// See [`SUBMIT_ENTER_DELAY`] for why the two cannot share a write.
async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
input: &mut W,
code: &[u8],
) -> std::io::Result<()> {
input.write_all(code).await?;
input.flush().await?;
tokio::time::sleep(SUBMIT_ENTER_DELAY).await;
input.write_all(b"\r").await?;
input.flush().await
}
/// Run `claude setup-token` in the container and return the token it printed. /// Run `claude setup-token` in the container and return the token it printed.
/// Streams redacted output as it arrives and forwards anything arriving on /// Streams redacted output as it arrives and forwards anything arriving on
/// `input_rx` (the user's pasted code) to the command's stdin. /// `input_rx` (the user's pasted code) to the command's stdin, each followed by Enter.
async fn run_setup_token( async fn run_setup_token(
app: &AppHandle, app: &AppHandle,
project_id: &str, project_id: &str,
@@ -894,14 +918,13 @@ async fn run_setup_token(
"Authentication cancelled. No token was stored.".to_string() "Authentication cancelled. No token was stored.".to_string()
); );
} }
Some(data) = input_rx.recv() => { Some(code) = input_rx.recv() => {
if let Err(e) = input.write_all(&data).await { if let Err(e) = type_code_then_enter(&mut input, &code).await {
return Err(format!( return Err(format!(
"Could not send the code to `claude setup-token`: {}. No token was stored.", "Could not send the code to `claude setup-token`: {}. No token was stored.",
e e
)); ));
} }
let _ = input.flush().await;
// Arm the rejection detector. Anything the CLI says from here // Arm the rejection detector. Anything the CLI says from here
// on is a verdict on *this* code. // on is a verdict on *this* code.
awaiting_code_result = true; awaiting_code_result = true;
@@ -1158,10 +1181,9 @@ pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
.to_string() .to_string()
})?; })?;
let mut keystrokes = code.as_bytes().to_vec(); // Just the code: the flow presses Enter itself, as a separate keystroke.
keystrokes.push(b'\r');
sender sender
.send(keystrokes) .send(code.as_bytes().to_vec())
.map_err(|_| "The authentication flow has already ended.".to_string()) .map_err(|_| "The authentication flow has already ended.".to_string())
} }
@@ -2229,4 +2251,50 @@ mod tests {
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome); assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome); assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
} }
/// Records every `poll_write` as a separate entry, with when it landed, so
/// a test can see write boundaries that a byte pipe would merge.
#[derive(Default)]
struct RecordingWriter {
writes: Vec<(tokio::time::Instant, Vec<u8>)>,
}
impl tokio::io::AsyncWrite for RecordingWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
self.writes
.push((tokio::time::Instant::now(), buf.to_vec()));
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
/// Code and Enter in one write is read by the CLI as a paste: the code
/// fills the prompt and the `\r` is swallowed with it, so nothing is
/// submitted and the flow sits until `SETUP_TIMEOUT`. Measured against
/// 2.1.283 under a pty; a separate Enter 20 ms later submits.
#[tokio::test(start_paused = true)]
async fn the_code_and_its_enter_are_separate_writes() {
let mut w = RecordingWriter::default();
type_code_then_enter(&mut w, b"abc#def").await.unwrap();
assert_eq!(w.writes.len(), 2, "expected two writes, got {:?}", w.writes);
assert_eq!(w.writes[0].1, b"abc#def");
assert_eq!(w.writes[1].1, b"\r");
assert!(w.writes[1].0 - w.writes[0].0 >= SUBMIT_ENTER_DELAY);
}
} }
@@ -469,6 +469,20 @@ mod tests {
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]); assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
} }
/// Auto mode is passed as a `--permission-mode` value, not its own flag.
#[test]
fn build_terminal_cmd_passes_auto_permission_mode() {
let mut p = project("anthropic", serde_json::Value::Null);
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
let cmd = build_claude_terminal_cmd(&p, None, None);
assert!(
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
"got: {}",
cmd[2]
);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the /// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec. /// prelude, immediately before the exec.
#[test] #[test]
+5
View File
@@ -166,6 +166,9 @@ pub enum PermissionMode {
Default, Default,
/// Auto-accept file edits, prompt for everything else. /// Auto-accept file edits, prompt for everything else.
AcceptEdits, AcceptEdits,
/// Claude Code's classifier approves safe actions and blocks risky ones,
/// without prompting.
Auto,
/// Skip all permission prompts. /// Skip all permission prompts.
Bypass, Bypass,
} }
@@ -180,6 +183,7 @@ impl PermissionMode {
PermissionMode::AcceptEdits => { PermissionMode::AcceptEdits => {
vec!["--permission-mode".to_string(), "acceptEdits".to_string()] vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
} }
PermissionMode::Auto => vec!["--permission-mode".to_string(), "auto".to_string()],
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()], PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
} }
} }
@@ -191,6 +195,7 @@ impl PermissionMode {
PermissionMode::Plan => "plan", PermissionMode::Plan => "plan",
PermissionMode::Default => "default", PermissionMode::Default => "default",
PermissionMode::AcceptEdits => "acceptEdits", PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Auto => "auto",
PermissionMode::Bypass => "bypass", PermissionMode::Bypass => "bypass",
} }
} }
+1
View File
@@ -26,6 +26,7 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" }, acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
auto: { text: "auto", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" }, bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
}; };
@@ -75,11 +75,11 @@ describe("PermissionModeControl", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("renders all four modes as a radio group with the effective one checked", () => { it("renders all five modes as a radio group with the effective one checked", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />); render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const group = screen.getByRole("radiogroup", { name: "Permission mode" }); const group = screen.getByRole("radiogroup", { name: "Permission mode" });
expect(group).toBeInTheDocument(); expect(group).toBeInTheDocument();
expect(screen.getAllByRole("radio")).toHaveLength(4); expect(screen.getAllByRole("radio")).toHaveLength(5);
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute( expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked", "aria-checked",
"true", "true",
@@ -92,6 +92,14 @@ describe("PermissionModeControl", () => {
expect(onChange).toHaveBeenCalledWith("acceptEdits"); expect(onChange).toHaveBeenCalledWith("acceptEdits");
}); });
it("offers Auto between Accept Edits and Bypass", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const labels = screen.getAllByRole("radio").map((r) => r.textContent);
expect(labels).toEqual(["Plan", "Default", "Accept Edits", "Auto", "Bypass"]);
fireEvent.click(screen.getByRole("radio", { name: "Auto" }));
expect(onChange).toHaveBeenCalledWith("auto");
});
it("moves selection with the arrow keys", () => { it("moves selection with the arrow keys", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />); render(<PermissionModeControl project={baseProject} onChange={onChange} />);
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), { fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
@@ -9,6 +9,11 @@ export const PERMISSION_MODES: Segment<PermissionMode>[] = [
label: "Accept Edits", label: "Accept Edits",
hint: "File edits are auto-approved; other tools still prompt.", hint: "File edits are auto-approved; other tools still prompt.",
}, },
{
value: "auto",
label: "Auto",
hint: "A safety classifier approves routine actions and blocks risky ones, without prompting.",
},
{ {
value: "bypass", value: "bypass",
label: "Bypass", label: "Bypass",
@@ -159,12 +159,19 @@ describe("TaskEditorModal", () => {
}); });
it("warns that a headless run cannot answer a permission prompt", async () => { it("warns that a headless run cannot answer a permission prompt", async () => {
// Bypass is the only mode where an unattended run is safe from stalling. // Bypass (and Auto, below) are the modes where an unattended run cannot stall.
await renderEditor(null, { ...baseProject, permission_mode: "bypass" }); await renderEditor(null, { ...baseProject, permission_mode: "bypass" });
expect(screen.getByText(/headless/i)).toBeInTheDocument(); expect(screen.getByText(/headless/i)).toBeInTheDocument();
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull(); expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
}); });
it("tells Auto mode that blocked actions are denied, and warns of the fallback", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "auto" });
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
expect(screen.getByText(/blocks are denied/i)).toBeInTheDocument();
expect(screen.getByText(/falls back to prompting/i)).toBeInTheDocument();
});
it("spells out the stall risk in any non-Bypass mode", async () => { it("spells out the stall risk in any non-Bypass mode", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "default" }); await renderEditor(null, { ...baseProject, permission_mode: "default" });
expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument(); expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument();
@@ -301,11 +301,18 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
terminal attached, using this project&rsquo;s permission mode ( terminal attached, using this project&rsquo;s permission mode (
<strong className="text-[var(--text-primary)]">{modeLabel}</strong>). <strong className="text-[var(--text-primary)]">{modeLabel}</strong>).
</p> </p>
{mode !== "bypass" && ( {mode === "auto" && (
<p className="text-xs text-[var(--warning)]">
In Auto mode, actions the safety classifier blocks are denied and the run carries on
without them. If Auto isn&rsquo;t available for this project&rsquo;s model or backend,
Claude Code falls back to prompting and the task may stall.
</p>
)}
{mode !== "bypass" && mode !== "auto" && (
<p className="text-xs text-[var(--warning)]"> <p className="text-xs text-[var(--warning)]">
A headless run cannot answer a permission prompt. In {modeLabel} mode the task may A headless run cannot answer a permission prompt. In {modeLabel} mode the task may
stall and produce an empty log; set the mode to Bypass in the Config tab for stall and produce an empty log; set the mode to Auto or Bypass in the Config tab
unattended runs. for unattended runs.
</p> </p>
)} )}
</div> </div>
+1 -1
View File
@@ -126,7 +126,7 @@ export const CUSTOM_ENDPOINT_BACKENDS: readonly Backend[] = [
]; ];
/** Mirrors Rust `PermissionMode` (serde camelCase). */ /** Mirrors Rust `PermissionMode` (serde camelCase). */
export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass"; export type PermissionMode = "plan" | "default" | "acceptEdits" | "auto" | "bypass";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
+5 -1
View File
@@ -62,11 +62,15 @@ TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# PermissionMode::cli_args() in app/src-tauri/src/models/project.rs. # PermissionMode::cli_args() in app/src-tauri/src/models/project.rs.
# NOTE: headless `claude -p` runs cannot answer a permission prompt, so any # NOTE: headless `claude -p` runs cannot answer a permission prompt, so any
# mode other than "bypass" means the task may stop early when Claude Code asks # mode other than "bypass" means the task may stop early when Claude Code asks
# for permission. Unset or unrecognized values pass no flag (Claude's default). # for permission. In "auto", classifier-blocked actions are denied instead of
# prompted, but if auto mode is unavailable for the session's model/backend,
# Claude Code falls back to prompting and the same stall applies.
# Unset or unrecognized values pass no flag (Claude's default).
PERMISSION_ARGS=() PERMISSION_ARGS=()
case "${TRIPLE_C_PERMISSION_MODE:-}" in case "${TRIPLE_C_PERMISSION_MODE:-}" in
plan) PERMISSION_ARGS=(--permission-mode plan) ;; plan) PERMISSION_ARGS=(--permission-mode plan) ;;
acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;; acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;;
auto) PERMISSION_ARGS=(--permission-mode auto) ;;
bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;; bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;;
*) PERMISSION_ARGS=() ;; *) PERMISSION_ARGS=() ;;
esac esac