From 88da1e49d6f30d261ea9b1c60f29afac8135f964 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 19 Jul 2026 22:46:07 +0100 Subject: [PATCH 1/2] fix(jobs): run agents with unattended permissions --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/architecture.md | 7 ++-- docs/configuration.md | 12 +++--- docs/index.md | 6 ++- docs/jobs.md | 10 +++-- docs/jobs/design.md | 10 +++-- docs/security.md | 18 +++++--- docs/services.md | 5 ++- src/agent.rs | 14 +++++++ src/assistant.rs | 2 +- src/claude.rs | 98 +++++++++++++++++++++++++++++++++---------- src/codex.rs | 69 ++++++++++++++++++++++++------ src/config.rs | 7 ++-- src/jobs.rs | 2 +- 15 files changed, 195 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 610c175..f792192 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,7 +797,7 @@ dependencies = [ [[package]] name = "push" -version = "0.8.0" +version = "0.8.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f6aa92f..31d2f97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "push" -version = "0.8.0" +version = "0.8.1" edition = "2021" license = "MIT" description = "A tiny messaging gateway that turns coding agents into a personal assistant you text." diff --git a/docs/architecture.md b/docs/architecture.md index 19cdd8a..76fa186 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -414,9 +414,10 @@ the trust boundary. iMessage uses `imessage.self_handles` and and `telegram.allow_chat_ids`; Slack uses stable `slack.allow_user_ids` member IDs and verifies the authenticated workspace. -Push does not override sandbox, approval, permission-mode, or tool-list settings. -Chats and jobs use the selected agent's own configuration and must use work -directories that do not overlap Push-owned state or configuration. +Push preserves sandbox, approval, permission-mode, and tool-list settings for +chats. Codex and Claude jobs bypass interactive permissions so unattended work +can complete. Every job must use a work directory that does not overlap +Push-owned state or configuration. ## Roadmap diff --git a/docs/configuration.md b/docs/configuration.md index 94d6349..638c191 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,8 +43,8 @@ allow_user_ids = [123456789] ``` `channel` is the easiest single-provider setup. `agent` is `claude`, `codex`, -or `pi`. Push does not set sandbox, approval, permission-mode, or tool flags. -Configure those in the selected agent. +or `pi`. Push preserves backend permission settings for chats. Codex and Claude +jobs bypass interactive permissions so unattended runs can complete. ### Pi setup @@ -177,10 +177,10 @@ Thread keys are: ## Agent permissions -Permissions belong to the agent, not the gateway. Push invokes Claude Code, -Codex, and Pi without overriding their sandbox, approval mode, or tool lists. -This keeps interactive and gateway behavior aligned. Configure permissions in -the selected agent and review [permissions and security](security.md). +For chats, Push invokes Claude Code, Codex, and Pi without overriding their +sandbox, approval mode, or tool lists. Codex and Claude jobs bypass interactive +permissions because scheduled work has no operator available to approve +requests. Review [permissions and security](security.md) before enabling jobs. ## Settings reference diff --git a/docs/index.md b/docs/index.md index 2100e30..eb0334a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -79,7 +79,7 @@ sends the result back when the work is done.
04

Use your existing tools

-

Keep the MCP servers, skills, permissions, and integrations already configured in Claude Code, Codex, or Pi.

+

Keep the MCP servers, skills, and integrations already configured in Claude Code, Codex, or Pi. Chats also preserve your configured permissions.

@@ -99,7 +99,9 @@ sends the result back when the work is done. Push does not replace your coding agent. It handles chat, history, schedules, approvals, and delivery. Claude Code, Codex, or Pi keeps control of models, -tools, skills, permissions, and authentication. +tools, skills, and authentication. Chats preserve configured agent permissions; +Codex and Claude jobs bypass interactive permissions so scheduled work can +finish without an operator. [See the full architecture](architecture.md){ .push-inline-link } diff --git a/docs/jobs.md b/docs/jobs.md index c7c09f9..258df27 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -171,7 +171,8 @@ and primary delivery destination. ## Execution and delivery guarantees - Every job and evaluator run uses a fresh backend session, without chat history. -- Jobs use the selected agent's own permission configuration. +- Codex and Claude jobs bypass interactive permissions so unattended work can + complete. Evaluators remain restricted. - Push does not retry failed or timed-out backend execution because the agent may have completed external side effects before failing. - Success, failure, timeout, overlap, and delivery state are stored separately. @@ -209,6 +210,7 @@ job. Rejection leaves the proposal inactive. !!! warning - Jobs use the selected agent's own permissions. A headless backend may not - ask for approval interactively, so configure its - unattended permissions carefully and keep job work directories narrow. + Jobs have no interactive approval path. Push runs Codex jobs with full + access and no prompts and Claude jobs in `bypassPermissions` mode. Treat + every enabled job as code execution by the Push service user, keep job work + directories narrow, and allow only trusted senders and job definitions. diff --git a/docs/jobs/design.md b/docs/jobs/design.md index 8b2470c..f645fbe 100644 --- a/docs/jobs/design.md +++ b/docs/jobs/design.md @@ -47,8 +47,9 @@ an agent runtime. - Push has one long-running gateway process with no inbound server port. Commands may run as short-lived local processes against the same SQLite store. -- Claude Code, Codex, and Pi own their permission controls. Push passes no - permission or tool overrides. +- Chats use the selected agent's permission controls without overrides. Codex + and Claude jobs bypass interactive permissions so unattended runs can finish; + Pi has no native filesystem sandbox or approval prompt. - Scheduled work can have external side effects, so duplicate execution is more dangerous than skipping a missed run. - Job files, `SOUL.md`, and `context/` belong to the Git-versioned assistant @@ -143,8 +144,9 @@ A scheduled occurrence is cancelled if its trigger no longer exists in that validated snapshot. Immediately before spawning a backend, Push resolves the work directory again so a path replacement is likely to be detected. This path-based check does not eliminate a replacement race between validation and -child startup; the agent's configuration and OS permissions remain the -enforcement boundary. The timeout must not exceed the configured jobs maximum. +child startup. Jobs bypass interactive backend permissions, so OS permissions +remain the enforcement boundary. The timeout must not exceed the configured +jobs maximum. Notification behavior follows the trigger rather than job metadata. A manual run prints its result to the invoking terminal and does not send a message. A diff --git a/docs/security.md b/docs/security.md index 85b74ea..46195b9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -24,8 +24,8 @@ Slack allowlists use stable member IDs, never mutable display names. ## Agent permissions Push does not pass sandbox, approval-policy, permission-mode, or tool-list -overrides to Claude Code, Codex, or Pi. The selected agent's own configuration -is the sole permission source for chats and jobs. +overrides for chats. The selected agent's own configuration is the permission +source for chat requests. This makes Push behave like the agent you already configured, but it also means every agent-approved capability may be one accepted message away. Review the @@ -40,9 +40,17 @@ selected agent instead. ## Job permissions -Jobs use the selected agent's own permission configuration. Push requires a -fixed, existing work directory and rejects overlap with Push-owned files, -including the loaded config file. +Jobs must complete without an interactive approval channel. Push therefore runs +Codex jobs with full filesystem and network access and no approval prompts, and +runs Claude jobs in `bypassPermissions` mode. Pi already has no native +filesystem sandbox or interactive permission prompt. Evaluators remain +read-only with tools disabled. + +This makes job bodies equivalent to unattended code execution as the Push +service user. Push requires a fixed, existing work directory and rejects overlap +with Push-owned files, including the loaded config file. Keep allowed senders +and job definitions trusted, and run the service with only the OS permissions +its jobs require. Do not place secrets in a job body. Make them available through the backend or service environment using the narrowest policy that works. diff --git a/docs/services.md b/docs/services.md index d9af03e..f0d0aac 100644 --- a/docs/services.md +++ b/docs/services.md @@ -237,8 +237,9 @@ is completed. Managed services run without a person watching the terminal. An allowed sender can instruct the configured backend to use its tools, subject to your backend settings. Keep `imessage.allow_from` narrow and configure each selected agent -for unattended use. Push passes no sandbox, approval, or tool overrides. Jobs -are kept away from Push-owned paths by work-directory validation. +for unattended use. Push preserves backend permissions for chats. Codex and +Claude jobs bypass interactive permissions so they can finish without an +operator. Jobs are kept away from Push-owned paths by work-directory validation. Store config files, state files, audit logs, backend credentials, and service logs with permissions appropriate for the service user. Logs may contain diff --git a/src/agent.rs b/src/agent.rs index 765bfb3..7982ad6 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -131,6 +131,20 @@ impl Runner { } } + pub async fn run_unattended( + &self, + req: Request<'_>, + timeout: Duration, + ) -> Result { + match self { + Runner::Claude(r) => r.run_unattended(req, timeout).await, + Runner::Codex(r) => r.run_unattended(req, timeout).await, + Runner::Pi(r) => r.run(req, timeout).await, + #[cfg(test)] + Runner::Fake(r) => r.run(req, timeout).await, + } + } + pub async fn run_evaluator( &self, req: Request<'_>, diff --git a/src/assistant.rs b/src/assistant.rs index 1de57f4..9db5e15 100644 --- a/src/assistant.rs +++ b/src/assistant.rs @@ -45,7 +45,7 @@ This Git repository contains the durable, user-owned parts of one Push assistant - `evals/` contains reusable agent evaluation criteria. - `jobs/` contains installed Push job runbooks. -Push owns channels, scheduling, history, security, approvals, and delivery outside this repository. Project skills may live here, while the configured agent runtime owns discovery, execution, permissions, global skills, MCP servers, and authentication. +Push owns channels, scheduling, history, security, approvals, and delivery outside this repository. Project skills may live here, while the configured agent runtime owns discovery, execution, global skills, MCP servers, and authentication. Chats preserve configured agent permissions. Codex and Claude jobs bypass interactive permissions so unattended work can finish. "#; const CONTEXT_README: &str = r#"# Context diff --git a/src/claude.rs b/src/claude.rs index 37ceb12..5aceb3a 100644 --- a/src/claude.rs +++ b/src/claude.rs @@ -13,6 +13,13 @@ pub struct Runner { pub bin: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum RunMode { + Configured, + Unattended, + Evaluator, +} + #[derive(Deserialize, Default)] struct CliResult { #[serde(default)] @@ -28,7 +35,15 @@ struct CliResult { impl Runner { /// Executes one turn and returns Claude's reply text, or a RunError. pub async fn run(&self, req: Request<'_>, timeout: Duration) -> Result { - self.run_with_mode(req, timeout, false).await + self.run_with_mode(req, timeout, RunMode::Configured).await + } + + pub async fn run_unattended( + &self, + req: Request<'_>, + timeout: Duration, + ) -> Result { + self.run_with_mode(req, timeout, RunMode::Unattended).await } pub async fn run_evaluator( @@ -36,18 +51,18 @@ impl Runner { req: Request<'_>, timeout: Duration, ) -> Result { - self.run_with_mode(req, timeout, true).await + self.run_with_mode(req, timeout, RunMode::Evaluator).await } async fn run_with_mode( &self, req: Request<'_>, timeout: Duration, - evaluator: bool, + mode: RunMode, ) -> Result { let is_resume = !req.is_new; let attempt = crate::agent::output_with_retry(|| { - let mut cmd = self.command(&req, evaluator); + let mut cmd = self.command(&req, mode); async move { cmd.output().await } }); let out = match tokio::time::timeout(timeout, attempt).await { @@ -59,13 +74,13 @@ impl Runner { self.parse_output(out, is_resume) } - fn command(&self, req: &Request<'_>, evaluator: bool) -> Command { + fn command(&self, req: &Request<'_>, mode: RunMode) -> Command { let mut cmd = Command::new(&self.bin); cmd.arg("-p") .arg(req.prompt) .arg("--output-format") .arg("json"); - if evaluator { + if mode == RunMode::Evaluator { cmd.arg("--safe-mode") .arg("--tools") .arg("") @@ -74,6 +89,8 @@ impl Runner { .arg("{}") .arg("--no-chrome") .arg("--no-session-persistence"); + } else if mode == RunMode::Unattended { + cmd.arg("--permission-mode").arg("bypassPermissions"); } if req.is_new { cmd.arg("--session-id").arg(req.session_id); @@ -96,12 +113,12 @@ impl Runner { // claude prints its JSON envelope to stdout even when it exits non-zero // (e.g. an API error), so parse stdout regardless of exit status. match serde_json::from_slice::(&out.stdout) { - Ok(r) if r.is_error => { - let msg = if r.result.is_empty() { - r.subtype - } else { - r.result - }; + Ok(r) if r.is_error || !out.status.success() => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let msg = [r.result, r.subtype, stderr] + .into_iter() + .find(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "claude exited unsuccessfully".to_string()); if is_resume && missing_resume_error(&msg) { Err(RunError::SessionMissing(msg)) } else { @@ -194,7 +211,7 @@ mod tests { } #[tokio::test] - async fn runs_new_session_with_push_owned_session_id() { + async fn unattended_new_session_bypasses_permissions() { let args_path = temp_path("claude-args"); let work_dir = temp_dir("claude-work"); let script = format!( @@ -205,7 +222,7 @@ mod tests { let runner = Runner { bin: cli.bin() }; let out = runner - .run( + .run_unattended( Request { session_id: "push-session", is_new: true, @@ -224,12 +241,8 @@ mod tests { assert_arg_pair(&args, "--session-id", "push-session"); assert_arg_pair(&args, "--append-system-prompt", "assistant identity"); assert_arg_pair(&args, "-p", "hello"); - for flag in [ - "--permission-mode", - "--tools", - "--allowed-tools", - "--disallowed-tools", - ] { + assert_arg_pair(&args, "--permission-mode", "bypassPermissions"); + for flag in ["--tools", "--allowed-tools", "--disallowed-tools"] { assert!( !args.contains(&flag.to_string()), "unexpected {flag} in {args:?}" @@ -239,7 +252,7 @@ mod tests { } #[tokio::test] - async fn runs_resumed_session_with_resume_flag() { + async fn unattended_resumed_session_bypasses_permissions() { let args_path = temp_path("claude-resume-args"); let work_dir = temp_dir("claude-resume-work"); let script = format!( @@ -250,7 +263,7 @@ mod tests { let runner = Runner { bin: cli.bin() }; let out = runner - .run( + .run_unattended( Request { session_id: "existing-session", is_new: false, @@ -266,6 +279,7 @@ mod tests { assert_eq!(out.reply, "resumed"); let args = read_args(&args_path); assert_arg_pair(&args, "--resume", "existing-session"); + assert_arg_pair(&args, "--permission-mode", "bypassPermissions"); assert!(!args.contains(&"--session-id".to_string())); assert_arg_pair(&args, "--append-system-prompt", "assistant identity"); assert!(!args.contains(&"--add-dir".to_string())); @@ -294,6 +308,28 @@ mod tests { } } + #[tokio::test] + async fn configured_run_preserves_backend_permission_settings() { + let args_path = temp_path("claude-configured-args"); + let work_dir = temp_dir("claude-configured-work"); + let cli = FakeCli::new( + "claude", + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\nprintf '%s\\n' '{{\"result\":\"reply\",\"session_id\":\"claude-session\"}}'\n", + sh_arg(&args_path) + ), + ); + let runner = Runner { bin: cli.bin() }; + + runner + .run(request(work_dir.to_str().unwrap()), Duration::from_secs(5)) + .await + .unwrap(); + + let args = read_args(&args_path); + assert!(!args.contains(&"--permission-mode".to_string())); + } + #[tokio::test] async fn resumed_lookup_failure_is_typed_before_gateway_retry() { let work_dir = temp_dir("claude-missing-resume-work"); @@ -340,6 +376,23 @@ mod tests { assert_failed(err, "api down"); } + #[tokio::test] + async fn rejects_non_zero_exit_with_non_error_json_envelope() { + let work_dir = temp_dir("claude-false-success-work"); + let cli = FakeCli::new( + "claude", + "#!/bin/sh\nprintf '%s\\n' '{\"result\":\"permission denied\",\"is_error\":false}'\nexit 1\n", + ); + let runner = Runner { bin: cli.bin() }; + + let error = runner + .run(request(work_dir.to_str().unwrap()), Duration::from_secs(5)) + .await + .unwrap_err(); + + assert_failed(error, "permission denied"); + } + #[tokio::test] async fn reports_timeout() { let work_dir = temp_dir("claude-timeout-work"); @@ -383,6 +436,7 @@ mod tests { assert_arg_pair(&args, "--mcp-config", "{}"); assert!(args.iter().any(|arg| arg == "--strict-mcp-config")); assert!(args.iter().any(|arg| arg == "--safe-mode")); + assert!(!args.contains(&"--permission-mode".to_string())); } fn request(work_dir: &str) -> Request<'_> { diff --git a/src/codex.rs b/src/codex.rs index 985803d..c55b431 100644 --- a/src/codex.rs +++ b/src/codex.rs @@ -17,6 +17,13 @@ pub struct Runner { pub bin: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum RunMode { + Configured, + Unattended, + Evaluator, +} + #[derive(Deserialize)] struct JsonEvent { #[serde(rename = "type")] @@ -53,7 +60,15 @@ impl Drop for OutputFile { impl Runner { /// Executes one turn and returns Codex's final reply plus the Codex session id. pub async fn run(&self, req: Request<'_>, timeout: Duration) -> Result { - self.run_with_mode(req, timeout, false).await + self.run_with_mode(req, timeout, RunMode::Configured).await + } + + pub async fn run_unattended( + &self, + req: Request<'_>, + timeout: Duration, + ) -> Result { + self.run_with_mode(req, timeout, RunMode::Unattended).await } pub async fn run_evaluator( @@ -61,20 +76,20 @@ impl Runner { req: Request<'_>, timeout: Duration, ) -> Result { - self.run_with_mode(req, timeout, true).await + self.run_with_mode(req, timeout, RunMode::Evaluator).await } async fn run_with_mode( &self, req: Request<'_>, timeout: Duration, - evaluator: bool, + mode: RunMode, ) -> Result { let output_file = OutputFile::create() .map_err(|error| RunError::Failed(format!("prepare Codex output: {error}")))?; let out_path = output_file.path.as_path(); let attempt = crate::agent::output_with_retry(|| { - let mut cmd = self.command(&req, out_path, evaluator); + let mut cmd = self.command(&req, out_path, mode); async move { cmd.output().await } }); let out = match tokio::time::timeout(timeout, attempt).await { @@ -113,13 +128,13 @@ impl Runner { }) } - fn command(&self, req: &Request<'_>, out_path: &Path, evaluator: bool) -> Command { + fn command(&self, req: &Request<'_>, out_path: &Path, mode: RunMode) -> Command { let mut cmd = Command::new(&self.bin); if !req.instructions.trim().is_empty() { cmd.arg("-c") .arg(developer_instructions(req.instructions.trim())); } - if evaluator { + if mode == RunMode::Evaluator { cmd.arg("-c") .arg("mcp_servers={}") .arg("-c") @@ -151,6 +166,11 @@ impl Runner { ] { cmd.arg("--disable").arg(feature); } + } else if mode == RunMode::Unattended { + cmd.arg("--sandbox") + .arg("danger-full-access") + .arg("--ask-for-approval") + .arg("never"); } if req.is_new { cmd.arg("exec") @@ -160,7 +180,7 @@ impl Runner { .arg(req.work_dir) .arg("-o") .arg(out_path); - if evaluator { + if mode == RunMode::Evaluator { cmd.arg("--sandbox") .arg("read-only") .arg("--ephemeral") @@ -279,7 +299,7 @@ mod tests { } #[tokio::test] - async fn runs_new_session_and_reads_jsonl_thread_id() { + async fn unattended_new_session_bypasses_permissions() { let args_path = temp_path("codex-args"); let work_dir = temp_dir("codex-work"); let script = codex_success_script(&args_path, "codex reply", Some("codex-thread")); @@ -287,7 +307,7 @@ mod tests { let runner = runner(cli.bin()); let out = runner - .run( + .run_unattended( Request { session_id: "", is_new: true, @@ -303,8 +323,8 @@ mod tests { assert_eq!(out.reply, "codex reply"); assert_eq!(out.session_id, Some("codex-thread".to_string())); let args = read_args(&args_path); - assert!(!args.contains(&"--ask-for-approval".to_string())); - assert!(!args.contains(&"--sandbox".to_string())); + assert_arg_pair(&args, "--ask-for-approval", "never"); + assert_arg_pair(&args, "--sandbox", "danger-full-access"); assert_arg_present(&args, "exec"); assert_arg_present(&args, "--json"); assert_arg_pair(&args, "-C", work_dir.to_str().unwrap()); @@ -314,7 +334,7 @@ mod tests { } #[tokio::test] - async fn runs_resumed_session_with_resume_command() { + async fn unattended_resumed_session_bypasses_permissions() { let args_path = temp_path("codex-resume-args"); let work_dir = temp_dir("codex-resume-work"); let script = codex_success_script(&args_path, "resumed reply", None); @@ -322,7 +342,7 @@ mod tests { let runner = runner(cli.bin()); let out = runner - .run( + .run_unattended( Request { session_id: "existing-thread", is_new: false, @@ -339,6 +359,8 @@ mod tests { assert_eq!(out.session_id, None); let args = read_args(&args_path); assert_arg_sequence(&args, &["exec", "resume"]); + assert_arg_pair(&args, "--ask-for-approval", "never"); + assert_arg_pair(&args, "--sandbox", "danger-full-access"); assert!(!args.contains(&"--add-dir".to_string())); assert_arg_present(&args, "existing-thread"); assert_arg_pair(&args, "-c", &developer_instructions("assistant identity")); @@ -362,6 +384,26 @@ mod tests { assert_failed(error, "codex exited without a final reply"); } + #[tokio::test] + async fn configured_run_preserves_backend_permission_settings() { + let args_path = temp_path("codex-configured-args"); + let work_dir = temp_dir("codex-configured-work"); + let cli = FakeCli::new( + "codex", + &codex_success_script(&args_path, "reply", Some("codex-thread")), + ); + let runner = runner(cli.bin()); + + runner + .run(request(work_dir.to_str().unwrap()), Duration::from_secs(5)) + .await + .unwrap(); + + let args = read_args(&args_path); + assert!(!args.contains(&"--ask-for-approval".to_string())); + assert!(!args.contains(&"--sandbox".to_string())); + } + #[tokio::test] async fn resumed_lookup_failure_is_typed_before_gateway_retry() { let work_dir = temp_dir("codex-missing-resume-work"); @@ -460,6 +502,7 @@ sleep 2 let args = read_args(&args_path); assert_arg_pair(&args, "--sandbox", "read-only"); + assert!(!args.contains(&"--ask-for-approval".to_string())); assert!(args.iter().any(|arg| arg == "--ephemeral")); assert!(args.iter().any(|arg| arg == "--ignore-user-config")); assert!(args.iter().any(|arg| arg == "mcp_servers={}")); diff --git a/src/config.rs b/src/config.rs index 387e859..06b7e3a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -190,7 +190,7 @@ impl Config { } if root.contains_key("job_permission_profiles") { bail!( - "job_permission_profiles is no longer supported; jobs run with the backend's own permission configuration, so remove this key" + "job_permission_profiles is no longer supported; jobs run unattended, so remove this key" ); } let has_assistant_root = root.contains_key("assistant_root"); @@ -410,9 +410,8 @@ impl Config { .with_context(|| format!("invalid jobs_max_timeout {}", self.jobs_max_timeout)) } - // Jobs run with the backend's own permission configuration, which may - // allow writes, so every job workdir must stay clear of Push-owned paths, - // including the loaded config file itself. + // Jobs run unattended with write access, so every job workdir must stay + // clear of Push-owned paths, including the loaded config file itself. pub fn validate_job_workdir(&self, workdir: &Path) -> Result<()> { let workdir = resolved_absolute("job workdir", workdir)?; let protected_paths = [ diff --git a/src/jobs.rs b/src/jobs.rs index 86821cb..5d7c06a 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1952,7 +1952,7 @@ async fn execute(cfg: &Config, job: &Job) -> std::result::Result Ok(output.reply), Err(RunError::Timeout) => Err(ExecutionError::Timeout), Err(RunError::Failed(error) | RunError::SessionMissing(error)) => { From 958a0ef9daf082b0a9cfe404492d6f9e9516afbb Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 19 Jul 2026 22:48:19 +0100 Subject: [PATCH 2/2] test(release): update v0.8.1 fixture --- tests/release-version.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/release-version.sh b/tests/release-version.sh index 61339cc..baa9fac 100755 --- a/tests/release-version.sh +++ b/tests/release-version.sh @@ -4,9 +4,9 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" check="$repo_root/scripts/check-release-version.sh" -"$check" v0.8.0 +"$check" v0.8.1 -for invalid_tag in 0.8.0 v0.7.0 v0.8.0-rc.1 refs/tags/v0.8.0; do +for invalid_tag in 0.8.1 v0.8.0 v0.8.1-rc.1 refs/tags/v0.8.1; do if "$check" "$invalid_tag" >/dev/null 2>&1; then echo "release version check accepted invalid tag: $invalid_tag" >&2 exit 1