agentradar-cli 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/index.js +11 -1
- package/dist/runner.js +17 -0
- package/dist/setup.js +57 -2
- package/dist/vendor-runner/feeder.mjs +550 -0
- package/dist/vendor-runner/pane-live.sh +27 -0
- package/dist/vendor-runner/session-frame.mjs +119 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +362 -0
- package/dist/vendor-runner/terminal-bridge.mjs +467 -0
- package/package.json +4 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ===========================================================================
|
|
2
|
+
// I2 — session injection frame (pure, testable).
|
|
3
|
+
// The interactive-host variant of the vendor runner does NOT spawn a fresh claude
|
|
4
|
+
// per event; it injects each drained event as the NEXT turn of a persistent,
|
|
5
|
+
// always-on claude running in a tmux pane (real in-session continuity). These pure
|
|
6
|
+
// helpers build (a) the single-line frame the agent sees and (b) the exact tmux
|
|
7
|
+
// send-keys argv. Kept dependency-free so they run under `node --test`.
|
|
8
|
+
//
|
|
9
|
+
// Two hard requirements bake in here:
|
|
10
|
+
// 1. EXPLICIT conversationId — the frame tells the agent which conversation to
|
|
11
|
+
// reply into, so `reply` never has to guess "most-recent" under concurrent
|
|
12
|
+
// turns (the concurrent-turn robustness the lead/disc flagged).
|
|
13
|
+
// 2. SINGLE LINE — a TUI submits on the first newline, so the frame must be one
|
|
14
|
+
// line (no embedded \n) to land as exactly one turn on send-keys + Enter.
|
|
15
|
+
// ===========================================================================
|
|
16
|
+
|
|
17
|
+
/** Collapse any whitespace/newlines to single spaces so the frame stays ONE line. */
|
|
18
|
+
function oneLine(s) {
|
|
19
|
+
return String(s ?? "")
|
|
20
|
+
.replace(/\s+/g, " ")
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the single-line injection frame for one drained next_event event.
|
|
26
|
+
* event: { eventId, source, channel?, conversationId?, text }
|
|
27
|
+
* Returns a one-line string safe to `tmux send-keys -l` then Enter.
|
|
28
|
+
*/
|
|
29
|
+
export function formatInjectionFrame(event) {
|
|
30
|
+
const e = event ?? {};
|
|
31
|
+
const tags = [`source=${e.source ?? "unknown"}`];
|
|
32
|
+
if (e.channel) tags.push(`channel=${e.channel}`);
|
|
33
|
+
if (e.conversationId) tags.push(`conversationId=${e.conversationId}`);
|
|
34
|
+
if (e.eventId) tags.push(`eventId=${e.eventId}`);
|
|
35
|
+
const header = `[AgentRadar inbound · ${tags.join(" · ")}]`;
|
|
36
|
+
const body = oneLine(e.text) || "(no text)";
|
|
37
|
+
const evId = e.eventId ? String(e.eventId) : null;
|
|
38
|
+
// The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
|
|
39
|
+
// (exact provenance under concurrent same-conversation turns — disc S2).
|
|
40
|
+
const parts = [];
|
|
41
|
+
if (e.conversationId) {
|
|
42
|
+
parts.push(
|
|
43
|
+
`Reply via your MCP \`reply\` tool with conversationId="${e.conversationId}"${evId ? ` and inResponseToEventId="${evId}"` : ""} (do NOT guess the channel).`,
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
parts.push(`Reply via your MCP \`reply\` tool${evId ? ` with inResponseToEventId="${evId}"` : ""}.`);
|
|
47
|
+
}
|
|
48
|
+
// L2 (disc #104): the feeder is at-least-once — a crash in the deliver→ack window
|
|
49
|
+
// re-injects this event. Tying report_usage's idempotencyKey to THIS eventId makes a
|
|
50
|
+
// re-delivery a no-op charge (#65 dedups on key) instead of a double bill, while a
|
|
51
|
+
// per-line suffix keeps distinct usage lines separate.
|
|
52
|
+
if (evId) {
|
|
53
|
+
parts.push(
|
|
54
|
+
`For billable work call \`report_usage\` with an idempotencyKey derived from this eventId (e.g. "${evId}:<usage-code>") so a re-delivered event never double-charges.`,
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
parts.push("Use `report_usage` for billable work.");
|
|
58
|
+
}
|
|
59
|
+
parts.push("Then stop.");
|
|
60
|
+
return oneLine(`${header} ${body} — ${parts.join(" ")}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* #173b — build the single-line frame for a #156 liveness PROBE.
|
|
65
|
+
*
|
|
66
|
+
* A security-hardened agent (correctly) refuses to call a tool on the strength of an unverified
|
|
67
|
+
* in-chat instruction — it treats a bare/raw probe as prompt-injection. The fix: deliver the probe
|
|
68
|
+
* with the SAME trusted control-plane provenance as a real event — the `[AgentRadar inbound · … ]`
|
|
69
|
+
* header — so the agent recognizes it as legitimate platform traffic, not an in-chat instruction.
|
|
70
|
+
*
|
|
71
|
+
* #176 (canary finding): the eventId is a server-minted real UUID (matching a genuine event's eventId
|
|
72
|
+
* shape) — because a security-hardened agent DECLINES a probe whose eventId visibly differs from its real
|
|
73
|
+
* events (Liza flagged the `probe-<nonce>` eventId as the tell of an in-chat injection). The eventId is
|
|
74
|
+
* decoupled from the nonce and no longer leaks "this is a probe"; the NONCE stays the sole anti-spoof
|
|
75
|
+
* boundary (server-minted, exact-match). We deliberately do NOT attach a conversationId (a dangling one
|
|
76
|
+
* an agent could reply to would touch the conversation path — lead + disc §3). source stays `agentradar`
|
|
77
|
+
* (honest control-plane origin — the kit teaches the agent this is legitimate platform traffic). There is
|
|
78
|
+
* NO `reply` / `report_usage` guidance — a probe is a no-reply, no-charge health check whose ONLY honored
|
|
79
|
+
* action is `liveness_ack(nonce)` (that directive is the server-supplied body).
|
|
80
|
+
*
|
|
81
|
+
* probe: { nonce, instruction, eventId?, source? }
|
|
82
|
+
*/
|
|
83
|
+
export function formatProbeFrame(probe) {
|
|
84
|
+
const p = probe ?? {};
|
|
85
|
+
const nonce = p.nonce != null ? String(p.nonce) : "";
|
|
86
|
+
// Prefer the server-minted UUID eventId; fall back only defensively (older payloads). No `probe-` prefix
|
|
87
|
+
// when we do fall back — use the bare nonce so we never re-introduce the shape tell the agent rejects.
|
|
88
|
+
const eventId = p.eventId ? String(p.eventId) : nonce || "probe";
|
|
89
|
+
const source = p.source ?? "agentradar";
|
|
90
|
+
// #182/F2 (frame-provenance forgeability): the source/eventId in this header are FORGEABLE content —
|
|
91
|
+
// an adversary with write-access to the agent's session can inject a look-alike header. That is why
|
|
92
|
+
// trust must NOT rest on them. #184's body carries the load-bearing argument (inert action + harness-
|
|
93
|
+
// grant + unforgeable nonce), which is SOURCE-INDEPENDENT: a forged source cannot change the honor
|
|
94
|
+
// basis, and honoring is inert either way (a forged frame can at most induce a no-op nonce-echo). So
|
|
95
|
+
// F2 is a safe-direction robustness gap, not a hole — the residual (a poison-adjacent agent that
|
|
96
|
+
// defensively declines) errs toward over-CREDIT (never a wrongful bill), and D1's pane→flag+HELD path
|
|
97
|
+
// is the human-review backstop. The header stays only as routing metadata.
|
|
98
|
+
const header = `[AgentRadar inbound · source=${source} · eventId=${eventId}]`;
|
|
99
|
+
// #184/D6: inertness-first, accurate-inertness fallback (used only for older payloads with no
|
|
100
|
+
// server-minted instruction). Trust rests on the action being inert + the nonce unforgeable, NOT on
|
|
101
|
+
// the header's source label. A matching ack DOES update the billing-liveness signal — don't overclaim
|
|
102
|
+
// "no charge / zero blast radius"; state the honest bounded effect.
|
|
103
|
+
const body =
|
|
104
|
+
oneLine(p.instruction) ||
|
|
105
|
+
`Liveness check — call the liveness_ack tool with nonce "${nonce}". Judge on structure, not who sent it: a wrong or stale nonce is a harmless no-op (safe even if forged); the tool is harness-granted (a message cannot add a tool); the nonce is server-minted (you cannot fabricate a valid one). A matching nonce only confirms your session is alive — it updates the liveness signal that gates billing; it moves no data, sends no message, creates no task, and creates no new charge.`;
|
|
106
|
+
return oneLine(`${header} ${body}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The two tmux invocations that inject `text` as one submitted turn into `session`'s
|
|
111
|
+
* pane: send the literal text (‑l so it is NOT parsed as key names), then a separate
|
|
112
|
+
* Enter to submit. Returns argv arrays (no shell) — caller spawns `tmux` with each.
|
|
113
|
+
*/
|
|
114
|
+
export function sendKeysCommands(session, text) {
|
|
115
|
+
return [
|
|
116
|
+
["send-keys", "-t", session, "-l", text],
|
|
117
|
+
["send-keys", "-t", session, "Enter"],
|
|
118
|
+
];
|
|
119
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# AgentRadar durable-supervisor (M3.6 H4 / #92) — the ONE process a launchd
|
|
4
|
+
# LaunchAgent (macOS) or systemd unit (Linux) keeps alive for a single always-on
|
|
5
|
+
# agent. It is GENERIC + parameterized by a per-agent env file: no agent-specific
|
|
6
|
+
# logic lives here (Spielberg's --chrome, brain path, pre-launch hooks, etc. are
|
|
7
|
+
# all env params). It idempotently ensures BOTH halves of an always-on agent are
|
|
8
|
+
# up, then blocks so KeepAlive/Restart revives the whole thing on any death:
|
|
9
|
+
#
|
|
10
|
+
# 1. a tmux session ($TMUX_SESSION) running the agent:
|
|
11
|
+
# claude --continue $CLAUDE_EXTRA_FLAGS --strict-mcp-config --mcp-config=$MCP_CONFIG
|
|
12
|
+
# in $AR_BASE, with a SCRUBBED allowlist env + a FRESH $CLAUDE_CONFIG_DIR
|
|
13
|
+
# (so the agent sees ONLY the AgentRadar MCP — no operator connectors);
|
|
14
|
+
# 2. the feeder ($FEEDER_JS) OUTSIDE that session, holding $AWP_BEARER, long-
|
|
15
|
+
# polling next_event and tmux-injecting each event into the pane.
|
|
16
|
+
#
|
|
17
|
+
# Durability: crash of either half → this script exits non-zero → KeepAlive
|
|
18
|
+
# (launchd) / Restart=always (systemd) restarts it → both re-ensured (idempotent).
|
|
19
|
+
# Power-loss reboot → RunAtLoad (launchd) / WantedBy=default.target (systemd)
|
|
20
|
+
# starts it on boot → same. Composes with the stateless AWP MCP (#111/#112:
|
|
21
|
+
# reconnect, no -32001) + `claude --continue` (context resumes) = hands-free revive.
|
|
22
|
+
#
|
|
23
|
+
# HARD RULE: claude.ai OAuth ONLY. Refuses to start if ANTHROPIC_API_KEY is set.
|
|
24
|
+
# =============================================================================
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
log() { printf '%s [supervise-agent%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${AGENT_NAME:+:$AGENT_NAME}" "$*"; }
|
|
28
|
+
die() { log "FATAL: $*"; exit 1; }
|
|
29
|
+
|
|
30
|
+
# A launchd LaunchAgent (macOS) / systemd unit gives a MINIMAL PATH (typically /usr/bin:/bin) that
|
|
31
|
+
# lacks Homebrew — where tmux / claude / node live on a dev box. Without this, `command -v tmux` fails
|
|
32
|
+
# on boot and the supervisor crash-loops ("tmux not found"). Prepend the common tool dirs (Apple-Silicon
|
|
33
|
+
# + Intel Homebrew) so the unit finds them; this PATH also flows to the scrubbed agent launch below.
|
|
34
|
+
export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
|
|
35
|
+
|
|
36
|
+
# --- 1. Load the per-agent env ------------------------------------------------
|
|
37
|
+
# Env file: $1, or $AGENT_ENV, or ~/.agentradar/agents/<name>.env
|
|
38
|
+
ENV_FILE="${1:-${AGENT_ENV:-}}"
|
|
39
|
+
if [ -z "${ENV_FILE}" ] && [ -n "${AGENT_NAME:-}" ]; then
|
|
40
|
+
ENV_FILE="${HOME}/.agentradar/agents/${AGENT_NAME}.env"
|
|
41
|
+
fi
|
|
42
|
+
[ -n "${ENV_FILE}" ] || die "no agent env file (pass a path, or set AGENT_ENV / AGENT_NAME)"
|
|
43
|
+
[ -f "${ENV_FILE}" ] || die "agent env file not found: ${ENV_FILE}"
|
|
44
|
+
set -a
|
|
45
|
+
# shellcheck source=/dev/null
|
|
46
|
+
. "${ENV_FILE}"
|
|
47
|
+
set +a
|
|
48
|
+
|
|
49
|
+
# --- 2. Validate + defaults ---------------------------------------------------
|
|
50
|
+
: "${AGENT_NAME:?AGENT_NAME required in env}"
|
|
51
|
+
: "${AR_BASE:?AR_BASE required (the agent working dir)}"
|
|
52
|
+
: "${AWP_MCP_URL:?AWP_MCP_URL required}"
|
|
53
|
+
: "${AWP_BEARER:?AWP_BEARER required}"
|
|
54
|
+
# CLAUDE_CODE_OAUTH_TOKEN is OPTIONAL. If SET → claude uses the setup-token (bills "API Usage",
|
|
55
|
+
# subject to the org spend cap). If UNSET → claude falls through to SUBSCRIPTION OAuth (keychain login,
|
|
56
|
+
# a one-time `/login`) → draws on the Max plan's included usage. Either way it's claude.ai auth only —
|
|
57
|
+
# ANTHROPIC_API_KEY is still hard-refused below (never a pay-as-you-go key).
|
|
58
|
+
: "${CLAUDE_CODE_OAUTH_TOKEN:=}"
|
|
59
|
+
: "${MCP_CONFIG:?MCP_CONFIG required (path to awp-only.json)}"
|
|
60
|
+
TMUX_SESSION="${TMUX_SESSION:-${AGENT_NAME}}"
|
|
61
|
+
CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${HOME}/.agentradar/config/${AGENT_NAME}}"
|
|
62
|
+
FEEDER_JS="${FEEDER_JS:-$(cd "$(dirname "$0")/.." && pwd)/feeder.mjs}"
|
|
63
|
+
PANE_LIVE_SH="${PANE_LIVE_SH:-$(cd "$(dirname "$0")/.." && pwd)/pane-live.sh}"
|
|
64
|
+
# #133 M4 live terminal — the OPTIONAL feeder bridge (streams the tmux pane to the portals). It's an
|
|
65
|
+
# INDEPENDENT process that NEVER touches the liveness poll loop; its death is best-effort-restarted and
|
|
66
|
+
# never exits the supervisor (the terminal view is nice-to-have; the feeder + agent are billing-critical).
|
|
67
|
+
BRIDGE_JS="${BRIDGE_JS:-$(cd "$(dirname "$0")/.." && pwd)/terminal-bridge.mjs}"
|
|
68
|
+
TERMINAL_BRIDGE_ENABLED="${TERMINAL_BRIDGE_ENABLED:-1}"
|
|
69
|
+
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
|
|
70
|
+
TMUX_BIN="${TMUX_BIN:-tmux}"
|
|
71
|
+
CLAUDE_EXTRA_FLAGS="${CLAUDE_EXTRA_FLAGS:-}" # e.g. "--chrome" (Spielberg/Sam); empty (Liza)
|
|
72
|
+
PRE_LAUNCH_HOOK="${PRE_LAUNCH_HOOK:-}" # optional per-agent script run once before claude
|
|
73
|
+
BROWSER_APP="${BROWSER_APP:-Google Chrome}" # #150: app to ensure-up for --chrome agents (macOS)
|
|
74
|
+
BROWSER_READY_MAX_SEC="${BROWSER_READY_MAX_SEC:-20}" # #150: bounded wait for the browser proc on cold boot
|
|
75
|
+
BROWSER_READY_SETTLE_SEC="${BROWSER_READY_SETTLE_SEC:-3}" # #150: settle for extension/native-host after cold launch
|
|
76
|
+
HEALTH_POLL_SEC="${HEALTH_POLL_SEC:-15}"
|
|
77
|
+
# Pane-alive predicate — MUST be unique per agent. Default to the FULL mcp-config path (each agent's
|
|
78
|
+
# differs by directory), NOT the basename: on a multi-agent box every agent's config is named
|
|
79
|
+
# `awp-only.json`, so a basename match would find ANOTHER agent's live claude and mask THIS agent's
|
|
80
|
+
# death (silent death — the exact failure #145 kills). Override per agent for extra scoping if needed.
|
|
81
|
+
AGENT_PID_PATTERN="${AGENT_PID_PATTERN:-mcp-config.*${MCP_CONFIG}}"
|
|
82
|
+
|
|
83
|
+
# Optional per-agent TOOL credentials — a vendor brain's OWN MCP-tool API keys (e.g. an SEO agent's
|
|
84
|
+
# WEBFLOW_TOKEN / PERPLEXITY_API_KEY). These are ADDED to the scrubbed launch env so the agent's own
|
|
85
|
+
# tool servers (declared in its --mcp-config) get their creds, WITHOUT widening the daemon's
|
|
86
|
+
# inheritance: AWP_BEARER and everything else the supervisor holds still NEVER reach the pane. Point
|
|
87
|
+
# AGENT_TOOL_ENV_FILE at a 0600 file of KEY=VALUE lines (blank / `#`-comment lines ignored). Only
|
|
88
|
+
# well-formed KEY=VALUE entries pass through; anything else is warned + skipped. This reproduces a
|
|
89
|
+
# non-isolated launcher that used to `. tool.env` before claude, but keeps the strict allowlist.
|
|
90
|
+
AGENT_TOOL_ENV_FILE="${AGENT_TOOL_ENV_FILE:-}"
|
|
91
|
+
TOOL_ENV_ARGS=()
|
|
92
|
+
if [ -n "${AGENT_TOOL_ENV_FILE}" ]; then
|
|
93
|
+
[ -f "${AGENT_TOOL_ENV_FILE}" ] || die "AGENT_TOOL_ENV_FILE not found: ${AGENT_TOOL_ENV_FILE}"
|
|
94
|
+
while IFS= read -r _tline || [ -n "${_tline}" ]; do
|
|
95
|
+
_tline="${_tline#"${_tline%%[![:space:]]*}"}" # left-trim whitespace
|
|
96
|
+
case "${_tline}" in ''|'#'*) continue ;; esac # skip blanks + comments
|
|
97
|
+
_tline="${_tline#export }" # tolerate a leading `export `
|
|
98
|
+
case "${_tline}" in
|
|
99
|
+
[A-Za-z_][A-Za-z0-9_]*=*) : ;; # a well-formed KEY=VALUE — fall through
|
|
100
|
+
*) log "WARN ignoring non KEY=VALUE line in ${AGENT_TOOL_ENV_FILE}"; continue ;;
|
|
101
|
+
esac
|
|
102
|
+
# Defence-in-depth: a tool file must NOT shadow a reserved supervisor/agent var (isolation +
|
|
103
|
+
# identity). Reject at parse time (visible warning) AND — belt-and-suspenders — the launch lists
|
|
104
|
+
# the reserved allowlist AFTER TOOL_ENV_ARGS so `env` last-wins keeps reserved vars authoritative.
|
|
105
|
+
case "${_tline%%=*}" in
|
|
106
|
+
HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN)
|
|
107
|
+
log "WARN ignoring reserved key '${_tline%%=*}' in ${AGENT_TOOL_ENV_FILE} (cannot override supervisor/agent env)"; continue ;;
|
|
108
|
+
esac
|
|
109
|
+
TOOL_ENV_ARGS+=("${_tline}")
|
|
110
|
+
done < "${AGENT_TOOL_ENV_FILE}"
|
|
111
|
+
log "loaded ${#TOOL_ENV_ARGS[@]} tool-env var(s) from ${AGENT_TOOL_ENV_FILE}"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# HARD RULE — OAuth only, never a pay-as-you-go API key.
|
|
115
|
+
[ -z "${ANTHROPIC_API_KEY:-}" ] || die "ANTHROPIC_API_KEY is set — refuse to start (claude.ai OAuth only)"
|
|
116
|
+
|
|
117
|
+
command -v "${TMUX_BIN}" >/dev/null 2>&1 || die "tmux not found (${TMUX_BIN})"
|
|
118
|
+
command -v "${CLAUDE_BIN}" >/dev/null 2>&1 || die "claude CLI not found (${CLAUDE_BIN})"
|
|
119
|
+
[ -f "${FEEDER_JS}" ] || die "feeder not found: ${FEEDER_JS}"
|
|
120
|
+
[ -f "${PANE_LIVE_SH}" ] || die "pane liveness check not found: ${PANE_LIVE_SH}"
|
|
121
|
+
[ -d "${AR_BASE}" ] || die "AR_BASE dir not found: ${AR_BASE}"
|
|
122
|
+
mkdir -p "${CLAUDE_CONFIG_DIR}"
|
|
123
|
+
|
|
124
|
+
# --- 2b. Credential hygiene (#152): secrets to 0600 files, OUT of argv+env ------
|
|
125
|
+
# The AWP bearer (feeder) and — when used — the OAuth setup-token (claude) must not linger in any
|
|
126
|
+
# long-lived process's ARGV (`ps aux`) or ENVIRONMENT (`ps eww`), where a co-resident process on the
|
|
127
|
+
# box could read them. We stage them into per-agent 0600 files and hand each consumer the FILE PATH
|
|
128
|
+
# (not a secret): the feeder reads AWP_BEARER_FILE; the scrubbed claude wrapper reads AR_OAUTH_TOKEN_FILE.
|
|
129
|
+
# Then we UNSET the raw vars from this supervisor's env so neither the supervisor nor the feeder it
|
|
130
|
+
# spawns inherits them. (The agent's own outbound bearer already lives only in awp-only.json @0600.)
|
|
131
|
+
_SECRETS_DIR="${HOME}/.agentradar/run/${AGENT_NAME}"
|
|
132
|
+
( umask 077; mkdir -p "${_SECRETS_DIR}" )
|
|
133
|
+
chmod 700 "${_SECRETS_DIR}" 2>/dev/null || true
|
|
134
|
+
_BEARER_FILE="${_SECRETS_DIR}/awp_bearer"
|
|
135
|
+
( umask 077; printf '%s' "${AWP_BEARER}" > "${_BEARER_FILE}" )
|
|
136
|
+
AWP_BEARER_FILE="${_BEARER_FILE}"
|
|
137
|
+
unset AWP_BEARER # drop from the supervisor's exported env → feeder no longer inherits it; ps eww clean
|
|
138
|
+
# OAuth setup-token: same treatment. `set -a; . ENV_FILE` EXPORTS CLAUDE_CODE_OAUTH_TOKEN into the
|
|
139
|
+
# supervisor env, from which the feeder (spawned WITHOUT env -i) would inherit it → ps eww leak. So when
|
|
140
|
+
# it's set, stage it to a 0600 file and UNSET the raw var here (mirror of the bearer). ensure_agent then
|
|
141
|
+
# passes ONLY AR_OAUTH_TOKEN_FILE (the path) on the scrubbed env -i line, and the sh wrapper reads it into
|
|
142
|
+
# claude's env just before exec. When UNSET (subscription-login mode) _OAUTH_TOKEN_FILE stays empty → the
|
|
143
|
+
# claude launch is a pure no-op and falls through to subscription OAuth (no API-billing regression).
|
|
144
|
+
_OAUTH_TOKEN_FILE=""
|
|
145
|
+
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
|
146
|
+
_OAUTH_TOKEN_FILE="${_SECRETS_DIR}/oauth_token"
|
|
147
|
+
( umask 077; printf '%s' "${CLAUDE_CODE_OAUTH_TOKEN}" > "${_OAUTH_TOKEN_FILE}" )
|
|
148
|
+
fi
|
|
149
|
+
unset CLAUDE_CODE_OAUTH_TOKEN # out of the supervisor+feeder env (ps eww); staged to the 0600 file above
|
|
150
|
+
# Vendor MCP-tool creds (#166): the agent's OWN tool API keys (WEBFLOW_TOKEN, PERPLEXITY_API_KEY, …) were
|
|
151
|
+
# being passed INLINE as KEY=VALUE on the env -i line → stored PLAINTEXT in tmux's pane_start_command
|
|
152
|
+
# (`tmux list-panes` = any co-resident proc) and in claude's env (ps eww). Same inline-secret class as the
|
|
153
|
+
# bearer/token. Stage the already-validated entries to a 0600 file and pass ONLY AR_TOOL_ENV_FILE (a PATH);
|
|
154
|
+
# the wrapper reads+exports them just before exec, so the VALUES never appear on the pane command / argv
|
|
155
|
+
# (still in claude's env by necessity, exactly like the OAuth token — acceptable, same-user only).
|
|
156
|
+
_TOOL_ENV_FILE=""
|
|
157
|
+
if [ "${#TOOL_ENV_ARGS[@]}" -gt 0 ]; then
|
|
158
|
+
_TOOL_ENV_FILE="${_SECRETS_DIR}/tool_env"
|
|
159
|
+
( umask 077; : > "${_TOOL_ENV_FILE}"; for _kv in "${TOOL_ENV_ARGS[@]}"; do printf '%s\n' "${_kv}" >> "${_TOOL_ENV_FILE}"; done )
|
|
160
|
+
TOOL_ENV_ARGS=() # #166: no longer passed inline on the env -i line
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
session_exists() { "${TMUX_BIN}" has-session -t "${TMUX_SESSION}" 2>/dev/null; }
|
|
164
|
+
pane_alive() {
|
|
165
|
+
TMUX_BIN="${TMUX_BIN}" TMUX_SESSION="${TMUX_SESSION}" AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" \
|
|
166
|
+
/bin/sh "${PANE_LIVE_SH}"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# #150: for a --chrome agent, ensure the browser app is up BEFORE claude launches. `claude --chrome`
|
|
170
|
+
# connects to a running browser via the native-messaging host; if the browser isn't up at connect time
|
|
171
|
+
# the connection can fail. Generalized from Sam's per-agent PRE_LAUNCH_HOOK so ANY --chrome vendor agent
|
|
172
|
+
# is robust-by-construction. Idempotent (`open -ga` never spawns a second instance; -g = don't foreground).
|
|
173
|
+
# Overridable via BROWSER_APP (default "Google Chrome"). macOS-only; documented no-op elsewhere (on Linux,
|
|
174
|
+
# launch the browser in your runner env / at login). Belt-and-suspenders alongside a Login-Items entry.
|
|
175
|
+
ensure_browser() {
|
|
176
|
+
case " ${CLAUDE_EXTRA_FLAGS} " in
|
|
177
|
+
*" --chrome "*) : ;; # --chrome as a WHOLE space-bounded token (won't false-match e.g. --chromeless)
|
|
178
|
+
*) return 0 ;; # not a --chrome agent → nothing to do
|
|
179
|
+
esac
|
|
180
|
+
if ! command -v open >/dev/null 2>&1; then
|
|
181
|
+
log "NOTE --chrome set but no macOS 'open'; ensure '${BROWSER_APP}' is running out-of-band (e.g. launch it at login in your runner env)"
|
|
182
|
+
return 0
|
|
183
|
+
fi
|
|
184
|
+
# WARM path (common): browser already up → extension/native-host already initialized, no wait needed.
|
|
185
|
+
if pgrep -f "${BROWSER_APP}" >/dev/null 2>&1; then
|
|
186
|
+
log "browser '${BROWSER_APP}' already up — --chrome can connect"
|
|
187
|
+
return 0
|
|
188
|
+
fi
|
|
189
|
+
# COLD path (#150 + disc's race fix): `open -ga` returns BEFORE the app is actually up, so claude --chrome
|
|
190
|
+
# could race ahead and hit the native-host connect dialog. Launch, then BOUNDED-wait for the browser
|
|
191
|
+
# process to appear (poll pgrep up to BROWSER_READY_MAX_SEC), then a short settle for the extension/
|
|
192
|
+
# native-host to initialize. Degrades gracefully — never hangs (proceeds with a WARN on timeout).
|
|
193
|
+
log "browser '${BROWSER_APP}' not up (cold) — launching + waiting for readiness"
|
|
194
|
+
open -ga "${BROWSER_APP}" 2>/dev/null || log "WARN could not launch '${BROWSER_APP}' (continuing; --chrome may fail to connect)"
|
|
195
|
+
_bw=0
|
|
196
|
+
while [ "${_bw}" -lt "${BROWSER_READY_MAX_SEC}" ]; do
|
|
197
|
+
if pgrep -f "${BROWSER_APP}" >/dev/null 2>&1; then
|
|
198
|
+
log "browser process up after ${_bw}s; settling ${BROWSER_READY_SETTLE_SEC}s for extension/native-host"
|
|
199
|
+
sleep "${BROWSER_READY_SETTLE_SEC}"
|
|
200
|
+
return 0
|
|
201
|
+
fi
|
|
202
|
+
sleep 1; _bw=$((_bw + 1))
|
|
203
|
+
done
|
|
204
|
+
log "WARN browser '${BROWSER_APP}' not detected within ${BROWSER_READY_MAX_SEC}s — launching --chrome anyway (it will retry-connect)"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# --- 3. Ensure the agent's tmux+claude session (idempotent) -------------------
|
|
208
|
+
ensure_agent() {
|
|
209
|
+
if session_exists && pane_alive; then
|
|
210
|
+
log "agent session '${TMUX_SESSION}' already live"
|
|
211
|
+
return 0
|
|
212
|
+
fi
|
|
213
|
+
if session_exists && ! pane_alive; then
|
|
214
|
+
log "session '${TMUX_SESSION}' exists but claude pane is dead — recreating"
|
|
215
|
+
"${TMUX_BIN}" kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
|
|
216
|
+
fi
|
|
217
|
+
ensure_browser # #150: bring up the browser first for --chrome agents (idempotent, no-op otherwise)
|
|
218
|
+
if [ -n "${PRE_LAUNCH_HOOK}" ] && [ -x "${PRE_LAUNCH_HOOK}" ]; then
|
|
219
|
+
log "running pre-launch hook: ${PRE_LAUNCH_HOOK}"
|
|
220
|
+
"${PRE_LAUNCH_HOOK}" || log "WARN pre-launch hook exited non-zero (continuing)"
|
|
221
|
+
fi
|
|
222
|
+
# SCRUBBED launch: env -i with a strict allowlist so the agent inherits nothing from this daemon
|
|
223
|
+
# (AWP_BEARER, AWP_MCP_URL, etc. never reach the pane). CONNECTOR ISOLATION is enforced by
|
|
224
|
+
# --strict-mcp-config (the agent loads ONLY $MCP_CONFIG's servers) — NOT by the config dir, which is
|
|
225
|
+
# deliberately the agent's EXISTING CLAUDE_CONFIG_DIR (often the shared ~/.claude) so `claude
|
|
226
|
+
# --continue` resumes the real conversation. The agent's own tool creds (from AGENT_TOOL_ENV_FILE) are
|
|
227
|
+
# NOT inlined here anymore (#166): they were staged to a 0600 file in 2b and are passed only as the PATH
|
|
228
|
+
# AR_TOOL_ENV_FILE, which the wrapper reads+exports before exec — so no tool VALUE appears on the env -i
|
|
229
|
+
# argv / tmux pane_start_command. Reserved keys were already rejected at tool-file parse time (2b).
|
|
230
|
+
log "starting agent in tmux '${TMUX_SESSION}' (flags: ${CLAUDE_EXTRA_FLAGS:-none}; tool-env file: ${_TOOL_ENV_FILE:-none})"
|
|
231
|
+
# SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
|
|
232
|
+
# _tool_env / _oauth_env use the ${arr[@]+"${arr[@]}"} form so an EMPTY array is safe under `set -u` (bash 3.2).
|
|
233
|
+
# First-launch handling (#153/H4 durable-feeder): `claude --continue` EXITS 1 when the cwd has NO prior
|
|
234
|
+
# conversation (a fresh engagement folder / brand-new CLAUDE_CONFIG_DIR) → the pane dies → the supervisor
|
|
235
|
+
# crash-loops. So try `--continue`, and FALL BACK to a fresh `claude` (which stays up AND creates the
|
|
236
|
+
# session) when there's nothing to resume. Self-healing: once a conversation exists, --continue resumes it;
|
|
237
|
+
# if it's ever lost, we cleanly start fresh instead of exit-looping. (--continue exits 1 on no-session,
|
|
238
|
+
# verified — so the `||` fallback is reliable.) The `/bin/sh -c` wrapper keeps EXTRA_FLAGS word-splitting.
|
|
239
|
+
# shellcheck disable=SC2086
|
|
240
|
+
_launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
|
|
241
|
+
# OAuth setup-token (#152): the token was already staged to a 0600 file + UNSET from this env in section
|
|
242
|
+
# 2b (so it's off both the supervisor+feeder env / ps eww). Here we only pass AR_OAUTH_TOKEN_FILE (a PATH,
|
|
243
|
+
# not the secret) on the env -i line — so the token is NOT in the `env`/claude argv (`ps aux`) either. The
|
|
244
|
+
# wrapper below reads the file into CLAUDE_CODE_OAUTH_TOKEN just before exec'ing claude. When it was UNSET
|
|
245
|
+
# (subscription-login mode) _OAUTH_TOKEN_FILE is empty → we pass NOTHING token-related and the wrapper's
|
|
246
|
+
# read is a no-op → claude falls through to subscription OAuth exactly as before. bash 3.2-safe array form.
|
|
247
|
+
_oauth_env=()
|
|
248
|
+
[ -n "${_OAUTH_TOKEN_FILE}" ] && _oauth_env=( AR_OAUTH_TOKEN_FILE="${_OAUTH_TOKEN_FILE}" )
|
|
249
|
+
# #166: pass the tool creds as a FILE PATH (AR_TOOL_ENV_FILE), never inline KEY=VALUE on env -i.
|
|
250
|
+
_tool_env=()
|
|
251
|
+
[ -n "${_TOOL_ENV_FILE}" ] && _tool_env=( AR_TOOL_ENV_FILE="${_TOOL_ENV_FILE}" )
|
|
252
|
+
# #1: do NOT inherit TERM. Under launchd the supervisor's TERM is `dumb` (or absent), and `dumb` is
|
|
253
|
+
# neither unset nor empty, so the old `${TERM:-xterm-256color}` default never fired and the agent's
|
|
254
|
+
# claude ran with TERM=dumb. chalk/ink then disable colour ENTIRELY, so the TUI emitted zero SGR —
|
|
255
|
+
# measured: a real agent pane captured with `capture-pane -ep` was 3216 bytes with ZERO ESC bytes,
|
|
256
|
+
# against a control pane that showed 2. That, not any client-side defect, is why the live terminal
|
|
257
|
+
# rendered monochrome in both portals. The pane lives inside tmux, so match tmux's own
|
|
258
|
+
# `default-terminal` (tmux-256color); `infocmp tmux-256color` is present on the fleet mini. Both are
|
|
259
|
+
# overridable per agent via AR_AGENT_TERM / AR_AGENT_COLORTERM — names NO shell sets, which is the
|
|
260
|
+
# whole point: defaulting off `${TERM:-…}` is exactly the construct that already failed silently.
|
|
261
|
+
#
|
|
262
|
+
# LC_CTYPE was previously passed EMPTY, which leaves the agent in the C/POSIX character type even
|
|
263
|
+
# though LANG says UTF-8 — that garbles claude's `╭─╮` / `⏵⏵` box-drawing glyphs, which only becomes
|
|
264
|
+
# visible once there is colour to look at. LC_ALL stays empty on purpose: an empty LC_ALL does not
|
|
265
|
+
# override, so LC_CTYPE governs and a vendor can still set LANG per agent.
|
|
266
|
+
#
|
|
267
|
+
# 🔴 This comment block lives ABOVE the command, not inside it. A `#` comment between two
|
|
268
|
+
# `\`-continued lines TERMINATES the command at its unescaped newline: tmux would receive only the
|
|
269
|
+
# args up to SHELL=…, the pane would start with no `claude` at all, and the `TERM=… claude` tail
|
|
270
|
+
# would run as a separate, UNISOLATED command in the supervisor's own shell. `bash -n` passes on
|
|
271
|
+
# that — it is semantic, not syntactic — and a colour gate cannot see it either, because a pane that
|
|
272
|
+
# never starts has nothing to colour. Caught by disc counting the args tmux actually received.
|
|
273
|
+
"${TMUX_BIN}" new-session -d -s "${TMUX_SESSION}" -c "${AR_BASE}" \
|
|
274
|
+
env -i \
|
|
275
|
+
HOME="${HOME}" PATH="${PATH}" USER="${USER:-}" LOGNAME="${LOGNAME:-}" SHELL="${SHELL:-/bin/sh}" \
|
|
276
|
+
TERM="${AR_AGENT_TERM:-tmux-256color}" COLORTERM="${AR_AGENT_COLORTERM:-truecolor}" \
|
|
277
|
+
LANG="${LANG:-en_US.UTF-8}" LC_ALL="${LC_ALL:-}" LC_CTYPE="${AR_AGENT_LC_CTYPE:-en_US.UTF-8}" \
|
|
278
|
+
TMPDIR="${TMPDIR:-/tmp}" NODE_OPTIONS="${NODE_OPTIONS:-}" \
|
|
279
|
+
DISABLE_AUTOUPDATER=1 \
|
|
280
|
+
${_tool_env[@]+"${_tool_env[@]}"} ${_oauth_env[@]+"${_oauth_env[@]}"} CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR}" \
|
|
281
|
+
/bin/sh -c 'if [ -n "${AR_TOOL_ENV_FILE:-}" ]; then while IFS= read -r _l || [ -n "$_l" ]; do case "$_l" in ""|\#*) continue ;; esac; _k=${_l%%=*}; _v=${_l#*=}; case "$_k" in HOME|PATH|USER|LOGNAME|SHELL|TERM|COLORTERM|LANG|LC_ALL|LC_CTYPE|TMPDIR|NODE_OPTIONS|CLAUDE_CONFIG_DIR|CLAUDE_CODE_OAUTH_TOKEN|AR_OAUTH_TOKEN_FILE|AR_TOOL_ENV_FILE|ANTHROPIC_API_KEY|AWP_BEARER|AWP_BEARER_FILE|AWP_MCP_URL|TMUX_SESSION|AGENT_PID_PATTERN) continue ;; esac; export "$_k=$_v"; done < "$AR_TOOL_ENV_FILE"; unset AR_TOOL_ENV_FILE _l _k _v; fi; if [ -n "${AR_OAUTH_TOKEN_FILE:-}" ]; then CLAUDE_CODE_OAUTH_TOKEN="$(cat "$AR_OAUTH_TOKEN_FILE")"; export CLAUDE_CODE_OAUTH_TOKEN; unset AR_OAUTH_TOKEN_FILE; fi; CB="$1"; shift; "$CB" --continue "$@" || exec "$CB" "$@"' sh "${CLAUDE_BIN}" "${_launch_flags[@]}" || {
|
|
282
|
+
log "tmux could not start agent session '${TMUX_SESSION}'"
|
|
283
|
+
return 1
|
|
284
|
+
}
|
|
285
|
+
# wait for the pane to come up
|
|
286
|
+
for _ in $(seq 1 30); do pane_alive && { log "agent pane is live"; return 0; }; sleep 1; done
|
|
287
|
+
log "agent pane did not come up within 30s"
|
|
288
|
+
return 1
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# --- 4. Run: ensure agent, run feeder in foreground, monitor both -------------
|
|
292
|
+
FEEDER_PID=""
|
|
293
|
+
cleanup() {
|
|
294
|
+
[ -n "${FEEDER_PID}" ] && kill "${FEEDER_PID}" 2>/dev/null || true
|
|
295
|
+
[ -n "${BRIDGE_PID:-}" ] && kill "${BRIDGE_PID}" 2>/dev/null || true # #133 terminal bridge
|
|
296
|
+
# #152: remove the staged 0600 secret files (bearer + optional oauth token) on exit.
|
|
297
|
+
[ -n "${_SECRETS_DIR:-}" ] && rm -rf "${_SECRETS_DIR}" 2>/dev/null || true
|
|
298
|
+
}
|
|
299
|
+
trap cleanup EXIT INT TERM
|
|
300
|
+
|
|
301
|
+
until ensure_agent; do
|
|
302
|
+
log "agent start failed — retrying in ${HEALTH_POLL_SEC}s"
|
|
303
|
+
sleep "${HEALTH_POLL_SEC}"
|
|
304
|
+
done
|
|
305
|
+
|
|
306
|
+
log "starting feeder (${FEEDER_JS}) → session '${TMUX_SESSION}'"
|
|
307
|
+
# #152: pass AWP_BEARER_FILE (a 0600 PATH), NEVER the raw AWP_BEARER — the feeder reads the file, so the
|
|
308
|
+
# bearer is not in the feeder process's environment (`ps eww`) or argv. AWP_MCP_URL is not a secret.
|
|
309
|
+
AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
|
|
310
|
+
AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" \
|
|
311
|
+
node "${FEEDER_JS}" &
|
|
312
|
+
FEEDER_PID=$!
|
|
313
|
+
log "feeder pid ${FEEDER_PID}"
|
|
314
|
+
|
|
315
|
+
# #133 M4 — start the optional feeder bridge (best-effort, isolated). Same 0600 bearer FILE + session env
|
|
316
|
+
# as the feeder; NEVER the raw bearer. Its failure NEVER affects the feeder/agent (nice-to-have terminal).
|
|
317
|
+
BRIDGE_PID=""
|
|
318
|
+
start_bridge() {
|
|
319
|
+
case "${TERMINAL_BRIDGE_ENABLED}" in
|
|
320
|
+
1|true|yes|on)
|
|
321
|
+
if [ -f "${BRIDGE_JS}" ]; then
|
|
322
|
+
AWP_MCP_URL="${AWP_MCP_URL}" AWP_BEARER_FILE="${AWP_BEARER_FILE}" TMUX_SESSION="${TMUX_SESSION}" \
|
|
323
|
+
node "${BRIDGE_JS}" &
|
|
324
|
+
BRIDGE_PID=$!
|
|
325
|
+
log "terminal bridge pid ${BRIDGE_PID}"
|
|
326
|
+
else
|
|
327
|
+
log "terminal bridge enabled but ${BRIDGE_JS} not found — skipping (pull the latest template)"
|
|
328
|
+
fi
|
|
329
|
+
;;
|
|
330
|
+
*) log "terminal bridge disabled (TERMINAL_BRIDGE_ENABLED=${TERMINAL_BRIDGE_ENABLED})" ;;
|
|
331
|
+
esac
|
|
332
|
+
}
|
|
333
|
+
start_bridge
|
|
334
|
+
|
|
335
|
+
# Monitor. Recover the agent pane in place so direct supervisor invocation survives pane death too;
|
|
336
|
+
# launchd/systemd remains the parent-level/reboot safety net. The feeder self-pauses while the owned pane
|
|
337
|
+
# next_event heartbeat cannot paint the runner Live during recovery. Restart the bridge after a new pane is
|
|
338
|
+
# created because tmux pipe-pane was bound to the dead pane.
|
|
339
|
+
while true; do
|
|
340
|
+
if ! kill -0 "${FEEDER_PID}" 2>/dev/null; then
|
|
341
|
+
log "feeder exited — exiting so the service manager restarts the whole unit"
|
|
342
|
+
exit 1
|
|
343
|
+
fi
|
|
344
|
+
if ! pane_alive; then
|
|
345
|
+
log "agent claude pane died — recovering it in place"
|
|
346
|
+
if ensure_agent; then
|
|
347
|
+
if [ -n "${BRIDGE_PID}" ]; then
|
|
348
|
+
kill "${BRIDGE_PID}" 2>/dev/null || true
|
|
349
|
+
wait "${BRIDGE_PID}" 2>/dev/null || true
|
|
350
|
+
BRIDGE_PID=""
|
|
351
|
+
fi
|
|
352
|
+
start_bridge
|
|
353
|
+
else
|
|
354
|
+
log "agent recovery failed — retrying in ${HEALTH_POLL_SEC}s"
|
|
355
|
+
fi
|
|
356
|
+
fi
|
|
357
|
+
if [ -n "${BRIDGE_PID}" ] && ! kill -0 "${BRIDGE_PID}" 2>/dev/null; then
|
|
358
|
+
log "terminal bridge exited — restarting in place (best-effort; does not affect feeder/agent)"
|
|
359
|
+
start_bridge
|
|
360
|
+
fi
|
|
361
|
+
sleep "${HEALTH_POLL_SEC}"
|
|
362
|
+
done
|