937 lines
46 KiB
Rust
937 lines
46 KiB
Rust
//! The Self-run actuator: one `claude` child carries her one open Self run.
|
|
//!
|
|
//! `carry` finds her run (the one in-force Self run operated by the mind, read
|
|
//! through `launch::holders`: the run is the grant), starts `claude` with the
|
|
//! fixed argument list, writes the start message and watches the child's
|
|
//! stream-json output. It counts the main thread's requests, asks for the flush
|
|
//! once when the context prefix reaches the ceiling (ruling
|
|
//! `eureka-body:ruling:self-lifecycle-flush-rehydrate`: a Self run admits what
|
|
//! the mind lacks and ends, it never summarises), and stops a child that outlives its flush: an interrupt
|
|
//! after the grace, or once the requests of the flush's own turn have grown the
|
|
//! prefix by the headroom beyond the prefix the flush was asked at (ruling
|
|
//! `eureka-body:ruling:flush-always-gets-a-turn`), then SIGTERM and SIGKILL if
|
|
//! the child ignores that. Signals go to the child's process group.
|
|
//!
|
|
//! One run is one live session (ruling `eureka-body:ruling:live-mind-sessions`).
|
|
//! A result ends a turn, not the session. What her Self is asked is derived from
|
|
//! her mind (the `inbox` module): an open follow-up with owner `self-run` admitted
|
|
//! by her Persona turn is a brief. `drive` is the one writer into her
|
|
//! session, and keeps one write outstanding (ruling
|
|
//! `eureka-body:ruling:self-session-one-outstanding-write`): the start message,
|
|
//! then one line at a time holding the briefs that wait and the flush ask, each
|
|
//! sent only after the previous line's echo, mid-turn or idle. The CLI echoes each
|
|
//! line it takes into a request (`--replay-user-messages`) and joins lines queued
|
|
//! behind one request into a single echo, so the echo is the consumption point
|
|
//! only while no second line is in flight. Her Self resolves each brief she
|
|
//! answers; the actuator closes none, and a brief she left open is told to her
|
|
//! next session. Compaction is off in
|
|
//! the CLI (`DISABLE_COMPACT=1`): the organ's own ceiling, flush, end and rehydrate
|
|
//! is the only path. The session ends where its cache would be evicted, near the
|
|
//! unit's cap, at the context ceiling, or after the flush each of those asks.
|
|
//!
|
|
//! Every timing decision is one pure function, `decide` (the `decide` module,
|
|
//! ruling `eureka-body:ruling:self-run-decider`). `carry` is the loop that turns
|
|
//! the operating system's facts into events and executes the actions.
|
|
//!
|
|
//! The child's credential is a lease, not a variable (rulings
|
|
//! `eureka-body:ruling:self-runs-through-connector` and
|
|
//! `eureka-body:ruling:self-run-credential-lease`): before it starts `claude`,
|
|
//! `carry` asks the connector for a lease (`lease_client`) until the unit runtime
|
|
//! cap, given to it in seconds (the connector binds the lease to this unit
|
|
//! invocation), and the granted descriptor is the only place the token reaches
|
|
//! the child. A refusal closes the run Recorded and starts nothing. Nothing is
|
|
//! reserved, billed or settled: the lease ends when `carry` drops it, and nothing
|
|
//! bounds a run in dollars or in usage; the bounds are `--max-turns`, the unit
|
|
//! `RuntimeMaxSec` and the context-ceiling flush. The grant carries the plan usage
|
|
//! windows, which go to the child in its start message.
|
|
//!
|
|
//! The actuator admits run closures and brief closures, nothing else. Every exit of
|
|
//! `carry` that leaves her run open resolves it Recorded exactly once (the
|
|
//! child's result in the rationale, or that it gave none in the reason). It reads neither the
|
|
//! brake nor the environment and stops no unit: the gate and the brake hold own those.
|
|
//! It never echoes an argument, an endpoint, a
|
|
//! connector's refusal text or a stream line. Stream-json is Claude Code's wire, a
|
|
//! foreign owner's JSON: it is parsed into `serde_json::Value` here and nothing
|
|
//! parsed is stored.
|
|
|
|
use std::collections::HashSet;
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::fmt;
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{ChildStdin, Command, Stdio};
|
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
|
use std::time::Duration as StdDuration;
|
|
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use chrono::{DateTime, Utc};
|
|
use cultnet_rs::Selection;
|
|
use eureka_pipeline::{PipelineDocument, PipelineRef, ResolutionOutcome, RunTurn, Slug};
|
|
use huginn_mind::{PipelineAdmissionOutcome, PipelinePageItems};
|
|
use serde_json::Value;
|
|
use uuid::Uuid;
|
|
|
|
use crate::launch::{Clock, closure, holders};
|
|
use crate::connector::render_plan;
|
|
use crate::lease_client::{LeaseRefusalKind, LeaseRequest, Lender, Lent, TokenFd};
|
|
use model_connector::PlanQuota;
|
|
use crate::queue::MindPort;
|
|
|
|
pub(crate) use decide::Why;
|
|
#[cfg(test)]
|
|
use decide::{State, decide};
|
|
#[cfg(test)]
|
|
use serde_json::json;
|
|
use inbox::Brief;
|
|
use session::{Polled, SessionInbox};
|
|
pub(crate) use session::drive;
|
|
|
|
/// The measured Self ceiling: at this prefix the flush is asked for.
|
|
pub const CEILING_TOKENS: u64 = 300_000;
|
|
/// The context window the CLI reports for the model a Self runs (`modelUsage.contextWindow` in every result of the
|
|
/// `claude-2.1.312-live-session-*` fixtures: the default model, `claude-opus-5-5`, 1M). The seed sets no model, so the CLI's default is the model.
|
|
/// The window under the real subscription is read from a result's `contextWindow` and not otherwise observed here.
|
|
const SELF_CONTEXT_WINDOW_TOKENS: u64 = 1_000_000;
|
|
/// What the CLI keeps free below its window before it refuses a prompt (`blocking_limit`): the blocking-limit fixture sends a 990k request and refuses the next.
|
|
const CLI_BLOCKING_BUFFER_TOKENS: u64 = 10_000;
|
|
/// What the prefix may grow by beyond the prefix the flush was asked at before
|
|
/// the actuator interrupts. Relative to the flush, never an absolute limit.
|
|
pub const FLUSH_HEADROOM_TOKENS: u64 = 60_000;
|
|
/// How long the child may take over the flush before it is interrupted.
|
|
pub const FLUSH_GRACE_S: i64 = 15 * 60;
|
|
/// How long a child may ignore a signal before the next, stronger one.
|
|
pub const ESCALATE_AFTER_S: i64 = 120;
|
|
/// How long a stream may stay open after the child has exited (a descendant
|
|
/// holding its stdout) before the run is over without it.
|
|
pub const EXIT_DRAIN_S: i64 = 2;
|
|
/// How long before the unit's cap the session is asked for its flush, idle or working.
|
|
pub const ROTATE_MARGIN_S: i64 = 25 * 60;
|
|
/// The signal ladder's length: three escalations, the stream drain and a minute of slack. The grace interrupt is never later than the cap less this.
|
|
pub const LADDER_S: i64 = 3 * ESCALATE_AFTER_S + EXIT_DRAIN_S + 60;
|
|
const _: () = assert!(FLUSH_GRACE_S + LADDER_S < ROTATE_MARGIN_S, "the flush and the whole ladder must finish before the unit's cap");
|
|
/// The ceiling, and the headroom its flush's own turn may grow by, sit below the CLI's blocking limit with 600k to spare at a 1M window; a window under
|
|
/// 400k has no room for the flush's turn, and the assertion refuses to compile until the ceiling is cut to fit the window.
|
|
const _: () = assert!(CEILING_TOKENS + FLUSH_HEADROOM_TOKENS + 40_000 < SELF_CONTEXT_WINDOW_TOKENS - CLI_BLOCKING_BUFFER_TOKENS, "the ceiling and its flush headroom must fit under the CLI's blocking limit");
|
|
/// How often the mind is asked for new briefs.
|
|
pub const INBOX_POLL_S: i64 = 30;
|
|
/// The prompt cache's lifetimes, and how long before one runs out the session ends.
|
|
pub const TTL_1H_S: i64 = 3600;
|
|
pub const TTL_5M_S: i64 = 300;
|
|
pub const TTL_MARGIN_S: i64 = 60;
|
|
/// A backstop `claude` enforces; the ceiling is reached first.
|
|
pub const MAX_TURNS: u32 = 500;
|
|
/// Names the variable the Self unit sets to its own `RuntimeMaxSec`, in seconds:
|
|
/// the one length of a run. The unit owns the number; the lease asked for and
|
|
/// the connector's `--lease-max-ms` follow it.
|
|
pub const RUN_CAP_VARIABLE: &str = "MIND_SELF_RUN_CAP_S";
|
|
/// Names the directory `claude` keeps its configuration, settings, hooks and skills in.
|
|
const CONFIG_DIR_VARIABLE: &str = "CLAUDE_CONFIG_DIR";
|
|
/// The unit's per-invocation scratch directory (systemd's `RuntimeDirectory=`,
|
|
/// removed when the unit stops): where each child's configuration is made.
|
|
pub const SCRATCH_VARIABLE: &str = "RUNTIME_DIRECTORY";
|
|
/// Optional: a directory root owns, copied into each child's fresh configuration
|
|
/// (its skills and settings). The child may change its copy; no run sees another's.
|
|
pub const SEED_VARIABLE: &str = "MIND_SELF_CONFIG_SEED";
|
|
/// Names the descriptor the child reads its credential from.
|
|
const TOKEN_FD_VARIABLE: &str = "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR";
|
|
/// Turns off the CLI's compaction, auto, idle and manual: the mind is the one summary.
|
|
const DISABLE_COMPACT_VARIABLE: &str = "DISABLE_COMPACT";
|
|
/// Tells `claude` to scrub credentials from the environment of what it starts.
|
|
const ENV_SCRUB_VARIABLE: &str = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB";
|
|
/// Optional, set by the unit: `off` turns that scrub off for a run whose user holds root, where it
|
|
/// protects nothing and its sandbox's no_new_privs would only stop `sudo`. Anything else keeps it on.
|
|
const SCRUB_VARIABLE: &str = "MIND_SELF_SCRUB";
|
|
/// The name the start message tells her to admit as.
|
|
pub const AGENT: &str = "mind-self-run";
|
|
/// The namespace of the session id: the run id is the name in it.
|
|
pub const SESSION_NAMESPACE: Uuid = Uuid::NAMESPACE_OID;
|
|
|
|
/// What the child is told at the start. The contract with the `eureka`
|
|
/// skill: it names the skill, the instance and the run id.
|
|
pub fn start_message(instance: &Slug, run: &PipelineRef, plan_quota: &[PlanQuota], now_ms: u64) -> String {
|
|
format!(
|
|
"Use the eureka skill. You are Self for the instance `{}`; your run is `{}`. Admit as faculty SelfFaculty, agent {AGENT}.\n\nThis session stays open after you answer: briefs from your Persona arrive while you work and between tasks. When you answer a brief, resolve follow-up <its id> Recorded with your answer as rationale; if you decline it, resolve it Withdrawn with why. Work you take on beyond an answer is a follow-up of your own.\n\n{}",
|
|
instance.0,
|
|
run.id.0,
|
|
render_plan(plan_quota, now_ms)
|
|
)
|
|
}
|
|
|
|
/// The task section of the start message: one section per claim, in claim
|
|
/// order. A claimed follow-up gives its id, campaign, repo and `item` verbatim,
|
|
/// because the brief lives in the claim and a run that cannot reach its mind
|
|
/// would never see its job; a claimed cut spec is named as the task, to be read
|
|
/// with `huginn view`; a claim the mind does not hold, or of another kind, is
|
|
/// named by id. The claims are bounded by their types, so nothing is cut. A mind
|
|
/// that cannot answer is an error: the run is not started on a guessed task.
|
|
fn task_section(mind: &dyn MindPort, claims: &[PipelineRef]) -> Result<String> {
|
|
let mut task = String::new();
|
|
for claim in claims {
|
|
let one = Selection { keys: Some(vec![claim.id.0.clone()]), projection: "document".into(), ..Selection::default() };
|
|
let page = mind.query(&one).with_context(|| format!("the claim `{}` could not be read from the mind", claim.id.0))?;
|
|
let PipelinePageItems::Documents(views) = page.items else { bail!("the mind answered headers to a document selection") };
|
|
let id = &claim.id.0;
|
|
match views.into_iter().next().map(|view| view.document) {
|
|
Some(PipelineDocument::FollowUp(follow_up)) => {
|
|
task.push_str(&format!("
|
|
|
|
Task, claimed follow-up `{id}` (campaign `{}`, repo `{}`):
|
|
{}", follow_up.campaign.0, follow_up.repo.0, follow_up.item.0));
|
|
}
|
|
Some(PipelineDocument::CutSpec(spec)) => {
|
|
task.push_str(&format!("
|
|
|
|
Task, claimed cut spec `{id}` (campaign `{}`): the spec is the task; read it with `huginn view`.", spec.campaign.0));
|
|
}
|
|
Some(_) => task.push_str(&format!("
|
|
|
|
The run claims `{id}` ({:?}), which is no brief: its task is unknown.", claim.kind)),
|
|
None => task.push_str(&format!("
|
|
|
|
The run claims `{id}`, which the mind does not hold: its task is unknown.")),
|
|
}
|
|
}
|
|
Ok(task)
|
|
}
|
|
|
|
/// What the child is told once, when its session must end: the ceiling, the cap or the cache.
|
|
pub const FLUSH_MESSAGE: &str = "The context ceiling is reached. Admit to the mind what it lacks, close your run, and end.";
|
|
|
|
/// The flush as written: the one message, and what she does with the briefs she holds.
|
|
fn flush_text(why: Why) -> String {
|
|
let cause = match why {
|
|
Why::Ceiling => "",
|
|
Why::Rotation => " (This session ends soon: the unit's time is nearly up.)",
|
|
Why::Idle => " (This session ends because nothing is waiting and its cache would lapse.)",
|
|
};
|
|
format!("{FLUSH_MESSAGE}{cause} Resolve each brief you answered (Recorded, your answer as rationale); a brief you are still working stays open and the next session continues it.")
|
|
}
|
|
|
|
/// The session id of a run: UUIDv5 over the run's id. Derived, never stored.
|
|
pub fn session_id(run: &PipelineRef) -> Uuid {
|
|
Uuid::new_v5(&SESSION_NAMESPACE, run.id.0.as_bytes())
|
|
}
|
|
|
|
/// The argument list of the child, and nothing else: no resume, continue or
|
|
/// fork, no model, no settings and no hooks belong to this actuator. It asks the
|
|
/// CLI to echo each stdin line it takes into a request (`--replay-user-messages`),
|
|
/// which is how delivery is counted. It fixes the permission mode: print mode starts in Manual when nothing on the command
|
|
/// line sets a mode (the managed `defaultMode` was not applied in print mode,
|
|
/// 2026-10-31), and `--permission-prompts none` then denies every tool that
|
|
/// needs approval, so a Mind's reach within her slice is granted here, but
|
|
/// with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` set the CLI forces the mode to
|
|
/// default (probe, claude 2.1.312): the flag has no effect there and only the
|
|
/// seed's allow list decides tools. Only the
|
|
/// user's settings load (the fresh configuration directory
|
|
/// the seed fills); the working directory's project and local settings, which a
|
|
/// run could have written, do not.
|
|
pub fn claude_args(session: Uuid) -> Vec<String> {
|
|
[
|
|
"-p",
|
|
"--input-format",
|
|
"stream-json",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--session-id",
|
|
&session.to_string(),
|
|
"--max-turns",
|
|
&MAX_TURNS.to_string(),
|
|
"--permission-mode",
|
|
"bypassPermissions",
|
|
"--permission-prompts",
|
|
"none",
|
|
"--setting-sources",
|
|
"user",
|
|
"--replay-user-messages",
|
|
]
|
|
.map(String::from)
|
|
.to_vec()
|
|
}
|
|
|
|
/// The model name on the CLI's own assistant lines, which no request produced.
|
|
const SYNTHETIC_MODEL: &str = "<synthetic>";
|
|
|
|
/// One line of the child's stream, as far as the actuator reads it.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Usage {
|
|
/// A request of the main thread: the message's id and the context it
|
|
/// read (input, cache read and cache write tokens).
|
|
Request { id: String, prefix: u64 },
|
|
/// A user line of the main thread that the CLI took into a request (a replay echo): ours, never a tool result.
|
|
Echo,
|
|
/// A turn ended. `ok`: not an error. `blocked`: the CLI refused the context (`blocking_limit`).
|
|
Result { ok: bool, blocked: bool },
|
|
/// Valid JSON that is none of the above, or a request of a subagent.
|
|
Other,
|
|
/// Not a JSON object.
|
|
Unreadable,
|
|
}
|
|
|
|
/// Whether a main-thread request wrote its prompt cache for an hour (the split
|
|
/// `cache_creation.ephemeral_1h_input_tokens`), else for five minutes. None for
|
|
/// any other line.
|
|
pub fn writes_hour_cache(line: &str) -> Option<bool> {
|
|
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return None };
|
|
if event.get("type").and_then(Value::as_str) != Some("assistant") || !event.get("parent_tool_use_id").is_none_or(Value::is_null) {
|
|
return None;
|
|
}
|
|
let hour = event.get("message")?.get("usage")?.get("cache_creation")?.get("ephemeral_1h_input_tokens").and_then(Value::as_u64);
|
|
Some(hour.unwrap_or(0) > 0)
|
|
}
|
|
|
|
/// Reads one stream line.
|
|
pub fn read_usage(line: &str) -> Usage {
|
|
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return Usage::Unreadable };
|
|
match event.get("type").and_then(Value::as_str) {
|
|
Some("result") => Usage::Result {
|
|
ok: !event.get("is_error").and_then(Value::as_bool).unwrap_or(false),
|
|
blocked: event.get("terminal_reason").and_then(Value::as_str) == Some("blocking_limit"),
|
|
},
|
|
Some("user")
|
|
if event.get("isReplay").and_then(Value::as_bool) == Some(true)
|
|
&& event.get("parent_tool_use_id").is_none_or(Value::is_null)
|
|
&& event.get("message").and_then(|message| message.get("content")).is_some_and(Value::is_string) =>
|
|
{
|
|
Usage::Echo
|
|
}
|
|
Some("assistant") if event.get("parent_tool_use_id").is_none_or(Value::is_null) => {
|
|
let message = event.get("message");
|
|
// The CLI's own lines after an API error or a refused slash command carry a uuid id and no usage: no request was made.
|
|
if message.and_then(|message| message.get("model")).and_then(Value::as_str) == Some(SYNTHETIC_MODEL) {
|
|
return Usage::Other;
|
|
}
|
|
let Some(id) = message.and_then(|message| message.get("id")).and_then(Value::as_str) else { return Usage::Other };
|
|
let usage = message.and_then(|message| message.get("usage"));
|
|
let field = |name: &str| usage.and_then(|usage| usage.get(name)).and_then(Value::as_u64).unwrap_or(0);
|
|
// Saturating: a sum that overflows is at or over the ceiling, never a panic or a wrap.
|
|
let prefix = field("input_tokens").saturating_add(field("cache_read_input_tokens")).saturating_add(field("cache_creation_input_tokens"));
|
|
Usage::Request { id: id.to_string(), prefix }
|
|
}
|
|
_ => Usage::Other,
|
|
}
|
|
}
|
|
|
|
/// What the child gave in one wait: the operating system's facts about it.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Read {
|
|
Line(String),
|
|
Timeout,
|
|
/// Its stdout ended.
|
|
Eof,
|
|
/// It was reaped: its exit code, none when a signal ended it.
|
|
Exited(Option<i32>),
|
|
}
|
|
|
|
/// The running `claude`. A test hands in a scripted one.
|
|
pub trait Child {
|
|
/// Writes one line to its input. A dead child's stream closes, which is
|
|
/// how `carry` learns of it, so a failed write is not an error here.
|
|
fn write_line(&mut self, line: &str);
|
|
/// Closes its input; later writes go nowhere.
|
|
fn close_input(&mut self);
|
|
fn next(&mut self, timeout: StdDuration) -> Read;
|
|
/// Sends the signal to its whole process group; whether it was delivered.
|
|
fn stop(&mut self, how: Stop) -> bool;
|
|
}
|
|
|
|
/// The signals the actuator sends, weakest first.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum Stop {
|
|
/// SIGINT, as an operator's Ctrl-C.
|
|
Interrupt,
|
|
Terminate,
|
|
Kill,
|
|
}
|
|
|
|
impl Stop {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::Interrupt => Self::Terminate,
|
|
Self::Terminate | Self::Kill => Self::Kill,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait Spawner {
|
|
/// Starts the child with `token` as its credential descriptor.
|
|
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>>;
|
|
}
|
|
|
|
/// Why a run ended.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Cause {
|
|
/// The child ended with no flush asked for and no interrupt.
|
|
Own,
|
|
/// The ceiling was reached and the flush asked for; the child ended with no
|
|
/// signal. Whether it ran the flush is for the mind to say, not this label.
|
|
FlushRequested,
|
|
/// A signal was delivered, and this was the strongest one delivered.
|
|
Signalled(Stop),
|
|
/// Not even SIGKILL could be delivered: the child was not waited for.
|
|
Unstoppable,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum SelfRunOutcome {
|
|
/// She has not exactly one open Self run: nothing was started.
|
|
NoRun,
|
|
/// The connector would not lend: the run is closed Recorded and nothing was started.
|
|
LeaseRefused { kind: LeaseRefusalKind },
|
|
Ended { cause: Cause, code: Option<i32>, requests: u64, prefix: u64 },
|
|
}
|
|
|
|
impl SelfRunOutcome {
|
|
/// 0 when the child exited 0, 3 when no child was started, 4 when the
|
|
/// connector refused the lease, else 1 (a child killed by a signal has no code).
|
|
pub fn exit_status(&self) -> i32 {
|
|
match self {
|
|
Self::NoRun => 3,
|
|
Self::LeaseRefused { .. } => 4,
|
|
Self::Ended { code: Some(0), .. } => 0,
|
|
Self::Ended { .. } => 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for SelfRunOutcome {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::NoRun => write!(f, "no run: she has no single open Self run"),
|
|
Self::LeaseRefused { kind } => write!(f, "no run: the connector refused the lease ({kind:?})"),
|
|
Self::Ended { cause, code, requests, prefix } => {
|
|
let cause = match cause {
|
|
Cause::Own => "own",
|
|
Cause::FlushRequested => "flush-requested",
|
|
Cause::Signalled(Stop::Interrupt) => "interrupted",
|
|
Cause::Signalled(Stop::Terminate) => "terminated",
|
|
Cause::Signalled(Stop::Kill) => "killed",
|
|
Cause::Unstoppable => "unstoppable",
|
|
};
|
|
match code {
|
|
Some(code) => write!(f, "ended: {cause} code {code} requests {requests} prefix {prefix}"),
|
|
None => write!(f, "ended: {cause} signal requests {requests} prefix {prefix}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Her one open Self run and its claims, read through the launch primitive holder
|
|
/// reader. Anything but exactly one run is none: the mind admits at most one Self
|
|
/// run of hers in force, and the launch that opens the next closes a dead one.
|
|
fn her_run(mind: &dyn MindPort) -> Result<Option<(PipelineRef, Vec<PipelineRef>)>> {
|
|
let mut held = holders(mind, mind.instance(), RunTurn::SelfRun)?;
|
|
let (Some(run), None) = (held.pop(), held.pop()) else { return Ok(None) };
|
|
let one = Selection { keys: Some(vec![run.id.id.0.clone()]), projection: "document".into(), ..Selection::default() };
|
|
let PipelinePageItems::Documents(views) = mind.query(&one)?.items else { bail!("the mind answered headers to a document selection") };
|
|
match views.into_iter().next().map(|view| view.document) {
|
|
Some(PipelineDocument::Run(document)) => Ok(Some((run.id, document.claims))),
|
|
_ => Err(anyhow!("the run's document could not be read")),
|
|
}
|
|
}
|
|
|
|
/// What the stream said at the end of the run: its head becomes the closure's reason, its text (bounded) the rationale.
|
|
#[derive(Default)]
|
|
struct Heard {
|
|
/// The last result heard, ok or not: its head line and its text cut to `RATIONALE_RESULT_LIMIT`.
|
|
concluded: Option<(String, String)>,
|
|
}
|
|
|
|
impl Heard {
|
|
fn hear(&mut self, line: &str) {
|
|
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return };
|
|
if event.get("type").and_then(Value::as_str) == Some("result") {
|
|
self.concluded = Some((result_head(&event), cut_text(result_text(&event), RATIONALE_RESULT_LIMIT)));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn unix_ms(at: DateTime<Utc>) -> u64 {
|
|
u64::try_from(at.timestamp_millis()).unwrap_or(0)
|
|
}
|
|
|
|
/// Closes a run whose lease was refused: Recorded with the refusal's kind, so a
|
|
/// run that cannot be paid for counts as an empty run and trips the breaker.
|
|
fn close_refused(mind: &dyn MindPort, clock: &dyn Clock, run: &PipelineRef, kind: LeaseRefusalKind) -> Result<SelfRunOutcome> {
|
|
let reason = format!("the connector refused the credential lease: {kind:?}");
|
|
admit_closure(mind, clock, run, ResolutionOutcome::Recorded { reason: reason.as_str().into() }, "The Self run's credential lease was refused, so no claude was started.")?;
|
|
Ok(SelfRunOutcome::LeaseRefused { kind })
|
|
}
|
|
|
|
/// Admits the closing resolution of `run`: the one writer of a closure.
|
|
fn admit_closure(mind: &dyn MindPort, clock: &dyn Clock, run: &PipelineRef, outcome: ResolutionOutcome, rationale: &str) -> Result<()> {
|
|
let document = closure(mind, run, clock.now(), outcome, rationale)?;
|
|
let label = run.id.0.rsplit(':').next().unwrap_or(&run.id.0);
|
|
match mind.admit(AGENT, label, vec![document])? {
|
|
PipelineAdmissionOutcome::Committed { .. } => Ok(()),
|
|
_ => Err(anyhow!("the run could not be closed")),
|
|
}
|
|
}
|
|
|
|
/// Resolves `run` Recorded if it is still in force: the child may have closed it
|
|
/// itself in its flush, and a refused lease was closed already. One try; a mind
|
|
/// that cannot be reached leaves it open: the launch that opens the next run closes it.
|
|
fn close_if_open(mind: &dyn MindPort, clock: &dyn Clock, run: &PipelineRef, ran: &Result<SelfRunOutcome>, heard: &Heard) -> Result<()> {
|
|
if !holders(mind, mind.instance(), RunTurn::SelfRun)?.iter().any(|held| held.id == *run) {
|
|
return Ok(());
|
|
}
|
|
let how = match ran {
|
|
Ok(outcome) => outcome.to_string(),
|
|
Err(_) => "the actuator failed after the run opened".to_string(),
|
|
};
|
|
let (reason, rationale) = match &heard.concluded {
|
|
Some((head, text)) if !text.is_empty() => (format!("{how}; {head}"), text.as_str()),
|
|
Some((head, _)) => (format!("{how}; {head}"), CARRIER_CLOSED),
|
|
None => (format!("{how}; claude gave no result"), CARRIER_CLOSED),
|
|
};
|
|
admit_closure(mind, clock, run, ResolutionOutcome::Recorded { reason: reason.as_str().into() }, rationale)
|
|
}
|
|
|
|
/// The rationale of a closure that has no result text to give.
|
|
const CARRIER_CLOSED: &str = "The Self run's carrier closed the run its claude left open.";
|
|
|
|
/// Closes a run whose lease was refused for its length: Withdrawn, because no
|
|
/// claude ever ran and a misconfiguration is not an empty run; the breaker
|
|
/// counts nothing and a launch that finds the unit dead has no open run to close.
|
|
fn withdraw_misconfigured(mind: &dyn MindPort, clock: &dyn Clock, run: &PipelineRef) -> Result<()> {
|
|
let outcome = ResolutionOutcome::Withdrawn { reason: "the connector refused the lease as too long".into() };
|
|
admit_closure(mind, clock, run, outcome, "The unit's runtime cap exceeds the connector's lease length, so no claude was started.")
|
|
}
|
|
|
|
/// Carries her one open Self run: one `claude` child from the lease to its
|
|
/// end. The request lines go to `out`. A mind that cannot be reached, a
|
|
/// connector that cannot be reached or a child that cannot be started is an
|
|
/// error; every other end is an outcome. The lease comes first: with none
|
|
/// granted no child exists. The lease is dropped when the child is known to
|
|
/// have ended, which ends it.
|
|
/// `run_cap_s` is the unit's `RuntimeMaxSec`: a connector that finds the lease
|
|
/// too long is misconfigured, which is an error naming the mismatch and not a
|
|
/// refusal of the run: the run is withdrawn, so the breaker counts nothing.
|
|
///
|
|
/// Whatever happens after the run is found, it is resolved Recorded exactly once
|
|
/// before `carry` returns: the child's result (or that it gave none) in the
|
|
/// reason. A resolve that fails is logged and tried no more: the outcome (or the
|
|
/// original error) stands, a failed close of a run that ended normally is an
|
|
/// error, and the launch that opens the next run closes the run left open.
|
|
pub fn carry(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, spawner: &dyn Spawner, run_cap_s: u64, out: &mut dyn Write) -> Result<SelfRunOutcome> {
|
|
let Some((run, claims)) = her_run(mind)? else { return Ok(SelfRunOutcome::NoRun) };
|
|
let mut task = task_section(mind, &claims)?;
|
|
let mut told: Vec<Brief> = inbox::derive(mind, &HashSet::new())?;
|
|
let also: Vec<&Brief> = told.iter().filter(|brief| !claims.iter().any(|claim| claim.id.0 == brief.id)).collect();
|
|
if !also.is_empty() {
|
|
task.push_str("
|
|
|
|
Also open for you:");
|
|
for brief in also {
|
|
task.push_str(&format!("
|
|
- follow-up `{}` (campaign `{}`, repo `{}`): {} {}", brief.id, brief.campaign, brief.repo, brief.item, inbox::resolving_sentence(&brief.id)));
|
|
}
|
|
}
|
|
let mut heard = Heard::default();
|
|
let ran = lend_and_drive(mind, clock, lender, spawner, run_cap_s, out, (&run, &task), &mut told, &mut heard);
|
|
let closed = close_if_open(mind, clock, &run, &ran, &heard);
|
|
if closed.is_err() {
|
|
let _ = writeln!(out, "the run could not be closed: the next launch closes it");
|
|
}
|
|
match (ran, closed) {
|
|
(Err(error), _) => Err(error),
|
|
(Ok(outcome), Ok(())) => Ok(outcome),
|
|
(Ok(_), Err(error)) => Err(error.context("the run's process ended but its run could not be closed")),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, reason = "carry's ports, passed on whole")]
|
|
fn lend_and_drive(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, spawner: &dyn Spawner, run_cap_s: u64, out: &mut dyn Write, (run, task): (&PipelineRef, &str), told: &mut Vec<Brief>, heard: &mut Heard) -> Result<SelfRunOutcome> {
|
|
let started_ms = unix_ms(clock.now());
|
|
let request = LeaseRequest { expires_at_unix_ms: started_ms.saturating_add(run_cap_s.saturating_mul(1000)) };
|
|
let lease = match lender.lend(&request)? {
|
|
Lent::Granted(lease) => lease,
|
|
Lent::Refused(refused) if refused.kind == LeaseRefusalKind::Expired => {
|
|
withdraw_misconfigured(mind, clock, run)?;
|
|
bail!("the connector refused a {run_cap_s} s lease as too long: raise its --lease-max-ms to at least the unit's RuntimeMaxSec ({RUN_CAP_VARIABLE}) plus slack");
|
|
}
|
|
Lent::Refused(refused) => return close_refused(mind, clock, run, refused.kind),
|
|
};
|
|
let child = spawner.spawn(&claude_args(session_id(run)), lease.token())?;
|
|
let mut inbox = SelfInbox::new(mind, run, task, (lease.plan_quota(), started_ms), told, heard);
|
|
drive(clock, child, &mut inbox, run_cap_s, out)
|
|
}
|
|
|
|
/// The Self's side of the session: her briefs, derived from the mind, are what she is told.
|
|
struct SelfInbox<'a> {
|
|
mind: &'a dyn MindPort,
|
|
start: String,
|
|
plan: String,
|
|
told: &'a mut Vec<Brief>,
|
|
/// The briefs derived at the last poll that no line has carried yet.
|
|
waiting: Vec<Brief>,
|
|
heard: &'a mut Heard,
|
|
}
|
|
|
|
impl<'a> SelfInbox<'a> {
|
|
fn new(mind: &'a dyn MindPort, run: &PipelineRef, task: &str, (plan_quota, now_ms): (&[PlanQuota], u64), told: &'a mut Vec<Brief>, heard: &'a mut Heard) -> Self {
|
|
let start = format!("{}{task}", start_message(mind.instance(), run, plan_quota, now_ms));
|
|
Self { mind, start, plan: render_plan(plan_quota, now_ms), told, waiting: Vec::new(), heard }
|
|
}
|
|
}
|
|
|
|
impl SessionInbox for SelfInbox<'_> {
|
|
fn start_line(&mut self) -> Result<String> {
|
|
Ok(self.start.clone())
|
|
}
|
|
|
|
fn poll_every_s(&self) -> i64 {
|
|
INBOX_POLL_S
|
|
}
|
|
|
|
fn poll(&mut self) -> Result<Polled> {
|
|
let held: HashSet<String> = self.told.iter().map(|brief| brief.id.clone()).collect();
|
|
self.waiting = inbox::derive(self.mind, &held)?;
|
|
Ok(Polled { pending: !self.waiting.is_empty(), yield_now: false })
|
|
}
|
|
|
|
fn tell(&mut self) -> Result<String> {
|
|
let text = inbox::render(&self.waiting, &self.plan);
|
|
self.told.append(&mut self.waiting);
|
|
Ok(text)
|
|
}
|
|
|
|
fn flush_text(&mut self, why: Why) -> String {
|
|
flush_text(why)
|
|
}
|
|
|
|
fn line(&mut self, line: &str) {
|
|
self.heard.hear(line);
|
|
}
|
|
}
|
|
|
|
/// The most of a result's text that reaches the journal.
|
|
const RESULT_TEXT_LIMIT: usize = 4096;
|
|
/// The most of a result's text that reaches the closing resolution's rationale
|
|
/// (a `Para` holds 4000 bytes; the cut marker rides inside the rest).
|
|
const RATIONALE_RESULT_LIMIT: usize = 3800;
|
|
|
|
/// A result event's subtype and error flag when it has them, as one line.
|
|
fn result_head(event: &serde_json::Map<String, Value>) -> String {
|
|
let mut head = String::from("result");
|
|
if let Some(subtype) = event.get("subtype").and_then(Value::as_str) {
|
|
head.push_str(&format!(" subtype={subtype}"));
|
|
}
|
|
if let Some(is_error) = event.get("is_error").and_then(Value::as_bool) {
|
|
head.push_str(&format!(" is_error={is_error}"));
|
|
}
|
|
head
|
|
}
|
|
|
|
fn result_text(event: &serde_json::Map<String, Value>) -> &str {
|
|
event.get("result").and_then(Value::as_str).unwrap_or("")
|
|
}
|
|
|
|
/// `text` cut at a character boundary to `limit` bytes, marked when cut.
|
|
fn cut_text(text: &str, limit: usize) -> String {
|
|
let mut end = text.len().min(limit);
|
|
while !text.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
let marker = if end < text.len() { "\n[truncated]" } else { "" };
|
|
format!("{}{marker}", &text[..end])
|
|
}
|
|
|
|
/// A result event as one line: its head, then its text cut to `limit` bytes.
|
|
fn describe_result(event: &serde_json::Map<String, Value>, limit: usize) -> String {
|
|
format!("{}: {}", result_head(event), cut_text(result_text(event), limit))
|
|
}
|
|
|
|
/// What `claude` concluded, written to the unit's stdout so it reaches the
|
|
/// journal before the transcript goes with the runtime directory.
|
|
fn write_result(line: &str, out: &mut dyn Write) -> std::io::Result<()> {
|
|
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return Ok(()) };
|
|
if event.get("type").and_then(Value::as_str) != Some("result") {
|
|
return Ok(());
|
|
}
|
|
writeln!(out, "{}", describe_result(&event, RESULT_TEXT_LIMIT))
|
|
}
|
|
|
|
/// The real spawner: the `claude` on the unit's `PATH`, its stderr in the
|
|
/// journal. Its credential is the lease's descriptor, named in the one variable
|
|
/// that says where to read it; the token is in no environment, argument or file.
|
|
/// It holds the unit's environment, which the library never reads: the binary
|
|
/// hands it over, and [`child_environment`] decides what of it the child sees.
|
|
pub struct ClaudeSpawner {
|
|
unit_environment: Vec<(OsString, OsString)>,
|
|
scratch: PathBuf,
|
|
seed: Option<PathBuf>,
|
|
/// Her sandbox: with it the child runs under bubblewrap (`sandbox_args`), the Persona's; the Self passes none.
|
|
sandbox: Option<PathBuf>,
|
|
}
|
|
|
|
impl ClaudeSpawner {
|
|
/// From the unit's environment: it must name [`SCRATCH_VARIABLE`], and may name [`SEED_VARIABLE`].
|
|
pub fn new(unit_environment: impl IntoIterator<Item = (OsString, OsString)>) -> Result<Self> {
|
|
Self::in_sandbox(unit_environment, SEED_VARIABLE, None)
|
|
}
|
|
|
|
/// The same, with the seed named by `seed_variable` and, if `sandbox` is given, the child run under bubblewrap with that directory as its writable home.
|
|
pub fn in_sandbox(unit_environment: impl IntoIterator<Item = (OsString, OsString)>, seed_variable: &str, sandbox: Option<PathBuf>) -> Result<Self> {
|
|
let unit_environment: Vec<(OsString, OsString)> = unit_environment.into_iter().collect();
|
|
let named = |variable: &str| unit_environment.iter().find(|(name, _)| name == variable).map(|(_, value)| PathBuf::from(value));
|
|
// systemd lists several runtime directories colon-separated; the unit names one.
|
|
let scratch = named(SCRATCH_VARIABLE).filter(|path| !path.as_os_str().is_empty()).ok_or_else(|| anyhow!("{SCRATCH_VARIABLE} is not set: the unit needs RuntimeDirectory="))?;
|
|
let seed = named(seed_variable).filter(|path| !path.as_os_str().is_empty());
|
|
Ok(Self { unit_environment, scratch, seed, sandbox })
|
|
}
|
|
}
|
|
|
|
impl Spawner for ClaudeSpawner {
|
|
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
|
let config = FreshConfig::create(&self.scratch, self.seed.as_deref())?;
|
|
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, &self.unit_environment, config) };
|
|
let mut wrapped = sandbox_args(sandbox, &self.scratch);
|
|
wrapped.push("claude".into());
|
|
wrapped.extend(args.iter().cloned());
|
|
spawn_program(Path::new("bwrap"), &wrapped, token, &self.unit_environment, config)
|
|
}
|
|
}
|
|
|
|
/// The bubblewrap arguments up to the program: the whole filesystem read-only, a private `/tmp`
|
|
/// and `/run`, the unit's scratch directory read-only with only its `claude-config` writable (she
|
|
/// cannot create, replace or link a file where the organ writes, such as the instructions file),
|
|
/// her sandbox writable, her unit's credentials (`/etc/gamecult`, the other `/var/lib`
|
|
/// directories, the lease socket under `/run`) hidden, a private process table, and the child dead
|
|
/// when the unit's process is. The network is shared: the web, `huginn` and the API are reachable,
|
|
/// and what she reaches on the host is the uid fence's (gamecult-ops `mind-fence.service`), which
|
|
/// binds the organ and this sandbox alike. The boundary is the operating system's, not a pattern
|
|
/// list (ruling `eureka-body:ruling:persona-git-personal-sandbox`).
|
|
pub(crate) fn sandbox_args(sandbox: &Path, scratch: &Path) -> Vec<String> {
|
|
let (sandbox, scratch) = (sandbox.to_string_lossy(), scratch.to_string_lossy());
|
|
let config = scratch.to_string() + "/" + CONFIG_DIR_NAME;
|
|
[
|
|
"--ro-bind", "/", "/",
|
|
"--dev", "/dev",
|
|
"--proc", "/proc",
|
|
"--tmpfs", "/tmp",
|
|
"--tmpfs", "/run",
|
|
"--ro-bind-try", "/run/systemd/resolve", "/run/systemd/resolve",
|
|
"--ro-bind", &scratch, &scratch,
|
|
"--bind", &config, &config,
|
|
"--tmpfs", "/etc/gamecult",
|
|
"--tmpfs", "/var/lib",
|
|
"--bind", &sandbox, &sandbox,
|
|
"--unshare-pid",
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
"--chdir", &sandbox,
|
|
"--",
|
|
]
|
|
.map(String::from)
|
|
.to_vec()
|
|
}
|
|
|
|
/// The directory name of a child's configuration inside the scratch directory.
|
|
const CONFIG_DIR_NAME: &str = "claude-config";
|
|
|
|
/// One child's configuration directory: made empty (or as the seed) for it,
|
|
/// owner-only, and removed when the child's handle goes. Whatever the run writes
|
|
/// there (settings that set an environment variable, an `apiKeyHelper`, a base
|
|
/// URL, a hook) lasts only as long as that run: the next one starts from the seed.
|
|
struct FreshConfig(PathBuf);
|
|
|
|
impl FreshConfig {
|
|
fn create(scratch: &Path, seed: Option<&Path>) -> Result<Self> {
|
|
let dir = scratch.join(CONFIG_DIR_NAME);
|
|
match std::fs::remove_dir_all(&dir) {
|
|
Ok(()) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(_) => bail!("a stale configuration directory could not be removed"),
|
|
}
|
|
let mut made = std::fs::DirBuilder::new();
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::DirBuilderExt::mode(&mut made, 0o700);
|
|
made.create(&dir).context("the configuration directory could not be made")?;
|
|
let fresh = Self(dir);
|
|
if let Some(seed) = seed {
|
|
copy_tree(seed, &fresh.0).context("the configuration seed could not be copied")?;
|
|
}
|
|
Ok(fresh)
|
|
}
|
|
}
|
|
|
|
impl Drop for FreshConfig {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// A seed link the copy refuses: it would reach outside the seed.
|
|
#[derive(Debug)]
|
|
struct SeedLinkRefused {
|
|
link: PathBuf,
|
|
target: PathBuf,
|
|
}
|
|
|
|
impl fmt::Display for SeedLinkRefused {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "the seed link {} -> {} is absolute or leaves the seed", self.link.display(), self.target.display())
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SeedLinkRefused {}
|
|
|
|
/// Copies directories and regular files; a link is reproduced as a link with
|
|
/// the same target, never followed into the copy. A link is refused with
|
|
/// [`SeedLinkRefused`] unless its target is relative and, resolved through
|
|
/// every link on its way in the source, lands under the canonical seed root.
|
|
/// The source check refuses early; but a link's text resolves from where the
|
|
/// copy sits, so once the copy is whole every reproduced link is resolved again
|
|
/// in `to`, and one that lands outside it is refused the same way.
|
|
fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
|
|
copy_level(&from.canonicalize()?, from, to)?;
|
|
check_links(&to.canonicalize()?, to)
|
|
}
|
|
|
|
fn check_links(root: &Path, dir: &Path) -> std::io::Result<()> {
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let kind = entry.file_type()?;
|
|
if kind.is_dir() {
|
|
check_links(root, &entry.path())?;
|
|
} else if kind.is_symlink() && !entry.path().canonicalize().is_ok_and(|resolved| resolved.starts_with(root)) {
|
|
return Err(std::io::Error::other(SeedLinkRefused { link: entry.path(), target: std::fs::read_link(entry.path())? }));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_level(root: &Path, from: &Path, to: &Path) -> std::io::Result<()> {
|
|
for entry in std::fs::read_dir(from)? {
|
|
let entry = entry?;
|
|
let kind = entry.file_type()?;
|
|
let target = to.join(entry.file_name());
|
|
if kind.is_dir() {
|
|
std::fs::create_dir(&target)?;
|
|
copy_level(root, &entry.path(), &target)?;
|
|
} else if kind.is_file() {
|
|
std::fs::copy(entry.path(), &target)?;
|
|
} else if kind.is_symlink() {
|
|
let points_at = std::fs::read_link(entry.path())?;
|
|
let stays = !points_at.is_absolute() && entry.path().canonicalize().is_ok_and(|resolved| resolved.starts_with(root));
|
|
if !stays {
|
|
return Err(std::io::Error::other(SeedLinkRefused { link: entry.path(), target: points_at }));
|
|
}
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::symlink(&points_at, &target)?;
|
|
#[cfg(not(unix))]
|
|
return Err(std::io::Error::other("a seed link cannot be reproduced on this platform"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether the child may see a unit variable. `claude` prefers an ambient
|
|
/// credential (`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`,
|
|
/// `CLAUDE_CODE_OAUTH_TOKEN`) to the lease's descriptor and takes its endpoint
|
|
/// from `ANTHROPIC_BASE_URL`, so the child gets only what a shell needs to find
|
|
/// its way; nothing credential-shaped is on the list. Its `HOME` and
|
|
/// `CLAUDE_CONFIG_DIR` are its fresh configuration directory, set by the spawn.
|
|
/// `EUREKA_INSTANCE` and `HUGINN_ENDPOINT` are the `huginn` CLI's whole identity
|
|
/// (ruling `eureka-body:ruling:cli-over-mcp-agent-surface`): a name and an address, no secret.
|
|
fn child_may_see(name: &OsStr) -> bool {
|
|
let name = name.to_string_lossy();
|
|
matches!(name.as_ref(), "PATH" | "LANG" | "TERM" | "EUREKA_INSTANCE" | "HUGINN_ENDPOINT") || name.starts_with("LC_")
|
|
}
|
|
|
|
/// The unit's environment as the child sees it: the allow-list, and nothing else.
|
|
fn child_environment(unit: &[(OsString, OsString)]) -> Vec<(&OsString, &OsString)> {
|
|
unit.iter().filter(|(name, _)| child_may_see(name)).map(|(name, value)| (name, value)).collect()
|
|
}
|
|
|
|
/// Whether the unit turned the child's subprocess scrub off: only `MIND_SELF_SCRUB=off` does.
|
|
fn scrub_is_off(unit: &[(OsString, OsString)]) -> bool {
|
|
unit.iter().any(|(name, value)| name == SCRUB_VARIABLE && value == "off")
|
|
}
|
|
|
|
/// Starts `program` with a duplicate of the token descriptor open in it, and
|
|
/// closes that duplicate here the moment the child exists. The lease's own
|
|
/// descriptor is close-on-exec, so this duplicate is the child's only way to the
|
|
/// token. `claude` reads it to the end once at startup, so what its own children
|
|
/// inherit is an emptied pipe whose write end exists nowhere. The child's
|
|
/// environment is cleared and rebuilt from `unit`'s allow-listed variables.
|
|
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, unit: &[(OsString, OsString)], config: FreshConfig) -> Result<Box<dyn Child>> {
|
|
let inheritable = token.inheritable().context("the credential descriptor could not be placed")?;
|
|
let mut command = Command::new(program);
|
|
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit());
|
|
command.env_clear().envs(child_environment(unit));
|
|
command.env("HOME", &config.0).env(CONFIG_DIR_VARIABLE, &config.0);
|
|
command.env(DISABLE_COMPACT_VARIABLE, "1").env(TOKEN_FD_VARIABLE, inheritable.number().to_string()).env(ENV_SCRUB_VARIABLE, if scrub_is_off(unit) { "0" } else { "1" });
|
|
// Its own process group, so that a signal reaches what the child started too.
|
|
#[cfg(unix)]
|
|
std::os::unix::process::CommandExt::process_group(&mut command, 0);
|
|
let mut process = command.spawn().context("claude could not be started")?;
|
|
drop(inheritable);
|
|
let group = process.id();
|
|
let stdin = process.stdin.take();
|
|
let stdout = process.stdout.take().ok_or_else(|| anyhow!("claude has no stdout"))?;
|
|
let (sender, facts) = channel();
|
|
let lines = sender.clone();
|
|
std::thread::spawn(move || {
|
|
for line in BufReader::new(stdout).split(b'\n') {
|
|
let Ok(bytes) = line else { break };
|
|
if lines.send(Read::Line(String::from_utf8_lossy(&bytes).into_owned())).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
let _ = lines.send(Read::Eof);
|
|
});
|
|
// The one waitpid: the child's end is a fact of its own, not inferred from its stream.
|
|
std::thread::spawn(move || {
|
|
let _ = sender.send(Read::Exited(process.wait().ok().and_then(|status| status.code())));
|
|
});
|
|
Ok(Box::new(ClaudeProcess { stdin, facts, group, _config: config }))
|
|
}
|
|
|
|
struct ClaudeProcess {
|
|
stdin: Option<ChildStdin>,
|
|
facts: Receiver<Read>,
|
|
/// The child's pid, which is its process group's id.
|
|
group: u32,
|
|
/// Its configuration, removed when the child's handle goes.
|
|
_config: FreshConfig,
|
|
}
|
|
|
|
impl Child for ClaudeProcess {
|
|
fn write_line(&mut self, line: &str) {
|
|
if let Some(stdin) = self.stdin.as_mut() {
|
|
let _ = stdin.write_all(line.as_bytes()).and_then(|()| stdin.write_all(b"\n")).and_then(|()| stdin.flush());
|
|
}
|
|
}
|
|
|
|
fn close_input(&mut self) {
|
|
self.stdin = None;
|
|
}
|
|
|
|
fn next(&mut self, timeout: StdDuration) -> Read {
|
|
match self.facts.recv_timeout(timeout) {
|
|
Ok(fact) => fact,
|
|
Err(RecvTimeoutError::Timeout) => Read::Timeout,
|
|
Err(RecvTimeoutError::Disconnected) => Read::Eof,
|
|
}
|
|
}
|
|
|
|
fn stop(&mut self, how: Stop) -> bool {
|
|
// The `kill` program, not a libc call: the library names no FFI.
|
|
let signal = match how {
|
|
Stop::Interrupt => "INT",
|
|
Stop::Terminate => "TERM",
|
|
Stop::Kill => "KILL",
|
|
};
|
|
Command::new("kill").args(["-s", signal, "--", &format!("-{}", self.group)]).stderr(Stdio::null()).status().is_ok_and(|status| status.success())
|
|
}
|
|
}
|
|
|
|
mod decide;
|
|
pub mod inbox;
|
|
pub(crate) mod session;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|