Repository navigation
Let Cloud loop check scripts run room-cli loop and top-level spawns - #93
Open
jparker2006 wants to merge 3 commits into
Open
jparker2006 wants to merge 3 commits into
jparker2006 wants to merge 3 commits into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem. In a Cloud
--cron --new-threadloop, the--script-filecheck runs in the loop's own sandbox.ghandgitwork there, but every session-scopedroom-clicall fails. The same calls work in a thread's sandbox. When the script fails,room-cli loop listshows only"The script failed (exit 3).", never the output, so this is hard to debug.skills/create-loop/SKILL.mdalready tells agents a script canthread spawn --top-level, which can't work today.Repro. A real
--cron '* * * * *' --new-threadloop 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.room-cli thread listRun cloudroom thread list from a Cloudroom cloud threadroom-cli loop listRun cloudroom automation from a Cloudroom cloud threadroom-cli statusUnknown command status. Commands: room-cli thread, automation (loop), ...room-cli files waitProject files are ready in /code.gh api user,git --versionAfterwards
room-cli loop listshowed"last_error": "The script failed (exit 3).", and neither marker appeared anywhere.statusfails the same way in a thread: Cloudroom-clihas nostatuscommand 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 everythreadroute andautomation()answer 409, and the CLI exits 1. The message is also misleading: the script is running in Cloudroom.last_errorlive on the website (private). Core only forwardsroom-cli loop ...asapi.post("automation", ...)withsession, 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.
thread.rsidentify). 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 nearestCLOUDROOM_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 thatautomation.rsalready checks for IDs (nowvalid_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.linux.rs). I pulled the/proc/PID/environparsing out of the workload scan (CLOUDROOM_WORKLOAD) and the desktop check (CLOUDROOM_DESKTOP) into onevalues()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.loop ...andthread spawn --top-levelrequests carry"automation": "<id>"instead of"session"(Caller::scope). Core never builds a scope itself. The website decides.room-cli loop ...androom-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 spawnwithout--top-level, including--shared-sandbox: "A loop's check script has no thread to start a child under. Add --top-level ..."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 listis 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 atloop listis the honest choice.loopandspawnthe message now also mentions loop scripts.room-cli loop --helptext (automation.rsUSAGE): in a script,loop(exceptreport) andthread spawn --top-levelact for its loop, andlistshows each loop's last script output. Increate-loop/SKILL.md, "--top-levelin 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 secretkeeps 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).
CLOUDROOM_LOOP=<the loop's automation id>on the agent-account process. Set it after sudo, becauseenv_resetdrops 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.POST /api/core/automationandPOST /api/core/launchthen carry"automation": "<id>"in place of"session". Exactly one of the two is present. Every other field is unchanged.launchkeeps itsrequest_ididempotency, keyed on the loop instead of the session.automationonly 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, andhooksee the loops a thread in that project sees.launchstarts 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.reportwithautomation. Core already does, but the website should too. Refuse acreatethat would re-prompt "this thread", since a script has none:new_thread: false, or a one-time--at/--inwithout--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.loop list. For each loop, addlast_outputnext tolast_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.postreturns the website's JSON as-is, androom-cli loop listprints it verbatim (automation.rs#L336-L337). Never put a top-levelerrorfield in a successful answer: the CLI treats that as a failure (mac/mod.rs#L773).cloudroomaccount, it has no ptrace rights. So it can't read an agent process's/proc/PID/environ, and the read fails withEACCES./proc/PID/statstays readable, so harness detection still works. This covers two setups in this repo: the managed setup ininstall/configure.py(separate accounts, systemd, andCLOUDROOM_API_URL, soroom-cli loopworks for threads there), andsandbox-start.shon 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'sloopandspawn --top-levelcalls 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 forroom-clito send its ownCLOUDROOM_LOOPwith 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 noCAP_SYS_PTRACE.Evidence, from this sandbox. A uid-65534 process with ambient
cap_dac_overridemodels protected Core's file-permission bypass; this container's bounding set has nodac_read_search. It getsPermission denied(errno 13) on an agent process'senviron, while that process'sstatstays readable. Root withoutsys_ptracefails the same way.Tests. The tests came first, as their own commit (
07904a18), against bd894ee's Rust code. They live incore/tests/core_fixture.pyLoopScriptTests, which CI already runs. Each test runs the real Rust service with its agent socket, next to a running fake-Codex thread, and usesfake_api.py.fake_api.pynow recordslaunchcalls too. A "script" request comes fromenv CLOUDROOM_LOOP=ID sh -c 'env -i <request>', so the marker is only on the parent shell, as it will be under sudo.test_a_loop_script_runs_loop_commands_and_top_level_spawns_as_its_looploop listandspawn --top-level. The website gets exactly{"action":"list","automation":ID}, andlaunchgetsautomationand nosession.409 {'error': 'Run cloudroom automation from a Cloudroom cloud thread'}(the live repro)test_a_process_without_a_loop_marker_is_still_refusedloop listandspawn --top-levelare still refused, and nothing reaches the website.test_a_malformed_loop_marker_is_refusedCLOUDROOM_LOOP, and nothing is forwarded.test_session_only_commands_refuse_a_loop_script_clearlythread list,update --title,archive --self,fork,tell,feedback, a child spawn, a--shared-sandboxspawn, andloop reportfrom a script get the specific refusals, and nothing is forwarded.Run cloudroom thread list from a Cloudroom cloud threadtest_a_harness_session_wins_over_a_loop_marker{"action":"list","session":"cr_seed"}.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. Makingenvironment_hiddenalways return false fails it.Results on
8abe8a40.cargo fmt --checkandcargo clippy --locked --all-targets -- -D warningspass.cargo test --locked --all-targets --no-fail-fast: everything passes, including the new unit test, exceptharness_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_harnesspasses 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.
thread listis refused rather than returning an empty list (reasons above).loop create/update/deletefrom a script go through to the website, which decides what a loop may manage (contract point 3). Core only blocksreport.spawn --top-levelcan 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.