diff --git a/CHANGELOG.md b/CHANGELOG.md index 476364e..cf2ebf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ is `0`, minor versions may carry breaking changes — they are called out here. ## [Unreleased] +### Security +- **Exec children no longer inherit the runtime's credentials.** `sh -c` children of + the `exec-*` drivers and the agent CLI got `VEJAS_APPROVAL_TOKEN`, `VEJAS_TOKEN`, + `VAULT_TOKEN` and every `VEJAS_SECRET_*`. They now get what the manifest declares + in `ENV`, so a recipe that read one of these by inheritance has to declare it. An + exec connector stays trusted code: this is hygiene, not a sandbox. + +### Fixed +- **The panel can write when `VEJAS_TOKEN` is set.** Only Approve and Reject worked + there: every other write got a 401, with no way to give the token. The panel now + asks once and keeps it in the page's memory. +- `http-in` writes its response in one segment instead of one per piece of the + format string. +- `bench/flow-only.sh` and `bench/detect.sh` no longer leave the runtime and NATS + running: under `set -e`, the status of the first killed process ended the trap. + +### Changed +- `scripts/deploy-docs.sh` publishes the book from a commit, and `RELEASING.md` gains + the step: the book at vejas.dev/docs had not moved since 2026-08-24. A release now + pushes master first and tags once CI is green. Site and book backups stay outside + the served directory, where a stale page was reachable. + ## [0.5.0] — 2026-11-07 The runtime leaves the deprecated sync NATS client. If your NATS user has a publish diff --git a/RELEASING.md b/RELEASING.md index c82db9d..f2e732d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,8 +11,9 @@ Versions follow SemVer; the git tag `vX.Y.Z` is the release trigger. 3. Commit: `release: vX.Y.Z`. 4. Tag and push: ```bash + git push origin master # wait for ci and audit, then: git tag -a vX.Y.Z -m "vX.Y.Z" - git push origin master --follow-tags + git push origin vX.Y.Z ``` Pushing the tag runs `.github/workflows/release.yml`, which: @@ -22,6 +23,18 @@ Pushing the tag runs `.github/workflows/release.yml`, which: `ghcr.io/cpoder/vejas-runtime:vX.Y.Z` and `:latest`; - creates the GitHub Release with the CHANGELOG section as its notes. +5. Publish what readers see. Nothing does this for you: + ```bash + scripts/deploy-docs.sh vX.Y.Z # the book at vejas.dev/docs, built from the tag + scripts/deploy-site.sh # vejas.dev, when site/ changed + ``` + `pages.yml` carries `site/` to GitHub Pages only. vejas.dev is a static + directory on a box shared with production, so these two stay manual. Before this + step existed the book stayed on 2026-08-24 through nine releases. + +Master goes first because a tag publishes at once: a red `ci` on a released commit +can only be fixed forward. + ## Version discipline (ADR-0030) The public HTTP API, `docs/CONTROL.md` and `docs/SUBJECTS.md` are a diff --git a/bench/compare/README.md b/bench/compare/README.md index b551ff6..5beb697 100644 --- a/bench/compare/README.md +++ b/bench/compare/README.md @@ -20,8 +20,8 @@ bench/compare/run-n8n.sh 20 32 # n8n (official image, tune | e2e latency p50 | 859 ms | 14 ms sustained / **2 ms** uncongested | 1 ms | 2 350 ms | | Flow-hop rate (no HTTP) | 171/s | **8 110/s** (9 948/s over 10 flows) | — | — | | Cold start | 15 ms | **11–13 ms** | 391 ms | 7–17 s (container) | -| RSS under load | **6–8 MB** | **6–8 MB** (49 MB at 50 flows) | 202 MB | 1.2–1.3 GB | -| Distribution size | 3.9 MB binary | **6.2 MB binary / 201 MB image** | 338 MB binary | 372 MB image | +| RSS under load | **6–8 MB** | **10 MB** (38 MB at 50 flows) | 202 MB | 1.2–1.3 GB | +| Distribution size | 3.9 MB binary | **11.2 MB binary / 118 MB image** | 338 MB binary | 372 MB image | | Persistence | every hop (JetStream) | **every hop (JetStream)** | in-flight only | per-execution DB (disabled for this run) | **n8n fairness notes.** Single instance from the official image, tuned per diff --git a/bench/detect.sh b/bench/detect.sh index 8f28d46..bb62ea4 100755 --- a/bench/detect.sh +++ b/bench/detect.sh @@ -41,8 +41,8 @@ STORE=$(mktemp -d); ROOT=$(mktemp -d) cleanup() { # wait for them, do not merely signal them: a run that leaves its port held # makes the NEXT run fail silently, which is how a matrix grows holes. - [ -n "${RUNTIME_PID:-}" ] && { kill "$RUNTIME_PID" 2>/dev/null; wait "$RUNTIME_PID" 2>/dev/null; } - [ -n "${NATS_PID:-}" ] && { kill "$NATS_PID" 2>/dev/null; wait "$NATS_PID" 2>/dev/null; } + [ -n "${RUNTIME_PID:-}" ] && { kill "$RUNTIME_PID" 2>/dev/null || true; wait "$RUNTIME_PID" 2>/dev/null || true; } + [ -n "${NATS_PID:-}" ] && { kill "$NATS_PID" 2>/dev/null || true; wait "$NATS_PID" 2>/dev/null || true; } rm -rf "$STORE" "$ROOT" } trap cleanup EXIT diff --git a/bench/flow-only.sh b/bench/flow-only.sh index 3ea0dc7..2300e99 100755 --- a/bench/flow-only.sh +++ b/bench/flow-only.sh @@ -16,7 +16,9 @@ STORE=$(mktemp -d) ROOT=$(mktemp -d) cleanup() { for pid in "${SUB_PID:-}" "${RUNTIME_PID:-}" "${NATS_PID:-}"; do - [ -n "$pid" ] && { kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; } + # `|| true`: under set -e the status of a killed process would end the trap + # at the first pid and leave the runtime and NATS running. + [ -n "$pid" ] && { kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; } done rm -rf "$STORE" "$ROOT" } diff --git a/core/src/connectors.rs b/core/src/connectors.rs index 42c6551..8e4aa79 100644 --- a/core/src/connectors.rs +++ b/core/src/connectors.rs @@ -272,6 +272,33 @@ fn suffix_allowed(suffix: &str, allow: &[String]) -> bool { .any(|e| suffix == e || suffix.starts_with(&format!("{e}."))) } +/// Is this variable a credential of the runtime itself? +fn is_runtime_credential(name: &str) -> bool { + matches!(name, "VEJAS_TOKEN" | "VEJAS_APPROVAL_TOKEN" | "VAULT_TOKEN") + || name.starts_with("VEJAS_SECRET_") +} + +/// Keep the runtime's own credentials out of a child's environment. A child gets +/// what its manifest declares in `ENV`, and an agent CLI what it needs, not the +/// approval token or every secret by inheritance, where an `env` in a log or an +/// agent reading its environment sends them on. This is hygiene and not a +/// sandbox: a child runs as the same user and reaches the same bus. +pub fn without_runtime_credentials(cmd: &mut std::process::Command) { + for (name, _) in std::env::vars_os() { + if name.to_str().is_some_and(is_runtime_credential) { + cmd.env_remove(name); + } + } +} + +/// `sh -c ` for an exec driver. +fn shell(cmd: &str) -> std::process::Command { + let mut child = std::process::Command::new("sh"); + child.arg("-c").arg(cmd); + without_runtime_credentials(&mut child); + child +} + /// One place in a bounded pool of per-connection or per-request threads, given /// back when the thread ends, a panic included. pub struct Slot(Arc); @@ -398,11 +425,13 @@ fn handle_http_in( } } }; - let _ = write!( - sock, + // One buffer, one write: with TCP_NODELAY `write!` puts each piece of the + // format string in a segment of its own. + let response = format!( "HTTP/1.1 {code}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{json}", json.len() ); + let _ = sock.write_all(response.as_bytes()); if eof { break; // client sent its last request, then closed } @@ -1576,9 +1605,7 @@ impl Driver for ExecSource { waited += 250; if waited >= interval * 1000 { waited = 0; - let out = std::process::Command::new("sh") - .arg("-c") - .arg(&cmd) + let out = shell(&cmd) .output() .map_err(|e| e.to_string())?; if !out.status.success() { @@ -1617,9 +1644,7 @@ impl Driver for ExecSink { let name = ctx.name.clone(); let env = ctx.config.env_vars(); run_sink(ctx, &subject, move |data| { - let mut child = std::process::Command::new("sh") - .arg("-c") - .arg(&cmd) + let mut child = shell(&cmd) .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -1654,7 +1679,7 @@ impl Driver for ExecStreamSource { } fn run(&self, ctx: &Ctx) -> Result<(), String> { use std::io::{BufRead, BufReader}; - use std::process::{Command, Stdio}; + use std::process::Stdio; use std::sync::mpsc::{sync_channel, RecvTimeoutError}; let cmd = ctx.config.str("CMD").ok_or("CMD required")?; @@ -1688,9 +1713,7 @@ impl Driver for ExecStreamSource { eprintln!("[{}] resuming at OFFSET={start}", ctx.name); run_env.push(("OFFSET".to_string(), start)); } - let mut child = Command::new("sh") - .arg("-c") - .arg(&cmd) + let mut child = shell(&cmd) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) // child status/errors flow to our logs @@ -2012,7 +2035,7 @@ impl Driver for ExecRpc { } fn run(&self, ctx: &Ctx) -> Result<(), String> { use std::io::{BufRead, BufReader, Write}; - use std::process::{Command, Stdio}; + use std::process::Stdio; let cmd = ctx.config.str("CMD").ok_or("CMD required")?; let subject = ctx @@ -2028,9 +2051,7 @@ impl Driver for ExecRpc { let env = ctx.config.env_vars(); let nc = bus::connect(&ctx.nats_url)?; - let mut child = Command::new("sh") - .arg("-c") - .arg(&cmd) + let mut child = shell(&cmd) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -2084,12 +2105,10 @@ impl Driver for ExecRpc { } fn probe(&self, ctx: &Ctx) -> Result { use std::io::{BufRead, BufReader, Write}; - use std::process::{Command, Stdio}; + use std::process::Stdio; let cmd = ctx.config.str("CMD").ok_or("CMD required")?; let env = ctx.config.env_vars(); - let mut child = Command::new("sh") - .arg("-c") - .arg(&cmd) + let mut child = shell(&cmd) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -2240,6 +2259,18 @@ fn next_url(base: &str, body: &serde_json::Value, field: &str) -> Option #[cfg(test)] mod tests { + #[test] + fn a_child_inherits_no_runtime_credential() { + std::env::set_var("VEJAS_APPROVAL_TOKEN", "the-approval-token"); + std::env::set_var("VEJAS_SECRET_DEMO_KEY", "a-secret"); + std::env::set_var("VEJAS_UNRELATED", "kept"); + let out = shell("env").env("DECLARED", "from-the-manifest").output().unwrap(); + let env = String::from_utf8_lossy(&out.stdout); + assert!(!env.contains("the-approval-token") && !env.contains("a-secret"), "{env}"); + assert!(env.contains("VEJAS_UNRELATED=kept"), "{env}"); + assert!(env.contains("DECLARED=from-the-manifest"), "{env}"); + } + fn letter(pending: &mut HashMap, unit: &str) { let a = pending.entry(unit.to_string()).or_default(); a.count += 1; diff --git a/core/src/main.rs b/core/src/main.rs index 96770bd..333cd48 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -2619,7 +2619,9 @@ const NO_AGENT_CLI: &str = "this deployment has no agent CLI (VEJAS_AGENT_CMD): /// Ask the agent CLI for a file and return its content (markdown fences stripped). fn ask_agent(prompt: String) -> Result { let agent = env::var("VEJAS_AGENT_CMD").unwrap_or_else(|_| "claude".into()); - let out = Command::new(agent) + let mut command = Command::new(agent); + connectors::without_runtime_credentials(&mut command); + let out = command .arg("-p") .arg(prompt) .output() diff --git a/core/src/panel.html b/core/src/panel.html index 020e17a..e91471d 100644 --- a/core/src/panel.html +++ b/core/src/panel.html @@ -275,9 +275,19 @@

Secrets

if (s === 'true') return true; if (s === 'false') return false; if (s !== '' && !isNaN(Number(s))) return Number(s); return s; }; +// With VEJAS_TOKEN set every write wants it. The panel asks once and keeps it in +// this page's memory, never in storage: a reload asks again. +let writeToken = ''; +const send = (url, payload, headers = {}) => fetch(url, { method: 'POST', + headers: {'Content-Type': 'application/json', + ...(writeToken ? {Authorization: `Bearer ${writeToken}`} : {}), ...headers}, + body: JSON.stringify(payload) }); async function post(url, payload) { - const res = await fetch(url, { method: 'POST', - headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); + let res = await send(url, payload); + if (res.status === 401 && !writeToken) { + writeToken = (prompt('Write token (VEJAS_TOKEN)') || '').trim(); + if (writeToken) res = await send(url, payload); + } if (!res.ok) throw new Error(await res.text()); return res; } @@ -905,19 +915,17 @@

Secrets

setInterval(loadEvents, 4000); // In governed mode a replay or a discard is a human act (ADR-0024): the server -// answers 409 or 401 and the approval token goes with the retry. +// answers 409 or 401 and the approval token goes with the retry. Outside it the +// same prompt takes the write token, so the one value is sent as both. async function dlqAction(path, body) { - const send = tok => fetch(path, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...(tok ? { 'X-Approval-Token': tok } : {}) }, - body: JSON.stringify(body) }); try { let tok = ($('#approval-token').value || '').trim(); - let res = await send(tok); + let res = await send(path, body, tok ? {'X-Approval-Token': tok} : {}); if ((res.status === 409 || res.status === 401) && !tok) { - tok = (prompt('Approval token (governed mode)') || '').trim(); + tok = (prompt('Approval token in governed mode, otherwise the write token') || '').trim(); if (!tok) return; - $('#approval-token').value = tok; - res = await send(tok); + res = await send(path, body, {'X-Approval-Token': tok, Authorization: `Bearer ${tok}`}); + if (res.ok) $('#approval-token').value = tok; } if (!res.ok) throw new Error(await res.text()); toast('Done — reloaded'); diff --git a/docs/adr/0015-dead-letter-queue.md b/docs/adr/0015-dead-letter-queue.md index 197fdf7..15e31ae 100644 --- a/docs/adr/0015-dead-letter-queue.md +++ b/docs/adr/0015-dead-letter-queue.md @@ -93,8 +93,8 @@ untrusted content and this event is what wakes things up. One event per unit per `VEJAS_DLQ_EVENT_SECS` (default 60), with `count` for the letters it stands for, so that a burst wakes a reader once and loses nothing. A letter whose subject is the event subject is not announced, so a failing reader cannot feed itself. An -agent started from an `exec-*` connector would inherit the runtime's environment, -so the documented route out is `http-out`. +agent started from an `exec-*` connector would run as the runtime's user, on the +same bus, so the documented route out is `http-out`. ## Alternatives considered diff --git a/docs/adr/0024-proposal-queue.md b/docs/adr/0024-proposal-queue.md index 040e902..15064ad 100644 --- a/docs/adr/0024-proposal-queue.md +++ b/docs/adr/0024-proposal-queue.md @@ -110,10 +110,10 @@ purge destroys what the agent's work is judged on, and dead letters are untrusted content that agent reads. No proposal kind carries them, so in governed mode they sit on `VEJAS_APPROVAL_TOKEN`: refused over MCP, and `POST /dlq/replay|purge` want the `X-Approval-Token` header. On those routes -and on approve/reject, a valid approval token also answers the write gate. The -panel holds that token and never `VEJAS_TOKEN`, so with both set, the documented -production posture, the human could not approve from the panel (401 from the -write gate) until this change. +and on approve/reject, a valid approval token also answers the write gate. A +human approves with that token and not with `VEJAS_TOKEN`, so with both set, the +documented production posture, approving from the panel failed with a 401 from +the write gate until this change. ## The loop this enables (the acte-6 recipe) diff --git a/docs/book/src/concepts/agents-mcp.md b/docs/book/src/concepts/agents-mcp.md index ed8e82d..dec7a5c 100644 --- a/docs/book/src/concepts/agents-mcp.md +++ b/docs/book/src/concepts/agents-mcp.md @@ -3,7 +3,7 @@ There is no builder UI to click together — the flow-writing interface *is* an agent, and the runtime *is* the MCP server (`POST /mcp`, ADR-0006). No sidecar, no plugin: the platform's own write path, test path and telemetry -are the [29 MCP tools](../reference/mcp-tools.md). +are the [MCP tools](../reference/mcp-tools.md). The loop an agent follows (enforced by the tool descriptions themselves — the generation contract): diff --git a/docs/book/src/getting-started/panel.md b/docs/book/src/getting-started/panel.md index 8262303..6669b93 100644 --- a/docs/book/src/getting-started/panel.md +++ b/docs/book/src/getting-started/panel.md @@ -14,7 +14,8 @@ turns them into a flow developer. transcoding tables, queue names — is editable in place. Saving goes through the same audited write path agents use (`/surface/set`), and the unit restarts itself. Credential-shaped values are masked (single-sourced - pattern, [ADR-0017](../decisions.md)). + pattern, [ADR-0017](../decisions.md)). With `VEJAS_TOKEN` set, the panel asks + for it on the first write and keeps it in the page's memory only. - **Read the rules.** The rules view projects each flow's decision logic as faithful sentences — whole-sentence or verbatim code, never a half translation ([the rules view](../guides/rules-view.md)). diff --git a/docs/book/src/guides/dlq-replay.md b/docs/book/src/guides/dlq-replay.md index 2c88f7b..b6e7ea3 100644 --- a/docs/book/src/guides/dlq-replay.md +++ b/docs/book/src/guides/dlq-replay.md @@ -47,8 +47,8 @@ happens next is a flow or a sink like any other: [`dlq_webhook.vjs.example`](https://github.com/cpoder/vejas/blob/master/docs/examples/dlq_webhook.vjs.example) posts the event to a URL: a chat channel, or a runner that starts your agent with the agent's token alone. Do not start an agent from an `exec-*` connector: -a child inherits the runtime's environment, `VEJAS_APPROVAL_TOKEN` and -`VEJAS_SECRET_*` included. +the runtime's credentials are out of a child's environment, but it runs as the +runtime's user and reaches the same bus, far more than a proposal needs. ## The loop that makes it self-healing diff --git a/docs/book/src/guides/governed-mode.md b/docs/book/src/guides/governed-mode.md index a573cac..46bdacf 100644 --- a/docs/book/src/guides/governed-mode.md +++ b/docs/book/src/guides/governed-mode.md @@ -43,8 +43,8 @@ the `X-Approval-Token` header. The panel asks for the token when it needs it. lists the queue. 2. **Human decides** — in the panel's **Approval queue** card, or `POST /proposals/{id}/approve|reject` with the `X-Approval-Token` - header, which is enough on its own even when `VEJAS_TOKEN` is set: the - panel never holds the agent's token. The card shows the evidence; a proposal with none is flagged + header, which is enough on its own even when `VEJAS_TOKEN` is set. The card + shows the evidence; a proposal with none is flagged **"⚠ No evidence"** loud — approving blind is a deliberate act. 3. **On approve** — the change executes exactly as a normal promote would (hot-reload, or a [cluster-wide version](change-safely.md) in 60 ms) and diff --git a/docs/examples/dlq_webhook.vjs.example b/docs/examples/dlq_webhook.vjs.example index 0dde399..7f8c28e 100644 --- a/docs/examples/dlq_webhook.vjs.example +++ b/docs/examples/dlq_webhook.vjs.example @@ -5,7 +5,7 @@ # # The URL is a chat webhook, or a runner of your own that starts your agent with # the agent's VEJAS_TOKEN alone. Keep the agent out of the runtime: a child of an -# exec-* connector inherits its environment, VEJAS_APPROVAL_TOKEN included. +# exec-* connector runs as its user and reaches the same bus. driver "http-out" SUBJECT = "vx.dlq.events" URL = secret("ops/dlq_webhook_url") diff --git a/docs/launch/distribution-kit.md b/docs/launch/distribution-kit.md index 06e9018..035c92a 100644 --- a/docs/launch/distribution-kit.md +++ b/docs/launch/distribution-kit.md @@ -49,7 +49,7 @@ Title alternates (kept for reference): > records the change with its actor). > > One Rust binary on NATS/JetStream, no other dependency. Measured, reproducible in -> bench/: cold start 11–13ms, 6–8MB RSS, end-to-end p50 2ms uncongested, every hop +> bench/: cold start 11–13ms, 10MB RSS, end-to-end p50 2ms uncongested, every hop > persisted, cluster promote 60ms lossless. Twenty-one connector recipes admitted > by CI (MQTT/RabbitMQ against real brokers; IBM MQ, Kafka). > diff --git a/docs/security-audit-scope.md b/docs/security-audit-scope.md index 9e0442f..baf2c6d 100644 --- a/docs/security-audit-scope.md +++ b/docs/security-audit-scope.md @@ -268,6 +268,14 @@ re-deriving these — they are fixed and regression-tested; instead, try to drop the connection. `e2e/emit-subject.sh` in CI. **Break it:** a subject that reaches the bus from outside without passing `valid_subject`. +- **H — exec children inherited the runtime's credentials (MEDIUM, fixed).** + `sh -c` children and the agent CLI inherited `VEJAS_APPROVAL_TOKEN`, + `VEJAS_TOKEN`, `VAULT_TOKEN` and every `VEJAS_SECRET_*`, for an `env` in a log or + an agent reading its environment to send on. They are now removed, and a child + gets what its manifest declares in `ENV`. This is hygiene and not a sandbox: an + exec connector is trusted code, it runs as the same user, can read + `/proc//environ`, and reaches the same bus. `e2e/governed-agent.sh` in CI. + ## Known minor points to confirm or harden (not blockers) - **M1 — poller URL in logs.** `http-poll`/`oauth-poll` log the full poll @@ -288,6 +296,11 @@ re-deriving these — they are fixed and regression-tested; instead, try to `root` (and unscanned, so inert), so this is not a traversal — a path-segment match would read cleaner. Hardening, not a bug. +- **M4 — `tiny_http`'s own pool is unbounded.** `VEJAS_HTTP_MAX_INFLIGHT` bounds the + requests the runtime handles, not the connections the crate accepts: it holds a + thread per connection and panics when the OS has none. The port is local by + default, and the failure is loud: `/healthz` stops answering. + ## Method note The internal pass used **static data-flow tracing** (no heavy live diff --git a/e2e/governed-agent.sh b/e2e/governed-agent.sh index f425737..d4d76a4 100755 --- a/e2e/governed-agent.sh +++ b/e2e/governed-agent.sh @@ -34,6 +34,9 @@ api_key = secret("demo/key") emit "vx.sdemo.out", {id: id, auth: f"Bearer {api_key}"} VJS echo '{"id": 1}' > "$R/flows/fixtures/sdemo.json" +mkdir -p "$R/connectors" +printf '# connector: exec_env\ndriver "exec-sink"\nSUBJECT = "vx.execenv"\nENV = {"DECLARED": secret("demo/key")}\nCMD = "cat > /dev/null; env > %s/child.env"\n' "$S" \ + > "$R/connectors/exec_env.vjs" nats-server -js -sd "$S/n" -a 127.0.0.1 -p 4278 > /dev/null 2>&1 & NP=$! sleep 0.5 @@ -143,6 +146,14 @@ done) echo "$AUDIT" | grep -q '"dlq-replay"' && ok "the replay is audited" || ko "no audit record for the replay" echo "$AUDIT" | grep -q '"dlq-purge"' && ok "the purge is audited" || ko "no audit record for the purge" +echo "── an exec child gets what its manifest declares, not the runtime's credentials" +nats -s $URL pub vx.execenv '{}' > /dev/null 2>&1 +for _ in $(seq 1 50); do [ -s "$S/child.env" ] && break; sleep 0.1; done +[ -s "$S/child.env" ] || ko "the exec child never ran" +grep -q "^DECLARED=$SECRET" "$S/child.env" && ok "a declared secret reaches it" || ko "the declared secret is gone" +LEAKS=$(grep -cE "^VEJAS_(TOKEN|APPROVAL_TOKEN|SECRET_)|$HUMAN|$AGENT" "$S/child.env") +[ "$LEAKS" = "0" ] && ok "no runtime credential by inheritance" || ko "$LEAKS credential line(s) inherited" + echo "── the human can decide from the panel with both tokens set" C=$(code -X POST -H "X-Approval-Token: $HUMAN" "$H/proposals/nope/approve") [ "$C" = "422" ] && ok "approve passes the write gate on the approval token alone ($C: no such proposal)" \ diff --git a/scripts/deploy-docs.sh b/scripts/deploy-docs.sh new file mode 100755 index 0000000..7d653d1 --- /dev/null +++ b/scripts/deploy-docs.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Publish the book to vejas.dev/docs, from a commit. +# scripts/deploy-docs.sh [ref] (default HEAD; a release passes its tag) +# +# GitHub Pages carries site/ only and the book has no CI deployment on purpose: +# that would put an SSH key for a box shared with production into repository +# secrets, for a documentation page. So this is a release step (RELEASING.md). +# +# The book is built from `git archive`, never from the working tree: uncommitted +# pages must not go live. It lands next to the live directory, and the two swap in +# one move, so a reader never sees a half-copied book. The previous one stays in +# $BACKUPS, outside $DEST, because everything under $DEST is served publicly. +set -euo pipefail + +BOX="${VEJAS_SITE_BOX:-cpo@95.216.191.129}" +DEST="${VEJAS_SITE_DEST:-/home/cpo/vejas-site}" +BACKUPS="${VEJAS_SITE_BACKUPS:-/home/cpo/vejas-site-backups}" +REF="${1:-HEAD}" +HERE="$(cd "$(dirname "$0")/.." && pwd)" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +git -C "$HERE" archive "$REF" | tar -x -C "$WORK" +mdbook build "$WORK/docs/book" + +STAMP=$(date -u +%Y%m%dT%H%M%S) +NEW="$BACKUPS/docs.new-$STAMP" +echo "[deploy-docs] $REF → $BOX:$DEST/docs" +tar -C "$WORK/docs/book/book" -czf - . | ssh -o BatchMode=yes "$BOX" \ + "set -e; mkdir -p '$NEW'; tar -xzf - -C '$NEW'; chmod -R a+rX '$NEW'" +ssh -o BatchMode=yes "$BOX" "set -e; mv '$DEST/docs' '$BACKUPS/docs-$STAMP'; mv '$NEW' '$DEST/docs'" +curl -fsS -m 15 -o /dev/null https://vejas.dev/docs/ +echo "[deploy-docs] done — previous book in $BOX:$BACKUPS/docs-$STAMP" diff --git a/scripts/deploy-site.sh b/scripts/deploy-site.sh index 94e6845..ccffdc2 100755 --- a/scripts/deploy-site.sh +++ b/scripts/deploy-site.sh @@ -4,16 +4,18 @@ # vejas.dev is a Caddy `file_server` over a static directory on the box # (a `caddy:2-alpine` container, bind-mounted from $DEST). There is NO CI for # it — this script is the reproducible path, so a landing change is one command, -# not an ad-hoc scp. The mdBook under $DEST/docs is built + deployed separately -# and is deliberately left untouched here (no --delete). +# not an ad-hoc scp. The mdBook under $DEST/docs has its own script, +# scripts/deploy-docs.sh, and is deliberately left untouched here (no --delete). set -euo pipefail BOX="${VEJAS_SITE_BOX:-cpo@95.216.191.129}" DEST="${VEJAS_SITE_DEST:-/home/cpo/vejas-site}" +BACKUPS="${VEJAS_SITE_BACKUPS:-/home/cpo/vejas-site-backups}" HERE="$(cd "$(dirname "$0")/.." && pwd)" echo "[deploy-site] $HERE/site → $BOX:$DEST" -ssh -o BatchMode=yes "$BOX" "mkdir -p '$DEST/demo'" +# Outside $DEST: everything under it is served publicly, a backup included. +ssh -o BatchMode=yes "$BOX" "mkdir -p '$DEST/demo' '$BACKUPS' && cp '$DEST/index.html' '$BACKUPS/index.html.bak-$(date -u +%Y%m%dT%H%M%S)'" scp "$HERE/site/index.html" "$BOX:$DEST/index.html" if [ -d "$HERE/site/demo" ] && [ -n "$(ls -A "$HERE/site/demo" 2>/dev/null)" ]; then scp "$HERE"/site/demo/* "$BOX:$DEST/demo/"