fix(security): close a daemon-socket sandbox escape and a codewhale web proof leak before 0.10.2

Found by a pre-release audit of the local network surfaces (Claude
workflow wf_d93a93bb-48d: three auditors plus adversarial verification
of every finding; a Gemini 4 Argon read-only review through agy found
nothing exploitable by default). Founder, 2026-10-18: pause account work
and hold the paid product until security review is stronger; this keeps
the local release to the same bar. Two unrefuted High findings and one
Medium are fixed here; the rest are listed in 0.10.2's Known limits.

1. Sandbox escape through the Runtime daemon socket (High).
   Sandboxed commands (test scripts, postinstall hooks) could read the
   owner receipt and connect to the daemon socket that `codewhale serve`,
   the desktop app, the VS Code extension or ACP holds. The socket checks
   only the peer uid, and an attached Control peer reaches the full
   dispatcher, including app/config/set (sandbox_mode, approval_policy,
   providers.*.base_url) and prompt/run. On Linux, bubblewrap's
   --unshare-all does not stop a path-based AF_UNIX connect. On macOS,
   Seatbelt with network on allows it.
   - read_guard.rs: the daemon's default run directories, as this process
     resolves them (CODEWHALE_HOME/run, $XDG_RUNTIME_DIR/codewhale,
     ~/Library/Application Support/codewhale, ~/.codewhale/run; an invalid
     CODEWHALE_HOME falls back to the other layouts), join the default
     sandbox deny-list. bubblewrap
     masks an existing denied directory with an empty tmpfs, so the
     socket and receipt are not there inside the sandbox.
   - seatbelt.rs: a file-read deny does not stop connect(2). Checked with
     sandbox-exec: with (allow network-outbound), the read deny alone
     still connected, and adding (deny network-outbound (remote
     unix-socket (subpath …))) refused it. With network on, every denied
     path now gets that rule.
   Live check (macOS, debug TUI, mock provider, CODEWHALE_SANDBOX_NETWORK_ACCESS=1,
   a listener at <CODEWHALE_HOME>/run/t.sock; release-0102-refreeze-20261018/):
   before "SOCKET-CONNECTED b'hi'", after "PermissionError: [Errno 1]".
   Linux bubblewrap was not run end to end here; it relies on the existing
   mask path proven for #7007.

2. `codewhale web` proof leak (High).
   GET / wrote the live request proof into a meta tag for any request
   carrying the session cookie and Sec-Fetch-Site: same-origin|none. The
   cookie is host-scoped, not port-scoped, so a listener on another
   127.0.0.1 port can receive it, and a non-browser client can claim any
   Sec-Fetch-Site. Cookie plus proof opens every /v1 route (threads with
   auto_approve, approvals, terminal input, config).
   - web.rs: the page is static; the proof is never put in HTML.
     index.html drops the meta tag; app.mjs drops pageProof and keeps the
     proof in sessionStorage, so a reload still works. The existing
     source assertion that app.mjs never uses localStorage stays.
   - Behaviour change: a new tab opened by typing the address is not
     signed in. docs/WEB.md and docs/RUNTIME_API.md say so and say what
     to do.
   Live check (curl against `codewhale web`, harness HOME, bootstrap
   redeemed, then GET / with the cookie and Sec-Fetch-Site: none):
   before "PROOF RE-ISSUED", after "no proof in page". Headless Chrome
   over CDP on the fixed build: bootstrap ready with the proof in
   sessionStorage and nothing in localStorage, reload ready, and a typed
   new tab shows "not authenticated".

3. Web approval card spoofing (Medium).
   When the model had written anything, the card showed only
   `intent_summary` (the model's own text) and never the input. It now
   shows the deterministic `summary`; then `description`, the runtime's
   reason for asking (sandbox escalation, auto-review hold, repo law,
   Computer Use consent), when it differs; then every argument in a <pre>:
   the command first, then all other fields as JSON (sandbox_permissions,
   justification, stdin, cwd, task_id ...), not truncated; and the model's
   text last, labelled "The model says:". Headless Chrome with the mock
   provider:
   - phase3: summary "Run `echo later-01`", request "echo later-01"
   - phase10 (bash with sandbox_permissions=danger-full-access): reason
     "Sandbox escalation to 'danger-full-access' for this exact call: needs
     the whole disk", request shows the command and the escalation fields

Tests changed for the new behaviour:
- web.rs runtime_surface_review_web_page_recovers_proof_for_reload_and_second_tab
  becomes ..._web_page_never_carries_the_request_proof.
- runtime_api/tests.rs: the reload loop asserts the proof is absent.
- runtime_web_client.test.mjs: the independent-tab test becomes "web
  session uses only its fragment or this tab's stored proof".

Checks:
- cargo test -p codewhale-tui --lib -- runtime_api:: runtime_surface
  sandbox:: read_guard seatbelt bwrap provider_lake: 603 passed; 0 failed
- cargo test -p codewhale-commands: 28 passed; 0 failed
- node --test crates/tui/tests/runtime_web_client.test.mjs: 43/43; vitest
  runtime_web app.test.mjs: 29/29
- after the review fixes: cargo test -p codewhale-tui --lib -- sandbox::
  read_guard seatbelt bwrap runtime_surface runtime_api::web: 114 passed;
  0 failed; clippy (tui, CI flags) clean; web tests 43/43 and 29/29
- cargo clippy -p codewhale-tui -p codewhale-commands --all-targets
  --all-features --locked with CI's flags: clean; cargo fmt --check: clean
- npm test: 101/101, 19/19, 413 passed + 7 skipped of 420, 7/7, vitest
  772/772; web prebuild + check-facts + check:docs OK; sync-changelog
  --check up to date

Pre-push review (Claude workflow wf_626dd2b1-3e0, three lenses; Gemini 4
Argon via agy on the diff) found no bypass of the default setup and
these, all addressed before push:
- The card hid the runtime's `description` (a regression for sandbox
  escalations) and dropped every input field but `command`. Fixed as in 3.
- An invalid (relative) CODEWHALE_HOME made runtime_daemon_socket_dirs
  return nothing. Fixed with the fallback above.
- docs/zh_hans/RUNTIME_API.md still described the meta-tag recovery, and
  zh_hans/WEB.md lacked the new-tab note. Both updated.
- The changelog overstated coverage. It now says the default deny-list
  must stay on, and the gaps are listed under Known limits.
Unverified and not acted on: a claimed window after a TUI workspace switch
before the respawned engine installs the deny-list (recorded in the
release packet).

Known limits, listed in 0.10.2: no Host/Origin check under --insecure and
--insecure-no-auth; the bootstrap nonce passes through the browser
launcher's argv; the opt-in control socket has no peer-uid check; OAuth
loopback callbacks can be ended by a malformed request; the daemon-socket
deny covers only default locations as this process sees them, not a
directory created after a long-running command started, and not a macOS
workspace at or above the daemon directory (the directory can be moved).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-18 17:34:30 -07:00
1 parent 7146817746
commit 0d347f5566
15 files changed
+253 -122

No files matched your search

+52 -7
View File
@@ -11,16 +11,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Codewhale v0.10.2 adds an animated pet view (`/pet on`), a live Terminal work
dock and per-chat composer colors (`/color`). It closes three session
data-loss paths, holds a `curl` or `wget` that uploads a credential, refuses
shell commands containing invisible Unicode, keeps the Linux sandbox working when a
hidden credential directory is a symlink, and uses much less CPU while the
TUI waits on you. `/undo` takes back a whole request, `/diff` works outside a
Git repository, DeepSeek's Responses route no longer rejects replayed tool
history, and contributor fixes improve token accounting, relay model lists,
localized replies and Windows launcher guidance.
data-loss paths, a sandbox escape through the Runtime daemon socket and a
`codewhale web` session leak; holds a `curl` or `wget` that uploads a
credential; refuses shell commands containing invisible Unicode; keeps the
Linux sandbox working when a hidden credential directory is a symlink; and
uses much less CPU while the TUI waits on you. `/undo` takes back a whole
request, `/diff` works outside a Git repository, DeepSeek's Responses route no
longer rejects replayed tool history, and contributor fixes improve token
accounting, relay model lists, localized replies and Windows launcher
guidance.
### Latest fixes
- Sandboxed commands can no longer reach Codewhale's Runtime daemon socket.
On Linux with bubblewrap, and on macOS with sandbox network access on, a
command the agent ran (a test script or a postinstall hook, say) could read
the owner receipt and attach to the socket that `codewhale serve`, the
desktop app, the VS Code extension or an ACP session holds. From there it
could change the sandbox mode, the approval policy or a provider's base URL
in your config, or start an unsandboxed turn. The daemon's default run
directories are now on the sandbox's default deny-list, and on macOS no
socket under a denied path can be connected to. Turning the default
deny-list off (`sandbox_read_denylist_defaults = false`) also turns this
off.
- `codewhale web` no longer gives its request proof to whoever holds the
session cookie. The cookie is scoped to the host, not the port, so a
listener on another `127.0.0.1` port could receive it, and `GET /` then
returned the proof that unlocks the whole Runtime API. The proof now stays
in the tab that opened the session, which keeps working across reloads
when the browser allows session storage; a new tab opened by typing the
address needs a fresh `codewhale web`.
- The `codewhale web` approval card shows the request summary, Codewhale's
reason for asking (such as a sandbox escalation) and every argument of the
call in full, with the model's own description below, labeled as such.
Before, when the model had written anything, the card showed only that
text and never the command, so a prompt-injected model could describe a
harmful command as a harmless one.
- `/settings` choice lists that don't fit, such as Language and Theme, show
a scroll rail. Thanks @Lstarsky0 (#7024).
- During an `/mcp` search that finds nothing, the status line says "No
@@ -1057,6 +1083,25 @@ localized replies and Windows launcher guidance.
### Known limits
- From a pre-release security review of the local network surfaces, not yet
fixed: `codewhale serve --insecure` and `codewhale app-server
--insecure-no-auth` check neither the `Host` nor the `Origin` header, so
with either flag a website can reach the Runtime API through DNS
rebinding; use them only on a machine you trust. The one-time
`codewhale web` and `--mobile` bootstrap capability passes through the
browser launcher's arguments, where another local OS user can read it. The
opt-in control socket (`[control_socket] enabled = true`) does not check
the connecting user and can be reached from sandboxed commands. OAuth
loopback callbacks end a sign-in on the first malformed request, so a page
or local process can make one fail.
- The sandbox deny-list for the Runtime daemon socket covers its default
locations as this process sees them. It does not cover a daemon started
with `--socket-path` or with a different `XDG_RUNTIME_DIR` or
`CODEWHALE_HOME`; a run directory created after a long-running sandboxed
command started is not hidden from that command; and on macOS, when the
workspace is your home directory or another folder above the daemon's
directory, a sandboxed command can still move that directory out of the
denied path.
- Ambiguous current-quota 429s without a body retry delay of at most 120 s
still end the turn; a header alone does not change that classification.
Explicit exhausted account, billing and subscription limits remain terminal
+52 -7
View File
@@ -11,16 +11,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Codewhale v0.10.2 adds an animated pet view (`/pet on`), a live Terminal work
dock and per-chat composer colors (`/color`). It closes three session
data-loss paths, holds a `curl` or `wget` that uploads a credential, refuses
shell commands containing invisible Unicode, keeps the Linux sandbox working when a
hidden credential directory is a symlink, and uses much less CPU while the
TUI waits on you. `/undo` takes back a whole request, `/diff` works outside a
Git repository, DeepSeek's Responses route no longer rejects replayed tool
history, and contributor fixes improve token accounting, relay model lists,
localized replies and Windows launcher guidance.
data-loss paths, a sandbox escape through the Runtime daemon socket and a
`codewhale web` session leak; holds a `curl` or `wget` that uploads a
credential; refuses shell commands containing invisible Unicode; keeps the
Linux sandbox working when a hidden credential directory is a symlink; and
uses much less CPU while the TUI waits on you. `/undo` takes back a whole
request, `/diff` works outside a Git repository, DeepSeek's Responses route no
longer rejects replayed tool history, and contributor fixes improve token
accounting, relay model lists, localized replies and Windows launcher
guidance.
### Latest fixes
- Sandboxed commands can no longer reach Codewhale's Runtime daemon socket.
On Linux with bubblewrap, and on macOS with sandbox network access on, a
command the agent ran (a test script or a postinstall hook, say) could read
the owner receipt and attach to the socket that `codewhale serve`, the
desktop app, the VS Code extension or an ACP session holds. From there it
could change the sandbox mode, the approval policy or a provider's base URL
in your config, or start an unsandboxed turn. The daemon's default run
directories are now on the sandbox's default deny-list, and on macOS no
socket under a denied path can be connected to. Turning the default
deny-list off (`sandbox_read_denylist_defaults = false`) also turns this
off.
- `codewhale web` no longer gives its request proof to whoever holds the
session cookie. The cookie is scoped to the host, not the port, so a
listener on another `127.0.0.1` port could receive it, and `GET /` then
returned the proof that unlocks the whole Runtime API. The proof now stays
in the tab that opened the session, which keeps working across reloads
when the browser allows session storage; a new tab opened by typing the
address needs a fresh `codewhale web`.
- The `codewhale web` approval card shows the request summary, Codewhale's
reason for asking (such as a sandbox escalation) and every argument of the
call in full, with the model's own description below, labeled as such.
Before, when the model had written anything, the card showed only that
text and never the command, so a prompt-injected model could describe a
harmful command as a harmless one.
- `/settings` choice lists that don't fit, such as Language and Theme, show
a scroll rail. Thanks @Lstarsky0 (#7024).
- During an `/mcp` search that finds nothing, the status line says "No
@@ -1057,6 +1083,25 @@ localized replies and Windows launcher guidance.
### Known limits
- From a pre-release security review of the local network surfaces, not yet
fixed: `codewhale serve --insecure` and `codewhale app-server
--insecure-no-auth` check neither the `Host` nor the `Origin` header, so
with either flag a website can reach the Runtime API through DNS
rebinding; use them only on a machine you trust. The one-time
`codewhale web` and `--mobile` bootstrap capability passes through the
browser launcher's arguments, where another local OS user can read it. The
opt-in control socket (`[control_socket] enabled = true`) does not check
the connecting user and can be reached from sandboxed commands. OAuth
loopback callbacks end a sign-in on the first malformed request, so a page
or local process can make one fail.
- The sandbox deny-list for the Runtime daemon socket covers its default
locations as this process sees them. It does not cover a daemon started
with `--socket-path` or with a different `XDG_RUNTIME_DIR` or
`CODEWHALE_HOME`; a run directory created after a long-running sandboxed
command started is not hidden from that command; and on macOS, when the
workspace is your home directory or another folder above the daemon's
directory, a sandboxed command can still move that directory out of the
denied path.
- Ambiguous current-quota 429s without a body retry delay of at most 120 s
still end the turn; a header alone does not change that classification.
Explicit exhausted account, billing and subscription limits remain terminal
+1 -3
View File
@@ -2124,9 +2124,7 @@ async fn web_bootstrap_sets_strict_cookie_once_and_preserves_v1_auth() -> Result
.send()
.await?;
assert_eq!(reloaded.headers()[header::CACHE_CONTROL], "no-store");
assert!(reloaded.text().await?.contains(&format!(
"name=\"codewhale-web-request\" content=\"{proof}\""
)));
assert!(!reloaded.text().await?.contains(&proof));
}
for method in [reqwest::Method::GET, reqwest::Method::POST] {
for metadata in [false, true] {
+23 -76
View File
@@ -8,7 +8,7 @@ use std::time::{Duration, Instant};
use axum::Json;
use axum::body::Body;
use axum::extract::{ConnectInfo, Path, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use uuid::Uuid;
@@ -249,35 +249,15 @@ pub(super) async fn refresh_stream_ticket(
response
}
pub(super) async fn web_page(State(state): State<RuntimeApiState>, headers: HeaderMap) -> Response {
let Some(web) = state.web.as_ref() else {
pub(super) async fn web_page(State(state): State<RuntimeApiState>) -> Response {
if state.web.is_none() {
return not_found();
};
web_page_response(web, &headers)
}
web_page_response()
}
fn web_page_response(web: &RuntimeWebState, headers: &HeaderMap) -> Response {
let mut html = WEB_HTML.to_owned();
// Recover the origin-scoped proof for reloads and independent tabs. A
// cookie alone is insufficient: require same-origin or direct navigation
// Fetch Metadata. Older clients without it still use the bootstrap proof.
// Proofs are generated hex strings, so no HTML escaping is needed here.
if headers
.get("sec-fetch-site")
.is_some_and(|site| site == "same-origin" || site == "none")
&& web.matches_session_cookie(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()))
&& let Some(proof) = web
.request_proof
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_deref()
{
html = html.replace(
"name=\"codewhale-web-request\" content=\"\"",
&format!("name=\"codewhale-web-request\" content=\"{proof}\""),
);
}
secured_asset("text/html; charset=utf-8", html)
fn web_page_response() -> Response {
secured_asset("text/html; charset=utf-8", WEB_HTML.to_owned())
}
pub(super) async fn web_styles(State(state): State<RuntimeApiState>) -> Response {
@@ -390,57 +370,24 @@ mod tests {
}
#[tokio::test]
async fn runtime_surface_review_web_page_recovers_proof_for_reload_and_second_tab() {
async fn runtime_surface_review_web_page_never_carries_the_request_proof() {
let (web, nonce) = RuntimeWebState::new();
let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
let cookie = web_session_cookie(&token);
for site in [
None,
Some("same-origin"),
Some("none"),
Some("same-site"),
Some("cross-site"),
] {
for valid_cookie in [false, true] {
for expired in [false, true] {
let mut session = web.clone();
if expired {
session.session_expires_at = Instant::now();
}
let mut headers = HeaderMap::new();
if valid_cookie {
headers.insert(header::COOKIE, cookie.parse().unwrap());
}
if let Some(site) = site {
headers.insert("sec-fetch-site", site.parse().unwrap());
}
let response = web_page_response(&session, &headers);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::CONTENT_SECURITY_POLICY]
.to_str()
.unwrap()
.contains("frame-ancestors 'none'")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = std::str::from_utf8(&body).unwrap();
let recovered = html.contains(&format!(
"name=\"codewhale-web-request\" content=\"{proof}\""
));
assert_eq!(
recovered,
valid_cookie && !expired && matches!(site, Some("same-origin" | "none")),
"site={site:?}, valid_cookie={valid_cookie}, expired={expired}"
);
assert!(!html.contains(&token));
if recovered {
assert!(session.matches_request(Some(&cookie), Some(&proof)));
}
}
}
}
let response = web_page_response();
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::CONTENT_SECURITY_POLICY]
.to_str()
.unwrap()
.contains("frame-ancestors 'none'")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = std::str::from_utf8(&body).unwrap();
assert!(!html.contains(&proof));
assert!(!html.contains(&token));
assert!(!html.contains("codewhale-web-request"));
assert_eq!(
web.consume(&nonce, "127.0.0.1".parse().unwrap()),
Err(BootstrapError::Invalid)
+25 -4
View File
@@ -495,11 +495,25 @@ export function eventStreamUrl(threadId, latestSeq, ticket = "") {
return ticket ? `${url}&web_stream_ticket=${encodeURIComponent(ticket)}` : url;
}
export function createWebSessionFetch({ location, history, storage, fetch, pageProof = "" }) {
export function approvalRequestText(input) {
if (input == null) return "";
if (typeof input === "string") return input;
if (typeof input !== "object") return String(input);
const { command, ...rest } = input;
const parts = [];
if (typeof command === "string") parts.push(command);
const restKeys = Object.keys(rest);
if (restKeys.length > 0 || typeof command !== "string") {
parts.push(JSON.stringify(typeof command === "string" ? rest : input, null, 2));
}
return parts.join("\n\n");
}
export function createWebSessionFetch({ location, history, storage, fetch }) {
const key = "codewhale_web_request_proof";
const fragmentProof = new URLSearchParams(location.hash.slice(1)).get("p");
if (fragmentProof) history.replaceState(null, "", location.pathname + location.search);
let proof = pageProof || fragmentProof || "";
let proof = fragmentProof || "";
if (proof) {
try { storage().setItem(key, proof); } catch (_) { /* Memory-only session. */ }
} else {
@@ -1081,7 +1095,6 @@ function startBrowserClient() {
location: window.location,
history: window.history,
storage: () => window.sessionStorage,
pageProof: document.querySelector('meta[name="codewhale-web-request"]')?.content,
fetch: (...args) => fetch(...args),
});
@@ -1743,7 +1756,15 @@ function startBrowserClient() {
const title = element("h2", "", approval.tool_name || "Tool request");
title.id = titleId;
card.append(title);
card.append(element("p", "", approval.intent_summary || approval.description || "Codewhale is waiting for permission."));
card.append(element("p", "", approval.summary || approval.description || "Codewhale is waiting for permission."));
if (approval.summary && approval.description && approval.description !== approval.summary) {
card.append(element("p", "approval-reason", approval.description));
}
const request = approvalRequestText(approval.input);
if (request) card.append(element("pre", "approval-request", request));
if (approval.intent_summary) {
card.append(element("p", "approval-claim", `The model says: ${approval.intent_summary}`));
}
const actions = element("div", "attention-actions");
const rememberLabel = element("label", "remember-field");
const remember = document.createElement("input");
-1
View File
@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="codewhale-web-request" content="">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#020711">
<title>Codewhale</title>
+22
View File
@@ -882,6 +882,28 @@ textarea::placeholder {
font-size: 15px;
}
.attention-card .approval-request {
margin: 0 0 12px;
padding: 8px 10px;
max-height: 240px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
border: 1px solid var(--line, rgba(127, 127, 127, 0.3));
border-radius: 6px;
}
.attention-card .approval-reason {
font-weight: 600;
}
.attention-card .approval-claim {
opacity: 0.75;
font-style: italic;
}
.attention-card p {
margin: 0;
color: var(--text-soft);
+43
View File
@@ -434,6 +434,7 @@ fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
crate::oauth::auth_file_path(),
"Codex CLI login (resolved auth file)",
));
out.extend(runtime_daemon_socket_dirs());
let Some(home) = dirs::home_dir() else {
return out;
};
@@ -573,6 +574,48 @@ fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
out
}
fn runtime_daemon_socket_dirs() -> Vec<(PathBuf, &'static str)> {
use codewhale_app_server::daemon_socket::{SocketPathInputs, resolve_socket_path};
let live = SocketPathInputs::from_environment(None).unwrap_or_else(|_| SocketPathInputs {
xdg_runtime_dir: std::env::var_os("XDG_RUNTIME_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from),
user_home: codewhale_paths::user_home(),
macos: cfg!(target_os = "macos"),
..SocketPathInputs::default()
});
let layouts = [
live.clone(),
SocketPathInputs {
codewhale_home_override: None,
..live.clone()
},
SocketPathInputs {
codewhale_home_override: None,
xdg_runtime_dir: None,
..live.clone()
},
SocketPathInputs {
codewhale_home_override: None,
xdg_runtime_dir: None,
macos: false,
..live
},
];
let mut dirs: Vec<PathBuf> = Vec::new();
for layout in &layouts {
if let Ok(socket) = resolve_socket_path(layout)
&& let Some(dir) = socket.parent()
&& !dirs.iter().any(|seen| seen == dir)
{
dirs.push(dir.to_path_buf());
}
}
dirs.into_iter()
.map(|dir| (dir, "Codewhale runtime daemon socket and owner receipt"))
.collect()
}
fn machine_wide_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
let mut out: Vec<(PathBuf, &'static str)> = vec![
(
+7
View File
@@ -263,6 +263,8 @@ fn generate_policy(
// last-match-wins, so these rules override every broad read allowance
// above — including the full-disk `(allow file-read*)` — for the listed
// subpaths. Metadata reads are denied too so the paths do not enumerate.
// A read deny does not stop connect(2) to a socket under the path, so with
// network on, unix-socket connects under each path are denied as well.
if !denied_read_subpaths.is_empty() {
full_policy.push_str("\n\n; Opt-in read deny-list (user-configured)\n");
for path in denied_read_subpaths {
@@ -271,6 +273,11 @@ fn generate_policy(
.replace('\\', "\\\\")
.replace('"', "\\\"");
full_policy.push_str(&format!("(deny file-read* (subpath \"{escaped}\"))\n"));
if policy.has_network_access() {
full_policy.push_str(&format!(
"(deny network-outbound (remote unix-socket (subpath \"{escaped}\")))\n"
));
}
}
}
+4 -7
View File
@@ -1094,8 +1094,8 @@ test("web session fetch keeps request proof outside cookies and strips bootstrap
assert.equal(calls.at(-1).options.headers.get("x-codewhale-web-request"), "memory-proof");
});
test("web session recovers from the page in an independent tab or without storage", async () => {
for (const storedProof of [null, "old-process-proof", "unavailable"]) {
test("web session uses only its fragment or this tab's stored proof", async () => {
for (const storedProof of [null, "this-tab-proof", "unavailable"]) {
const stored = new Map([["codewhale_web_request_proof", storedProof]]);
const calls = [];
const request = createWebSessionFetch({
@@ -1105,17 +1105,14 @@ test("web session recovers from the page in an independent tab or without storag
if (storedProof === "unavailable") throw new Error("storage unavailable");
return { getItem: (key) => stored.get(key), setItem: (key, value) => stored.set(key, value) };
},
pageProof: "current-page-proof",
fetch: async (_path, options) => { calls.push(options); return { ok: true }; },
});
await request("/v1/threads");
await request("/__codewhale/web/stream-ticket", { method: "POST" });
assert.equal(calls.length, 2);
const expected = storedProof === "this-tab-proof" ? "this-tab-proof" : "";
for (const options of calls) {
assert.equal(options.headers.get("x-codewhale-web-request"), "current-page-proof");
}
if (storedProof !== "unavailable") {
assert.equal(stored.get("codewhale_web_request_proof"), "current-page-proof");
assert.equal(options.headers.get("x-codewhale-web-request"), expected);
}
}
});
+9 -7
View File
@@ -908,13 +908,15 @@ attacker has strictly easier local avenues than this race.
Web fetches require the session cookie plus an origin-scoped request proof;
streams use a fresh single-use ticket. The initial redirect carries the proof
in a fragment, which the client removes and saves in origin-scoped
`sessionStorage`. On reload or in a second tab, an authenticated `GET /` also
embeds the proof in a meta tag when `Sec-Fetch-Site` is `same-origin` or `none`
(direct navigation). The page uses `no-store`, disallows framing, and grants no
cross-origin read access. This lets a new tab recover without reusing the
bootstrap URL, including when storage is unavailable. Clients without Fetch
Metadata can only reuse the fragment or their existing stored proof; recovery
does not extend the server session or replace an expired cookie.
`sessionStorage`, so a reload of that tab keeps working when the browser
allows session storage. The server never puts
the proof in a page: the session cookie is host-scoped, not port-scoped, so a
listener on another loopback port can receive it, and the cookie alone must
not be enough to recover the proof. A new tab opened by typing the address
therefore has no proof; use the original tab, duplicate it, or restart
`codewhale web` for a fresh one-time session. The page uses `no-store`,
disallows framing, and grants no cross-origin read access. Nothing extends the
server session or replaces an expired cookie.
Cross-origin Fetch Metadata or a mismatched Origin is rejected on web API
requests. Explicit bearer and Runtime-token header clients keep their existing
behavior. Transient stream-ticket failures retry with capped backoff; HTTP
+3
View File
@@ -80,6 +80,9 @@ before operating either one, especially before selecting a non-loopback bind.
credentials.
- If a session expired, stop and restart `codewhale web` to mint a new
process-local session. Reusing an old bootstrap URL is expected to fail.
- A new tab opened by typing the address is not signed in: the session's
request proof stays in the tab that opened it. Use that tab, duplicate it,
or restart `codewhale web`.
For integration endpoints, headers, events, and the complete web-session
contract, see [RUNTIME_API.md](RUNTIME_API.md).
+6 -6
View File
@@ -774,12 +774,12 @@ codewhale serve --mobile [...] [--insecure]
攻击者有严格比这个竞争更简单的本地途径。
Web 请求需要会话 cookie 加上一个限定于源(origin)的请求证明;
流使用一张新的一次性票据。初始重定向把证明放在 fragment 中,客户端将其移除并
保存到限定于源的 `sessionStorage`。重新加载或在第二个标签页中打开时,
当 `Sec-Fetch-Site` 为 `same-origin` 或 `none`(直接导航)时,
经过认证的 `GET /` 还会把证明嵌入一个 meta 标签。该页面使用 `no-store`、
禁止被嵌入框架,也不授予任何跨源读取权限。这让新标签页无需复用 bootstrap URL
即可恢复,包括存储不可用的情况。不支持 Fetch Metadata 的客户端只能复用 fragment
或它已存储的证明;恢复不会延长服务器会话,也不会替换已过期的 cookie。
保存到限定于源的 `sessionStorage`;只要浏览器允许会话存储,重新加载该标签页
仍可继续使用。服务器从不把证明写进页面:会话 cookie 只限定主机而不限定端口,
另一个回环端口上的监听者可能收到它,因此仅凭 cookie 不能取回证明。通过手动输入
地址打开的新标签页没有证明;请使用原来的标签页、复制该标签页,或重新启动
`codewhale web` 获取新的一次性会话。该页面使用 `no-store`、禁止被嵌入框架,
也不授予任何跨源读取权限。任何操作都不会延长服务器会话,也不会替换已过期的 cookie。
跨源的 Fetch Metadata 或不匹配的 Origin 会在 web API 请求上被拒绝。显式的
bearer 与 Runtime 令牌 header 客户端保持其既有行为。暂时性的流票据失败会以
有上限的退避重试;HTTP 401/403 会停止票据重试,直到打开新的会话。
+2
View File
@@ -72,5 +72,7 @@ Runtime token 不会出现在渲染后的 HTML、浏览器存储、URL 查询参
web 命令不会配置或迁移提供商的凭据。
- 如果会话已过期,请停止并重新启动 `codewhale web`,以创建一个仅对新进程有效的会话。
重复使用旧的引导 URL 应当失败。
- 通过手动输入地址打开的新标签页不会处于登录状态:会话的请求证明只保存在打开它的
标签页中。请使用那个标签页、复制它,或重新启动 `codewhale web`。
有关集成端点、请求头、事件和完整的网页会话约定,请参阅 [RUNTIME_API.md](../RUNTIME_API.md)。
+4 -4
View File
@@ -288,8 +288,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "4fef71a41b64ef8bf38479d8f7bda40bec369608a8033f990fcf73856889f40b",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. New integrations should target `app-server`.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) |\n| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`); background jobs are `GET /v1/jobs` on the Runtime API; `/prompt` and `/thread` messages execute real turns via the runtime bridge. There is no direct `/tool` route: tools run only inside Engine turns, under the Engine's tool catalog and approval posture. This legacy server does not surface approvals: its bridge forwards only text deltas and the turn's completion, and it has no decision route, so an approval-gated call waits unanswered. Drive approval-gated work through the Runtime API (`/v1/threads/*` events and `POST /v1/approvals/{approval_id}`) |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nThe legacy in-process `codewhale app-server` also requires an explicit\n`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback\nhost; its generated one-time `cwapp_*` token is loopback-only.\n\nDevice tokens minted by the master through `POST /v1/auth/client-tokens`\nhave an immutable `intent`: `Line truncated
"sha256": "60965eebaa7200290f8cb306be99f4cd0b8edd7a6382c6a72becc3ed23975b5f",
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. New integrations should target `app-server`.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) |\n| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`); background jobs are `GET /v1/jobs` on the Runtime API; `/prompt` and `/thread` messages execute real turns via the runtime bridge. There is no direct `/tool` route: tools run only inside Engine turns, under the Engine's tool catalog and approval posture. This legacy server does not surface approvals: its bridge forwards only text deltas and the turn's completion, and it has no decision route, so an approval-gated call waits unanswered. Drive approval-gated work through the Runtime API (`/v1/threads/*` events and `POST /v1/approvals/{approval_id}`) |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nThe legacy in-process `codewhale app-server` also requires an explicit\n`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback\nhost; its generated one-time `cwapp_*` token is loopback-only.\n\nDevice tokens minted by the master through `POST /v1/auth/client-tokens`\nhave an immutable `intent`: `Line truncated
}
]
},
@@ -299,8 +299,8 @@
"sources": [
{
"path": "docs/WEB.md",
"sha256": "6235bee74db77a00625539fc5e3a243b6cb2ed4fe7788b3337c03c3c3eb8037b",
"content": "# Local browser client\n\n> 阅读简体中文版:[zh_hans/WEB.md](zh_hans/WEB.md)。\n\n`codewhale web` opens Codewhale's embedded browser client over the canonical\nRuntime API. It is a local surface: the server always binds to\n`127.0.0.1`, cannot be rebound to a LAN address, and cannot run with Runtime\nauthentication disabled.\n\n## Start it\n\nFrom the workspace Codewhale should operate in, run:\n\n```bash\ncodewhale web\n```\n\nThe default address is `http://127.0.0.1:7878`. To avoid a local port\ncollision, choose another loopback port:\n\n```bash\ncodewhale web --port 8788\n```\n\nCodewhale starts the Runtime API, serves the dependency-free client embedded\nin the installed binary, prints a single-use launch URL, and asks the operating\nsystem to open that URL in the default browser. If the browser does not open,\nuse the printed URL within ten minutes. Stop the process with `Ctrl+C`; the\nbrowser session ends with it.\n\n## What the browser can do\n\nThe embedded client provides a responsive thread and search rail, Runtime-owned\nsession facts, transcript and tool receipts, and a composer. It can create,\nselect, rename, and archive threads; start or steer turns; interrupt work;\nresolve approvals; and answer Runtime user-input requests.\n\nThe browser is another view of the same local Runtime. It does not create a\nsecond cloud account, copy provider credentials into browser storage, or\nweaken the configured approval and sandbox policies.\n\n## Authentication boundary\n\nThe browser-launch URL contains a random, short-lived, one-time bootstrap\ncapability. It never contains the Runtime bearer token. A loopback request\nexchanges the capability for an `HttpOnly`, `SameSite=Strict`, process-local\nsession cookie and immediately invalidates the capability.\n\nReused, expired, malformed, and non-loopback bootstrap attempts fail closed.\nThe Runtime token is not placed in rendered HTML, browser storage, URL\nqueries or fragments, or browser-launch arguments. The one-time bootstrap\nvalue is printed in the local terminal and briefly passes through the operating\nsystem's browser launcher. It is single-use and expires after ten minutes, but\na hostile process already running as the same OS user remains inside the local\ntrust boundary.\n\nCookie-authenticated state-changing requests must also present the exact\nlocal web origin. Cross-origin browser requests are rejected. Existing\nexplicit bearer and Runtime-token-header clients retain their normal Runtime\nAPI behavior.\n\n## Local means local\n\n`codewhale web` accepts only `--port`; there is no `--host` or insecure-auth\noption on this command. Do not treat it as a public website or expose its port\ndirectly through router forwarding, a public reverse proxy, or a tunnel.\n\nThe separate `codewhale app-server --mobile` and `--http` modes have different\ndeployment and authentication contracts. Read [RUNTIME_API.md](RUNTIME_API.md)\nbefore operating either one, especially before selecting a non-loopback bind.\n\n## Troubleshooting\n\n- If port `7878` is occupied, pass an unused `--port` value.\n- If the browser does not open, copy the printed single-use bootstrap URL into\n a browser on the same machine within ten minutes. Start `codewhale web` again\n if that URL has already been used or expired.\n- If the page loads but a provider is unavailable, inspect `codewhale doctor`\n and `/provider`; the web command does not configure or move provider\n credentials.\n- If a session expired, stop and restart `codewhale web` to mint a new\n process-local session. Reusing an old bootstrap URL is expected to fail.\n\nFor integration endpoints, headers, events, and the complete web-session\ncontract, see [RUNTIME_API.md](RUNTIME_API.md).\n"
"sha256": "e38dbc33c3f768fa9ea208a2c6231445406fe8f6421c56c961c21afa7382901e",
"content": "# Local browser client\n\n> 阅读简体中文版:[zh_hans/WEB.md](zh_hans/WEB.md)。\n\n`codewhale web` opens Codewhale's embedded browser client over the canonical\nRuntime API. It is a local surface: the server always binds to\n`127.0.0.1`, cannot be rebound to a LAN address, and cannot run with Runtime\nauthentication disabled.\n\n## Start it\n\nFrom the workspace Codewhale should operate in, run:\n\n```bash\ncodewhale web\n```\n\nThe default address is `http://127.0.0.1:7878`. To avoid a local port\ncollision, choose another loopback port:\n\n```bash\ncodewhale web --port 8788\n```\n\nCodewhale starts the Runtime API, serves the dependency-free client embedded\nin the installed binary, prints a single-use launch URL, and asks the operating\nsystem to open that URL in the default browser. If the browser does not open,\nuse the printed URL within ten minutes. Stop the process with `Ctrl+C`; the\nbrowser session ends with it.\n\n## What the browser can do\n\nThe embedded client provides a responsive thread and search rail, Runtime-owned\nsession facts, transcript and tool receipts, and a composer. It can create,\nselect, rename, and archive threads; start or steer turns; interrupt work;\nresolve approvals; and answer Runtime user-input requests.\n\nThe browser is another view of the same local Runtime. It does not create a\nsecond cloud account, copy provider credentials into browser storage, or\nweaken the configured approval and sandbox policies.\n\n## Authentication boundary\n\nThe browser-launch URL contains a random, short-lived, one-time bootstrap\ncapability. It never contains the Runtime bearer token. A loopback request\nexchanges the capability for an `HttpOnly`, `SameSite=Strict`, process-local\nsession cookie and immediately invalidates the capability.\n\nReused, expired, malformed, and non-loopback bootstrap attempts fail closed.\nThe Runtime token is not placed in rendered HTML, browser storage, URL\nqueries or fragments, or browser-launch arguments. The one-time bootstrap\nvalue is printed in the local terminal and briefly passes through the operating\nsystem's browser launcher. It is single-use and expires after ten minutes, but\na hostile process already running as the same OS user remains inside the local\ntrust boundary.\n\nCookie-authenticated state-changing requests must also present the exact\nlocal web origin. Cross-origin browser requests are rejected. Existing\nexplicit bearer and Runtime-token-header clients retain their normal Runtime\nAPI behavior.\n\n## Local means local\n\n`codewhale web` accepts only `--port`; there is no `--host` or insecure-auth\noption on this command. Do not treat it as a public website or expose its port\ndirectly through router forwarding, a public reverse proxy, or a tunnel.\n\nThe separate `codewhale app-server --mobile` and `--http` modes have different\ndeployment and authentication contracts. Read [RUNTIME_API.md](RUNTIME_API.md)\nbefore operating either one, especially before selecting a non-loopback bind.\n\n## Troubleshooting\n\n- If port `7878` is occupied, pass an unused `--port` value.\n- If the browser does not open, copy the printed single-use bootstrap URL into\n a browser on the same machine within ten minutes. Start `codewhale web` again\n if that URL has already been used or expired.\n- If the page loads but a provider is unavailable, inspect `codewhale doctor`\n and `/provider`; the web command does not configure or move provider\n credentials.\n- If a session expired, stop and restart `codewhale web` to mint a new\n process-local session. Reusing an old bootstrap URL is expected to fail.\n- A new tab opened by typing the address is not signed in: the session's\n request proof stays in the tab that opened it. Use that tab, duplicate it,\n or restart `codewhale web`.\n\nFor integration endpoints, headers, events, and the complete web-session\ncontract, see [RUNTIME_API.md](RUNTIME_API.md).\n"
}
]
},