agent-relay-server 0.123.1 → 0.123.2

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 CHANGED
@@ -477,7 +477,9 @@ client/ # TypeScript client (npm: agent-relay-clien
477
477
  ## Development
478
478
 
479
479
  See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and
480
- [SECURITY.md](SECURITY.md) for vulnerability reporting.
480
+ [SECURITY.md](SECURITY.md) for vulnerability reporting. Before a feature counts as
481
+ shipped, work it through the [Feature Delivery Guide](docs/feature-delivery.md) — the
482
+ end-to-end Definition of Done that stops assembly-correct-but-dead features.
481
483
 
482
484
  ```bash
483
485
  git clone https://github.com/edimuj/agent-relay.git
package/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.123.1",
5
+ "version": "0.123.2",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.123.1",
3
+ "version": "0.123.2",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -28,6 +28,7 @@
28
28
  "scripts/orchestrator-spawn-smoke.ts",
29
29
  "runner/src/**/*.ts",
30
30
  "runner/plugins/**",
31
+ "runner/hooks/**",
31
32
  "!runner/**/*.test.ts",
32
33
  "public/**",
33
34
  "docs/openapi.json",
@@ -36,7 +37,7 @@
36
37
  "CONTRIBUTING.md"
37
38
  ],
38
39
  "dependencies": {
39
- "agent-relay-channels-host": "0.123.1",
40
+ "agent-relay-channels-host": "0.123.2",
40
41
  "agent-relay-providers": "0.104.4",
41
42
  "agent-relay-sdk": "0.2.115",
42
43
  "ajv": "^8.20.0"
@@ -0,0 +1,18 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${HOOK_DIR}/rg-r-guard.cjs\"",
10
+ "timeout": 5,
11
+ "statusMessage": "Checking ripgrep replace guard",
12
+ "suppressOutput": true
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ }
18
+ }
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ let input = "";
5
+ process.stdin.setEncoding("utf8");
6
+ process.stdin.on("data", (chunk) => {
7
+ input += chunk;
8
+ });
9
+ process.stdin.on("end", () => {
10
+ let payload;
11
+ try {
12
+ payload = JSON.parse(input || "{}");
13
+ } catch {
14
+ return;
15
+ }
16
+
17
+ const toolName = String(payload.tool_name || payload.toolName || "").toLowerCase();
18
+ const command = String(payload.tool_input?.command || payload.toolInput?.command || "");
19
+ if (toolName !== "bash" || !command || !containsRipgrepReplaceFootgun(command)) return;
20
+
21
+ const reason = "Blocked: `rg -r` is ripgrep replace, not recursive search. Use `rg PATTERN PATH` for search, or `rg --replace REPLACEMENT PATTERN` only when replacement output is intentional.";
22
+ process.stdout.write(JSON.stringify({
23
+ hookSpecificOutput: {
24
+ hookEventName: "PreToolUse",
25
+ permissionDecision: "deny",
26
+ permissionDecisionReason: reason,
27
+ },
28
+ }));
29
+ process.stdout.write("\n");
30
+ });
31
+
32
+ function containsRipgrepReplaceFootgun(command) {
33
+ for (const segment of splitShellSegments(command)) {
34
+ const words = shellWords(segment);
35
+ for (let i = 0; i < words.length; i += 1) {
36
+ const word = basename(words[i] || "");
37
+ if (word !== "rg" && word !== "ripgrep") continue;
38
+ if (ripgrepArgsUseReplace(words.slice(i + 1))) return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ function ripgrepArgsUseReplace(args) {
45
+ for (let i = 0; i < args.length; i += 1) {
46
+ const arg = args[i] || "";
47
+ if (arg === "--") return false;
48
+ if (arg === "--replace" || arg.startsWith("--replace=")) return true;
49
+ if (arg === "-r") return true;
50
+ if (/^-([A-Za-z]*r[A-Za-z]*|[A-Za-z]*r=.+)$/.test(arg)) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ function splitShellSegments(text) {
56
+ const segments = [];
57
+ let current = "";
58
+ let quote = "";
59
+ let escaped = false;
60
+ for (let i = 0; i < text.length; i += 1) {
61
+ const ch = text[i];
62
+ if (escaped) {
63
+ current += ch;
64
+ escaped = false;
65
+ continue;
66
+ }
67
+ if (ch === "\\") {
68
+ current += ch;
69
+ escaped = true;
70
+ continue;
71
+ }
72
+ if (quote) {
73
+ current += ch;
74
+ if (ch === quote) quote = "";
75
+ continue;
76
+ }
77
+ if (ch === "'" || ch === "\"") {
78
+ quote = ch;
79
+ current += ch;
80
+ continue;
81
+ }
82
+ // Command-substitution + grouping boundaries (`$(rg -r …)`, backtick `` `rg -r …` ``,
83
+ // subshells) split into their own segments so the inner command is scanned on its own
84
+ // (basename of `$(rg` ≠ rg otherwise) and its args never bleed into the outer command's
85
+ // scan. Only fires OUTSIDE quotes — a single/double-quoted literal is left intact, matching
86
+ // the tokenizer's existing quote handling. Known gap: substitution inside double quotes
87
+ // ("$(rg -r x)") is still treated as an opaque quoted word.
88
+ if (ch === "(" || ch === ")" || ch === "`") {
89
+ if (current.trim()) segments.push(current);
90
+ current = "";
91
+ continue;
92
+ }
93
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
94
+ if (current.trim()) segments.push(current);
95
+ current = "";
96
+ continue;
97
+ }
98
+ current += ch;
99
+ }
100
+ if (current.trim()) segments.push(current);
101
+ return segments;
102
+ }
103
+
104
+ function shellWords(text) {
105
+ const words = [];
106
+ let current = "";
107
+ let quote = "";
108
+ let escaped = false;
109
+ for (let i = 0; i < text.length; i += 1) {
110
+ const ch = text[i];
111
+ if (escaped) {
112
+ current += ch;
113
+ escaped = false;
114
+ continue;
115
+ }
116
+ if (ch === "\\") {
117
+ escaped = true;
118
+ continue;
119
+ }
120
+ if (quote) {
121
+ if (ch === quote) quote = "";
122
+ else current += ch;
123
+ continue;
124
+ }
125
+ if (ch === "'" || ch === "\"") {
126
+ quote = ch;
127
+ continue;
128
+ }
129
+ if (/\s/.test(ch)) {
130
+ if (current) {
131
+ words.push(current);
132
+ current = "";
133
+ }
134
+ continue;
135
+ }
136
+ current += ch;
137
+ }
138
+ if (current) words.push(current);
139
+ return words;
140
+ }
141
+
142
+ function basename(value) {
143
+ return value.split("/").pop() || value;
144
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.123.1",
4
+ "version": "0.123.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,134 @@
1
+ // Real-chain spawn inspector (#1103 L1 seed) — the regression harness whose absence let two
2
+ // dead #1096 fixes ship. It drives the ACTUAL spawn chain for a named profile, end to end, with
3
+ // NO hand-fed config objects:
4
+ //
5
+ // getAgentProfile(name) — the real built-in/stored profile
6
+ // → withResolvedProvisioning — relay-side registry resolution (needs a seeded DB)
7
+ // → buildSpawnCommand — the command params the orchestrator dispatches
8
+ // → JSON round-trip — the command-bus / AGENT_RELAY_AGENT_PROFILE_JSON env seam
9
+ // → spawnOptionsFromControl — orchestrator re-parse of the command
10
+ // → ClaudeAdapter.buildSpawnArgs — runner assembly + hook/skill/plugin MATERIALIZATION to disk
11
+ //
12
+ // then reads back the EFFECTIVE on-disk artifacts: the written managed `settings.json`, the
13
+ // materialized `relay-provisioned-hooks/` tree, and the assembled argv. Two prior fixes each
14
+ // tested one half in isolation (relay resolution OR runner materialization) with a hand-fed
15
+ // `provisioning.hooks`, so neither caught that the bundled hook asset is never SHIPPED (the
16
+ // `runner/hooks/**` packaging gap) and so never seeds → resolves empty on a real deployed spawn.
17
+ // This helper runs the whole chain against the real DB + real package layout, so that class of
18
+ // break surfaces as a red on-disk artifact instead of a green half-test.
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import type { SpawnProvider } from "agent-relay-sdk";
23
+ import { withResolvedProvisioning } from "../db";
24
+ import { getAgentProfile } from "../config-store";
25
+ import { buildSpawnCommand } from "../spawn-command";
26
+ import { spawnOptionsFromControl } from "../../orchestrator/src/control";
27
+ import { ClaudeAdapter } from "../../runner/src/adapters/claude";
28
+ import { defaultProviderConfig } from "../../runner/src/config";
29
+ import type { RunnerSpawnConfig } from "../../runner/src/adapter";
30
+
31
+ /** The bundled baseline safety hook whose silent absence is the #1096 bug. */
32
+ export const BASELINE_HOOK_REF = "agent-relay-rg-r-guard";
33
+ export const BASELINE_HOOK_SCRIPT = "rg-r-guard.cjs";
34
+
35
+ export interface SpawnChainInspection {
36
+ profile: string;
37
+ provider: SpawnProvider;
38
+ /** The isolated managed CLAUDE_CONFIG_DIR the runner assembled + materialized into. */
39
+ configHome: string | undefined;
40
+ /** Absolute path of the managed settings.json (whether or not it exists). */
41
+ settingsPath: string | undefined;
42
+ /** Parsed on-disk settings.json (null when relay wrote none). */
43
+ settings: Record<string, unknown> | null;
44
+ /** The effective launch argv the adapter would exec. */
45
+ argv: string[];
46
+ /** The launch command (claude / claude-rig). */
47
+ command: string;
48
+ /** relay-provisioned-hooks/<ref> dir for the baseline guard. */
49
+ baselineHookAssetDir: string | undefined;
50
+ baselineHookAssetExists: boolean;
51
+ baselineHookScriptExists: boolean;
52
+ /** True iff the WRITTEN settings.json (the documented, transit-safe hook source) carries the
53
+ * baseline PreToolUse guard — the #1096 proof-of-life bar. */
54
+ settingsHasBaselineHook: boolean;
55
+ /** True iff SOME launch channel (written settings.json OR inline --settings) carries it. */
56
+ launchHasBaselineHook: boolean;
57
+ }
58
+
59
+ function settingsCarriesBaselineHook(settings: Record<string, unknown> | null): boolean {
60
+ const pre = (settings?.hooks as { PreToolUse?: unknown } | undefined)?.PreToolUse;
61
+ if (!Array.isArray(pre)) return false;
62
+ return JSON.stringify(pre).includes(BASELINE_HOOK_SCRIPT);
63
+ }
64
+
65
+ function argvCarriesBaselineHook(argv: string[]): boolean {
66
+ const idx = argv.findIndex((a) => a === "--settings");
67
+ if (idx < 0 || idx + 1 >= argv.length) return false;
68
+ return argv[idx + 1]!.includes(BASELINE_HOOK_SCRIPT);
69
+ }
70
+
71
+ /**
72
+ * Run the real spawn chain for a profile and read back the on-disk artifacts. Two preconditions,
73
+ * both owned by the caller so this stays a pure harness (no global/env mutation of its own):
74
+ * • the DB is seeded (initDb) so registry resolution can find the bundled hook, and
75
+ * • AGENT_RELAY_PROVIDER_HOME_ROOT points at a scratch dir (the runner's config module reads it
76
+ * to place isolated provider homes) — set it like launch-assembly.test.ts does.
77
+ */
78
+ export function inspectSpawnChain(opts: {
79
+ profile: string;
80
+ provider: SpawnProvider;
81
+ cwd: string;
82
+ approvalMode?: string;
83
+ }): SpawnChainInspection {
84
+ const { profile, provider, cwd } = opts;
85
+
86
+ // 1. RELAY: resolve the named profile's provisioning against the registry.
87
+ const resolved = withResolvedProvisioning(getAgentProfile(profile)?.value, provider);
88
+ if (!resolved) throw new Error(`profile not found: ${profile}`);
89
+
90
+ // 2. RELAY: build the spawn command params the orchestrator dispatches.
91
+ const cmdParams = buildSpawnCommand({
92
+ provider, cwd, agentProfile: resolved, env: {}, requestedBy: "spawn-chain-inspector",
93
+ } as Parameters<typeof buildSpawnCommand>[0]);
94
+
95
+ // 3. SEAM: JSON round-trip through the command bus / AGENT_RELAY_AGENT_PROFILE_JSON env var.
96
+ const roundTripped = JSON.parse(JSON.stringify(cmdParams)) as Record<string, unknown>;
97
+
98
+ // 4. ORCHESTRATOR: re-parse the command into spawn options.
99
+ const spawnOpts = spawnOptionsFromControl(roundTripped, { providers: [provider], baseDir: cwd } as never);
100
+
101
+ // 5. RUNNER: assemble the RunnerSpawnConfig the adapter consumes.
102
+ const providerConfig = defaultProviderConfig(provider);
103
+ const config = {
104
+ provider, runnerId: "inspector", instanceId: `insp-${profile}`, agentId: "insp",
105
+ relayUrl: "http://localhost:4850", cwd, headless: true,
106
+ approvalMode: opts.approvalMode ?? "open", providerArgs: [], providerConfig,
107
+ env: {}, controlPort: 4900,
108
+ agentProfile: spawnOpts.agentProfile,
109
+ } as unknown as RunnerSpawnConfig;
110
+
111
+ // 6. RUNNER: the REAL adapter path — prepares the home, materializes provisioned assets to
112
+ // disk, and returns the launch argv (exactly what the runner execs).
113
+ const spawnArgs = new ClaudeAdapter().buildSpawnArgs(config, providerConfig);
114
+
115
+ // 7. Read back the effective on-disk artifacts.
116
+ const configHome = spawnArgs.env.CLAUDE_CONFIG_DIR;
117
+ const settingsPath = configHome ? join(configHome, "settings.json") : undefined;
118
+ const settings = settingsPath && existsSync(settingsPath)
119
+ ? (JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>)
120
+ : null;
121
+ const baselineHookAssetDir = configHome
122
+ ? join(configHome, "relay-provisioned-hooks", BASELINE_HOOK_REF)
123
+ : undefined;
124
+
125
+ return {
126
+ profile, provider, configHome, settingsPath, settings,
127
+ argv: spawnArgs.args, command: spawnArgs.command,
128
+ baselineHookAssetDir,
129
+ baselineHookAssetExists: Boolean(baselineHookAssetDir && existsSync(baselineHookAssetDir)),
130
+ baselineHookScriptExists: Boolean(baselineHookAssetDir && existsSync(join(baselineHookAssetDir, BASELINE_HOOK_SCRIPT))),
131
+ settingsHasBaselineHook: settingsCarriesBaselineHook(settings),
132
+ launchHasBaselineHook: settingsCarriesBaselineHook(settings) || argvCarriesBaselineHook(spawnArgs.args),
133
+ };
134
+ }