Skip to content

Let Cloud loop check scripts run room-cli loop and top-level spawns - #93

Open
jparker2006 wants to merge 3 commits into
davidondrej:mainfrom
jparker2006:core-loop-script-room-cli
Open

jparker2006 wants to merge 3 commits into
davidondrej:mainfrom
jparker2006:core-loop-script-room-cli

Conversation

@jparker2006

@jparker2006 jparker2006 commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Problem. In a Cloud --cron --new-thread loop, the --script-file check runs in the loop's own sandbox. gh and git work there, but every session-scoped room-cli call fails. The same calls work in a thread's sandbox. When the script fails, room-cli loop list shows only "The script failed (exit 3).", never the output, so this is hard to debug. skills/create-loop/SKILL.md already tells agents a script can thread spawn --top-level, which can't work today.

Repro. A real --cron '* * * * *' --new-thread loop on core 0.1.87 (bd894ee), 2026-10-31. Its script ran these calls, printed a marker to stdout and one to stderr, and exited 3.

Call In the loop's script sandbox Exit In a thread's sandbox
room-cli thread list Run cloudroom thread list from a Cloudroom cloud thread 1 0
room-cli loop list Run cloudroom automation from a Cloudroom cloud thread 1 0
room-cli status Unknown command status. Commands: room-cli thread, automation (loop), ... 2 2
room-cli files wait Project files are ready in /code. 0 0
gh api user, git --version work 0 0

Afterwards room-cli loop list showed "last_error": "The script failed (exit 3).", and neither marker appeared anywhere.

status fails the same way in a thread: Cloud room-cli has no status command at all (it is Mac-only). It isn't a script problem, so this PR leaves it alone.

Cause.

  • caller() maps the requesting PID's ancestors to a harness session (Manager::harness_session). The website runs a loop script through the sandbox provider (bash <- timeout <- bash <- sudo <- bash <- sudo <- sh), not from a harness. So every thread route and automation() answer 409, and the CLI exits 1. The message is also misleading: the script is running in Cloudroom.
  • The script runner, its 60 s limit, and last_error live on the website (private). Core only forwards room-cli loop ... as api.post("automation", ...) with session, then prints the website's JSON unchanged.

Scope. A full fix also needs the private website. This PR is the core half plus an exact website contract below. I could not run the two together end to end, and I don't claim to have.

Change.

  • Identity (thread.rs identify). Core's trust model stays the same: the peer uid must be the agent account. A harness above the caller still wins, always. Only when there is none does Core read the nearest CLOUDROOM_LOOP=<automation id> in the caller's ancestry. The website sets that marker (see below). Core checks the value has the 36-character UUID shape that automation.rs already checks for IDs (now valid_id, shared). A malformed marker is refused with an error that names the variable. Core reads the whole ancestry, not just the caller, so a script command that clears its environment (env -i ...) still counts.
  • Environment reading (linux.rs). I pulled the /proc/PID/environ parsing out of the workload scan (CLOUDROOM_WORKLOAD) and the desktop check (CLOUDROOM_DESKTOP) into one values() helper. All three uses share it, with the same semantics as before. nearest_value() returns the first process in a PID list that sets a variable.
  • Scope sent to the website. A loop script's loop ... and thread spawn --top-level requests carry "automation": "<id>" instead of "session" (Caller::scope). Core never builds a scope itself. The website decides.
  • What a script may run. room-cli loop ... and room-cli thread spawn --top-level. Everything else gets a clear refusal that says it's a loop's check script and what works instead:
    • loop report: "A loop's check script can't report: room-cli loop report is for the run's own thread. Print what the run needs instead; Cloudroom adds it to the run's prompt."
    • thread spawn without --top-level, including --shared-sandbox: "A loop's check script has no thread to start a child under. Add --top-level ..."
    • Every other session-only command (thread list/output/tell/stop/archive/update/move, fork, --self, feedback, gpu, computer-use): "A loop's check script has no thread, so it can't run cloudroom X. It can run room-cli loop (except report) and room-cli thread spawn --top-level."
  • thread list is refused, not empty. A script has no children here. The threads it starts are top-level and can't be read back (TOP_NOTE). An empty list would look like a real answer to "is anything running?", so a clear refusal pointing at loop list is the honest choice.
  • A process with neither a harness nor a marker is refused as before. For loop and spawn the message now also mentions loop scripts.
  • Docs. One sentence in the room-cli loop --help text (automation.rs USAGE): in a script, loop (except report) and thread spawn --top-level act for its loop, and list shows each loop's last script output. In create-loop/SKILL.md, "--top-level in a Cloud thread" becomes "in a Cloud loop", since scripts don't run in a thread. Both lines describe behaviour after the website half ships. Drop them if it doesn't ship in the same release.
  • room-cli secret keeps its own session check and message. A script can't open a secret form for the user anyway.

Website half (needed, not in this repo).

  1. Marker. Wherever the website execs a loop's check script, set CLOUDROOM_LOOP=<the loop's automation id> on the agent-account process. Set it after sudo, because env_reset drops it otherwise: sudo -u cloudroom-agent env CLOUDROOM_LOOP=<id> bash .... Do this for both places scripts run: the loop's own sandbox (fresh-thread loops) and the thread's sandbox (other loops). Nothing else is needed in the sandbox. Core reads it from /proc.
  2. Request field. POST /api/core/automation and POST /api/core/launch then carry "automation": "<id>" in place of "session". Exactly one of the two is present. Every other field is unchanged. launch keeps its request_id idempotency, keyed on the loop instead of the session.
  3. Scoping rule. Accept automation only if that loop's script runs in the sandbox this Core is signed in as (its token and store). Otherwise answer 403 with an error. When it's accepted, act as that loop's owner in that loop's project. list, update, pause, resume, run, delete, and hook see the loops a thread in that project sees. launch starts a top-level thread in the loop's project, with the loop's agent unless the request names one. A loop ID is not a secret, so this sandbox check is the security boundary. Any agent-account process can set the variable; Core only ignores it under a harness.
  4. Refusals. Refuse report with automation. Core already does, but the website should too. Refuse a create that would re-prompt "this thread", since a script has none: new_thread: false, or a one-time --at/--in without --new-thread. Suggested message: "A loop's check script has no thread to re-prompt. Add --new-thread." Core leaves this to the website, because the website owns the defaults.
  5. Output in loop list. For each loop, add last_output next to last_error. It holds the most recent script run's stdout and stderr combined, cut to the last 4 KB, and the next run replaces it. Show it only where the run prompt's script output already goes: to the loop's owner. Don't log or send it anywhere else. Core needs no change for this. api.post returns the website's JSON as-is, and room-cli loop list prints it verbatim (automation.rs#L336-L337). Never put a top-level error field in a successful answer: the CLI treats that as a failure (mac/mod.rs#L773).
  6. Protected machines can't see the marker. Where Core runs as its own cloudroom account, it has no ptrace rights. So it can't read an agent process's /proc/PID/environ, and the read fails with EACCES. /proc/PID/stat stays readable, so harness detection still works. This covers two setups in this repo: the managed setup in install/configure.py (separate accounts, systemd, and CLOUDROOM_API_URL, so room-cli loop works for threads there), and sandbox-start.sh on a machine with cgroups. Cloud containers ("cgroup_root": null) are unaffected, because Core runs as the agent's own uid there; both repro sandboxes are containers. If the website runs a script on a protected machine (say, a same-thread loop whose thread lives there), that script's loop and spawn --top-level calls get a specific refusal instead of the generic one: "This machine's Core runs as its own account and can't read this command's environment, so it can't see a loop script's CLOUDROOM_LOOP. Run cloudroom loop from a Cloudroom cloud thread here." Whether the website still provisions protected machines, or runs scripts on them, is in the private repo, so I can't check. If scripts must work there, the follow-up is for room-cli to send its own CLOUDROOM_LOOP with the request. That is no weaker: any agent-account process can already set the variable, and the sandbox check in point 3 is the boundary. I left it out to keep this PR small. Core gets no CAP_SYS_PTRACE.
    Evidence, from this sandbox. A uid-65534 process with ambient cap_dac_override models protected Core's file-permission bypass; this container's bounding set has no dac_read_search. It gets Permission denied (errno 13) on an agent process's environ, while that process's stat stays readable. Root without sys_ptrace fails the same way.

Tests. The tests came first, as their own commit (07904a18), against bd894ee's Rust code. They live in core/tests/core_fixture.py LoopScriptTests, which CI already runs. Each test runs the real Rust service with its agent socket, next to a running fake-Codex thread, and uses fake_api.py. fake_api.py now records launch calls too. A "script" request comes from env CLOUDROOM_LOOP=ID sh -c 'env -i <request>', so the marker is only on the parent shell, as it will be under sudo.

Test Proves On bd894ee
test_a_loop_script_runs_loop_commands_and_top_level_spawns_as_its_loop A marked non-harness process can run loop list and spawn --top-level. The website gets exactly {"action":"list","automation":ID}, and launch gets automation and no session. fails: 409 {'error': 'Run cloudroom automation from a Cloudroom cloud thread'} (the live repro)
test_a_process_without_a_loop_marker_is_still_refused Without a marker, loop list and spawn --top-level are still refused, and nothing reaches the website. passes (guard)
test_a_malformed_loop_marker_is_refused Empty, short, non-hex, path-like, and 37-character markers are refused with an error naming CLOUDROOM_LOOP, and nothing is forwarded. fails: generic message
test_session_only_commands_refuse_a_loop_script_clearly thread list, update --title, archive --self, fork, tell, feedback, a child spawn, a --shared-sandbox spawn, and loop report from a script get the specific refusals, and nothing is forwarded. fails: Run cloudroom thread list from a Cloudroom cloud thread
test_a_harness_session_wins_over_a_loop_marker A harness's tool command that carries a marker still forwards {"action":"list","session":"cr_seed"}. passes (guard)

Mutation checks against the fix: letting the marker win over the harness fails the harness test. Reading only the caller's own environment, not its ancestors', fails three tests.

A Rust unit test in thread.rs, an_unreadable_caller_environment_gets_its_own_refusal, covers point 6. A readable caller with no marker gets the usual refusal. An unreadable one, pid 1 (root's), gets the specific one. If the test runs as root with ptrace rights, pid 1 is readable, so it checks only the first half. Making environment_hidden always return false fails it.

Results on 8abe8a40. cargo fmt --check and cargo clippy --locked --all-targets -- -D warnings pass. cargo test --locked --all-targets --no-fail-fast: everything passes, including the new unit test, except harness_commits_drop_ai_co_authors_and_keep_human_ones. That test fails the same way on clean bd894ee in this sandbox (git 2.39.5 keeps the AI co-author lines). a_full_disk_stops_the_writing_tool_command_not_the_harness passes here, and it exercises the workload environment scan this PR refactors. python3 tests/core_fixture.py: 37 of 37 pass (32 existing + 5 new).

Worth challenging.

  • The website half is the real fix. Until it sets the marker, scripts behave exactly as today, apart from the clearer message.
  • Protected machines (point 6) get a clear refusal, not a working script. Making them work needs a second way to pass the marker, and I'd only add that if the website runs scripts there.
  • thread list is refused rather than returning an empty list (reasons above).
  • loop create/update/delete from a script go through to the website, which decides what a loop may manage (contract point 3). Core only blocks report.
  • A script's spawn --top-level can take longer than the 60 s script limit when it wakes a sandbox. The start runs as its own task in Core, so killing the script doesn't cut it in half. A script that may retry should pass a stable --request-id, so the next minute's run never starts a second thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant