Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f56adae0 | ||
|
|
89dd1702b6 | ||
|
|
cd65e8e755 | ||
|
|
28e547f120 | ||
|
|
05a254746e | ||
|
|
529372f762 | ||
|
|
3b18efdd25 | ||
|
|
6e044b5f2f | ||
|
|
310f916675 | ||
|
|
41d56c06b9 |
No files matched your search
+3
-1
@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
@@ -19,14 +20,15 @@ Docs: https://docs.clawd.bot
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
|
||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||
- Zalouser: parse zca JSON output when logs or ANSI codes are present. (#1379) Thanks @ptn1411.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]?
|
||||
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable]?,
|
||||
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.device = device
|
||||
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
case device
|
||||
|
||||
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]?
|
||||
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable]?,
|
||||
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.device = device
|
||||
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
case device
|
||||
|
||||
@@ -87,6 +87,7 @@ If a prompt is required but no UI is reachable, fallback decides:
|
||||
|
||||
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
||||
|
||||
Examples:
|
||||
- `~/Projects/**/bin/bird`
|
||||
@@ -104,6 +105,15 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||
|
||||
## Safe bins (stdin-only)
|
||||
|
||||
`tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`)
|
||||
that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject
|
||||
positional file args and path-like tokens, so they can only operate on the incoming stream.
|
||||
Shell chaining and redirections are not auto-allowed in allowlist mode.
|
||||
|
||||
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
|
||||
|
||||
## Control UI editing
|
||||
|
||||
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
||||
|
||||
+10
-1
@@ -43,6 +43,7 @@ Notes:
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
@@ -64,7 +65,8 @@ Example:
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||
if the exec call already sets `env.PATH`.
|
||||
if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
|
||||
the node host PATH (no replacement).
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
@@ -90,6 +92,13 @@ Example:
|
||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||
|
||||
## Allowlist + safe bins
|
||||
|
||||
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
|
||||
`security=allowlist`, shell commands are auto-allowed only if every pipeline segment is
|
||||
allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in
|
||||
allowlist mode.
|
||||
|
||||
## Examples
|
||||
|
||||
Foreground:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseJsonOutput } from "./zca.js";
|
||||
|
||||
describe("parseJsonOutput", () => {
|
||||
it("parses plain JSON output", () => {
|
||||
expect(parseJsonOutput<{ ok: boolean }>('{"ok":true}')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("parses JSON wrapped in ANSI codes", () => {
|
||||
const output = "\u001B[32m{\"ok\":true}\u001B[0m";
|
||||
expect(parseJsonOutput<{ ok: boolean }>(output)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("parses JSON after log prefix lines", () => {
|
||||
const output = ["INFO starting up", "{\"items\":[1,2]}"].join("\n");
|
||||
expect(parseJsonOutput<{ items: number[] }>(output)).toEqual({ items: [1, 2] });
|
||||
});
|
||||
|
||||
it("skips invalid JSON blocks and returns the next payload", () => {
|
||||
const output = ["INFO", "{bad}", "{\"ok\":true}"].join("\n");
|
||||
expect(parseJsonOutput<{ ok: boolean }>(output)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("returns null when no JSON payload is found", () => {
|
||||
expect(parseJsonOutput("INFO no payload")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -114,11 +114,36 @@ export function runZcaInteractive(
|
||||
});
|
||||
}
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
||||
}
|
||||
|
||||
export function parseJsonOutput<T>(stdout: string): T | null {
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch {
|
||||
return null;
|
||||
const cleaned = stripAnsi(stdout);
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as T;
|
||||
} catch {
|
||||
// zca may prefix output with INFO/log lines, try to find JSON
|
||||
const lines = cleaned.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith("{") || line.startsWith("[")) {
|
||||
// Try parsing from this line to the end
|
||||
const jsonCandidate = lines.slice(i).join("\n").trim();
|
||||
try {
|
||||
return JSON.parse(jsonCandidate) as T;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
type ExecAsk,
|
||||
type ExecHost,
|
||||
type ExecSecurity,
|
||||
type ExecAllowlistEntry,
|
||||
addAllowlistEntry,
|
||||
analyzeShellCommand,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveSafeBins,
|
||||
recordAllowlistUse,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
@@ -94,6 +97,7 @@ export type ExecToolDefaults = {
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
agentId?: string;
|
||||
backgroundMs?: number;
|
||||
timeoutSec?: number;
|
||||
@@ -298,6 +302,7 @@ export function createExecTool(
|
||||
? defaults.timeoutSec
|
||||
: 1800;
|
||||
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
|
||||
const safeBins = resolveSafeBins(defaults?.safeBins);
|
||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||
|
||||
@@ -593,13 +598,27 @@ export function createExecTool(
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=gateway security=deny");
|
||||
}
|
||||
|
||||
const resolution = resolveCommandResolution(params.command, workdir, env);
|
||||
const allowlistMatch =
|
||||
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
let allowlistSatisfied = false;
|
||||
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
|
||||
allowlistSatisfied = analysis.segments.every((segment) => {
|
||||
const match = matchAllowlist(approvals.allowlist, segment.resolution);
|
||||
if (match) allowlistMatches.push(match);
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins,
|
||||
cwd: workdir,
|
||||
});
|
||||
return Boolean(match || safe);
|
||||
});
|
||||
}
|
||||
const requiresAsk =
|
||||
hostAsk === "always" ||
|
||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
||||
(hostAsk === "on-miss" &&
|
||||
hostSecurity === "allowlist" &&
|
||||
(!analysis.ok || !allowlistSatisfied));
|
||||
|
||||
let approvedByAsk = false;
|
||||
if (requiresAsk) {
|
||||
@@ -613,7 +632,7 @@ export function createExecTool(
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath: resolution?.resolvedPath ?? null,
|
||||
resolvedPath: analysis.segments[0]?.resolution?.resolvedPath ?? null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
@@ -630,7 +649,7 @@ export function createExecTool(
|
||||
if (askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
} else if (askFallback === "allowlist") {
|
||||
if (!allowlistMatch) {
|
||||
if (!analysis.ok || !allowlistSatisfied) {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
approvedByAsk = true;
|
||||
@@ -644,30 +663,37 @@ export function createExecTool(
|
||||
if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
if (hostSecurity === "allowlist") {
|
||||
const pattern =
|
||||
resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
params.command.split(/\s+/).shift() ??
|
||||
"";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
|
||||
if (
|
||||
hostSecurity === "allowlist" &&
|
||||
(!analysis.ok || !allowlistSatisfied) &&
|
||||
!approvedByAsk
|
||||
) {
|
||||
throw new Error("exec denied: allowlist miss");
|
||||
}
|
||||
|
||||
if (allowlistMatch) {
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
allowlistMatch,
|
||||
params.command,
|
||||
resolution?.resolvedPath,
|
||||
);
|
||||
if (allowlistMatches.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const match of allowlistMatches) {
|
||||
if (seen.has(match.pattern)) continue;
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
match,
|
||||
params.command,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ describe("extractModelDirective", () => {
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("does not extract /models with argument", () => {
|
||||
it("extracts /models with argument", () => {
|
||||
const result = extractModelDirective("/models gpt-5");
|
||||
expect(result.hasDirective).toBe(false);
|
||||
expect(result.cleaned).toBe("/models gpt-5");
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.rawModel).toBe("gpt-5");
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("extracts /model with provider/model format", () => {
|
||||
@@ -113,11 +114,10 @@ describe("extractModelDirective", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("extracts models with multiple path segments", () => {
|
||||
it("preserves spacing when /model is followed by a path segment", () => {
|
||||
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.rawModel).toBe("gpt-5/tmp/hello");
|
||||
expect(result.cleaned).toBe("thats not");
|
||||
expect(result.cleaned).toBe("thats not /hello");
|
||||
});
|
||||
|
||||
it("handles alias with special regex characters", () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function extractModelDirective(
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
|
||||
const modelMatch = body.match(
|
||||
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
|
||||
);
|
||||
|
||||
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
||||
|
||||
+14
-73
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lists allowlisted models on /models <provider>", async () => {
|
||||
it("lists allowlisted models on /model list", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -84,29 +84,9 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Models (anthropic)");
|
||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
|
||||
const openaiRes = await getReplyFromConfig(
|
||||
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
|
||||
expect(openaiText).toContain("Models (openai)");
|
||||
expect(openaiText).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -117,7 +97,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -135,29 +115,9 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Models (anthropic)");
|
||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
|
||||
const openaiRes = await getReplyFromConfig(
|
||||
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
|
||||
expect(openaiText).toContain("Models (openai)");
|
||||
expect(openaiText).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -172,7 +132,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -190,29 +150,10 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Models (xai)");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||
expect(text).toContain("xai/grok-4");
|
||||
|
||||
const minimaxRes = await getReplyFromConfig(
|
||||
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: ["openai/gpt-4.1-mini"],
|
||||
},
|
||||
imageModel: { primary: "minimax/MiniMax-M2.1" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text;
|
||||
expect(minimaxText).toContain("Models (minimax)");
|
||||
expect(minimaxText).toContain("minimax/MiniMax-M2.1");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -232,7 +173,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -261,7 +202,8 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Models (minimax)");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -289,7 +231,6 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model listing moved.");
|
||||
expect(text).not.toContain("missing (missing)");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+2
-2
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("selects exact alias matches even when ambiguous", async () => {
|
||||
it("prefers alias matches when fuzzy selection is ambiguous", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+15
-15
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
@@ -94,10 +94,10 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
assertModelSelection(storePath, {
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "/model kimi-k2-0905-preview",
|
||||
From: "+1222",
|
||||
@@ -140,10 +140,10 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
assertModelSelection(storePath, {
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -152,7 +152,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
@@ -181,10 +181,10 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
assertModelSelection(storePath, {
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+5
-4
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
it("shows a model summary on /model", async () => {
|
||||
it("lists allowlisted models on /model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
@@ -211,9 +211,10 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Browse: /models");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).not.toContain("claude-sonnet-4-1");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+15
-15
@@ -95,7 +95,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("shows a /model summary with browse hints", async () => {
|
||||
it("shows a /model summary and points to /models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -116,17 +116,19 @@ describe("trigger handling", () => {
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(normalized).toContain("Browse: /models");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
expect(normalized).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||
expect(normalized).toContain("More: /model status");
|
||||
expect(normalized).not.toContain("reasoning");
|
||||
expect(normalized).not.toContain("image");
|
||||
});
|
||||
});
|
||||
it("lists providers on /models", async () => {
|
||||
it("moves /model list to /models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/models",
|
||||
Body: "/model list",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
@@ -141,12 +143,9 @@ describe("trigger handling", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Providers:");
|
||||
expect(normalized).toContain("anthropic");
|
||||
expect(normalized).toContain("openrouter");
|
||||
expect(normalized).toContain("openai");
|
||||
expect(normalized).toContain("openai-codex");
|
||||
expect(normalized).toContain("minimax");
|
||||
expect(normalized).toContain("Model listing moved.");
|
||||
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
});
|
||||
});
|
||||
it("selects the exact provider/model pair for openrouter", async () => {
|
||||
@@ -200,16 +199,17 @@ describe("trigger handling", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(normalizeTestText(text ?? "")).toContain(
|
||||
"Numeric model selection is not supported in chat.",
|
||||
);
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Numeric model selection is not supported in chat.");
|
||||
expect(normalized).toContain("Browse: /models or /models <provider>");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
|
||||
const store = loadSessionStore(cfg.session.store);
|
||||
expect(store[sessionKey]?.providerOverride).toBeUndefined();
|
||||
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
||||
});
|
||||
});
|
||||
it("selects exact provider/model combo via /model <provider/model>", async () => {
|
||||
it("resets to the default model via /model <provider/model>", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const sessionKey = "telegram:slash:111";
|
||||
@@ -231,7 +231,7 @@ describe("trigger handling", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(normalizeTestText(text ?? "")).toContain(
|
||||
"Model reset to default (anthropic/claude-opus-4-5).",
|
||||
"Model reset to default (anthropic/claude-opus-4-5)",
|
||||
);
|
||||
|
||||
const store = loadSessionStore(cfg.session.store);
|
||||
|
||||
@@ -80,47 +80,14 @@ describe("/models command", () => {
|
||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||
});
|
||||
|
||||
it("includes configured providers and defaults", async () => {
|
||||
const configuredCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "synthetic/synth-1",
|
||||
fallbacks: ["synthetic/synth-2"],
|
||||
},
|
||||
imageModel: {
|
||||
primary: "synthetic/synth-image",
|
||||
fallbacks: ["synthetic/synth-image-2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://example.com",
|
||||
models: [
|
||||
{
|
||||
id: "synth-3",
|
||||
name: "Synth 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
const providersResult = await handleCommands(buildParams("/models", configuredCfg));
|
||||
expect(providersResult.shouldContinue).toBe(false);
|
||||
expect(providersResult.reply?.text).toContain("synthetic");
|
||||
|
||||
const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg));
|
||||
expect(modelsResult.shouldContinue).toBe(false);
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-1");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-2");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-3");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-image");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2");
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
const params = buildParams("/models anthropic 3 all", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/1");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).not.toContain("Page out of range");
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
@@ -138,4 +105,29 @@ describe("/models command", () => {
|
||||
expect(result.reply?.text).toContain("Unknown provider");
|
||||
expect(result.reply?.text).toContain("Available providers");
|
||||
});
|
||||
|
||||
it("lists configured models outside the curated catalog", async () => {
|
||||
const customCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "localai/ultra-chat",
|
||||
fallbacks: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
imageModel: "visionpro/studio-v1",
|
||||
},
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
const providerList = await handleCommands(buildParams("/models", customCfg));
|
||||
expect(providerList.reply?.text).toContain("localai");
|
||||
expect(providerList.reply?.text).toContain("visionpro");
|
||||
|
||||
const result = await handleCommands(buildParams("/models localai", customCfg));
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (localai)");
|
||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||
expect(result.reply?.text).not.toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
@@ -17,15 +17,6 @@ function formatProviderLine(params: { provider: string; count: number }): string
|
||||
return `- ${params.provider} (${params.count})`;
|
||||
}
|
||||
|
||||
function addModelRef(map: Map<string, Set<string>>, provider: string, model: string): void {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const normalizedModel = String(model ?? "").trim();
|
||||
if (!normalizedProvider || !normalizedModel) return;
|
||||
const set = map.get(normalizedProvider) ?? new Set<string>();
|
||||
set.add(normalizedModel);
|
||||
map.set(normalizedProvider, set);
|
||||
}
|
||||
|
||||
function parseModelsArgs(raw: string): {
|
||||
provider?: string;
|
||||
page: number;
|
||||
@@ -100,57 +91,66 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const addRaw = (raw?: string) => {
|
||||
const value = String(raw ?? "").trim();
|
||||
if (!value) return;
|
||||
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const add = (p: string, m: string) => {
|
||||
const key = normalizeProviderId(p);
|
||||
const set = byProvider.get(key) ?? new Set<string>();
|
||||
set.add(m);
|
||||
byProvider.set(key, set);
|
||||
};
|
||||
|
||||
const addRawModelRef = (raw?: string) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return;
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: value,
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
addModelRef(byProvider, resolved.ref.provider, resolved.ref.model);
|
||||
add(resolved.ref.provider, resolved.ref.model);
|
||||
};
|
||||
|
||||
const addModelConfigEntries = () => {
|
||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||
if (typeof modelConfig === "string") {
|
||||
addRawModelRef(modelConfig);
|
||||
} else if (modelConfig && typeof modelConfig === "object") {
|
||||
addRawModelRef(modelConfig.primary);
|
||||
for (const fallback of modelConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
||||
if (typeof imageConfig === "string") {
|
||||
addRawModelRef(imageConfig);
|
||||
} else if (imageConfig && typeof imageConfig === "object") {
|
||||
addRawModelRef(imageConfig.primary);
|
||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of allowed.allowedCatalog) {
|
||||
addModelRef(byProvider, entry.provider, entry.id);
|
||||
}
|
||||
|
||||
addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model);
|
||||
|
||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||
const modelFallbacks =
|
||||
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
|
||||
for (const fallback of modelFallbacks) {
|
||||
addRaw(String(fallback ?? ""));
|
||||
}
|
||||
|
||||
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
||||
if (imageConfig) {
|
||||
if (typeof imageConfig === "string") {
|
||||
addRaw(imageConfig);
|
||||
} else {
|
||||
addRaw(imageConfig.primary);
|
||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||
addRaw(String(fallback ?? ""));
|
||||
}
|
||||
}
|
||||
add(entry.provider, entry.id);
|
||||
}
|
||||
|
||||
// Include config-only allowlist keys that aren't in the curated catalog.
|
||||
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
||||
addRaw(String(raw ?? ""));
|
||||
addRawModelRef(raw);
|
||||
}
|
||||
|
||||
for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) {
|
||||
for (const modelDef of providerConfig?.models ?? []) {
|
||||
addModelRef(byProvider, provider, modelDef?.id ?? "");
|
||||
}
|
||||
}
|
||||
// Ensure configured defaults/fallbacks/image models show up even when the
|
||||
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
||||
add(resolvedDefault.provider, resolvedDefault.model);
|
||||
addModelConfigEntries();
|
||||
|
||||
const providers = [...byProvider.keys()].sort();
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
||||
if (Math.abs(aLen - bLen) > maxDistance) return null;
|
||||
|
||||
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
|
||||
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
|
||||
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
|
||||
const curr = Array.from({ length: bLen + 1 }, () => 0);
|
||||
|
||||
for (let i = 1; i <= aLen; i++) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { startGatewayServer } from "../../gateway/server.js";
|
||||
import { acquireGatewayLock } from "../../infra/gateway-lock.js";
|
||||
import {
|
||||
consumeGatewaySigusr1RestartAuthorization,
|
||||
isGatewaySigusr1RestartExternallyAllowed,
|
||||
@@ -14,6 +15,7 @@ export async function runGatewayLoop(params: {
|
||||
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
||||
runtime: typeof defaultRuntime;
|
||||
}) {
|
||||
const lock = await acquireGatewayLock();
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||
let shuttingDown = false;
|
||||
let restartResolver: (() => void) | null = null;
|
||||
@@ -96,6 +98,7 @@ export async function runGatewayLoop(params: {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await lock?.release();
|
||||
cleanupSignals();
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const callGateway = vi.fn(async (opts: { method?: string }) => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (opts.method === "exec.approvals.get") {
|
||||
if (opts.method === "exec.approvals.node.get") {
|
||||
return {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
exists: true,
|
||||
|
||||
@@ -38,6 +38,7 @@ type ExecDefaults = {
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
};
|
||||
|
||||
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
||||
@@ -95,6 +96,7 @@ function resolveExecDefaults(
|
||||
ask: globalExec.ask,
|
||||
node: globalExec.node,
|
||||
pathPrepend: globalExec.pathPrepend,
|
||||
safeBins: globalExec.safeBins,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
@@ -104,6 +106,7 @@ function resolveExecDefaults(
|
||||
ask: agentExec?.ask ?? globalExec?.ask,
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,7 +233,9 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||
|
||||
const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) as {
|
||||
const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", opts, {
|
||||
nodeId,
|
||||
})) as {
|
||||
file?: unknown;
|
||||
} | null;
|
||||
const approvalsFile =
|
||||
|
||||
@@ -220,12 +220,18 @@ describe("legacy config detection", () => {
|
||||
});
|
||||
it("keeps gateway.bind tailnet", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: { bind: "tailnet" as const },
|
||||
});
|
||||
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
||||
expect(res.config?.gateway?.bind).toBe("tailnet");
|
||||
expect(res.config).toBeNull();
|
||||
|
||||
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
|
||||
expect(validated.ok).toBe(true);
|
||||
if (validated.ok) {
|
||||
expect(validated.config.gateway?.bind).toBe("tailnet");
|
||||
}
|
||||
});
|
||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
@@ -202,10 +202,12 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
||||
|
||||
let mutated = false;
|
||||
const nextDefaults = { ...defaults };
|
||||
const contextPruning = defaults.contextPruning ?? {};
|
||||
const heartbeat = defaults.heartbeat ?? {};
|
||||
|
||||
if (defaults.contextPruning?.mode === undefined) {
|
||||
nextDefaults.contextPruning = {
|
||||
...defaults.contextPruning,
|
||||
...contextPruning,
|
||||
mode: "cache-ttl",
|
||||
ttl: defaults.contextPruning?.ttl ?? "1h",
|
||||
};
|
||||
@@ -214,7 +216,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
||||
|
||||
if (defaults.heartbeat?.every === undefined) {
|
||||
nextDefaults.heartbeat = {
|
||||
...defaults.heartbeat,
|
||||
...heartbeat,
|
||||
every: authMode === "oauth" ? "1h" : "30m",
|
||||
};
|
||||
mutated = true;
|
||||
|
||||
@@ -7,11 +7,7 @@ export function migrateLegacyConfig(raw: unknown): {
|
||||
changes: string[];
|
||||
} {
|
||||
const { next, changes } = applyLegacyMigrations(raw);
|
||||
if (!next) {
|
||||
const validated = validateConfigObjectWithPlugins(raw);
|
||||
if (!validated.ok) return { config: null, changes: [] };
|
||||
return { config: validated.config, changes: [] };
|
||||
}
|
||||
if (!next) return { config: null, changes: [] };
|
||||
const validated = validateConfigObjectWithPlugins(next);
|
||||
if (!validated.ok) {
|
||||
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
|
||||
|
||||
@@ -166,6 +166,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
@@ -367,6 +368,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"tools.exec.notifyOnExit":
|
||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.message.allowCrossContextSend":
|
||||
"Legacy override: allow cross-context sends across all providers.",
|
||||
"tools.message.crossContext.allowWithinProvider":
|
||||
|
||||
@@ -131,6 +131,8 @@ export type ExecToolConfig = {
|
||||
node?: string;
|
||||
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
|
||||
pathPrepend?: string[];
|
||||
/** Safe stdin-only binaries that can run without allowlist entries. */
|
||||
safeBins?: string[];
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
|
||||
@@ -257,6 +257,7 @@ export const AgentToolsSchema = z
|
||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||
node: z.string().optional(),
|
||||
pathPrepend: z.array(z.string()).optional(),
|
||||
safeBins: z.array(z.string()).optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
@@ -485,6 +486,7 @@ export const ToolsSchema = z
|
||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||
node: z.string().optional(),
|
||||
pathPrepend: z.array(z.string()).optional(),
|
||||
safeBins: z.array(z.string()).optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
|
||||
@@ -2,7 +2,14 @@ export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||
export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
||||
|
||||
export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||
export const HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
export const getHandshakeTimeoutMs = () => {
|
||||
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
|
||||
const parsed = Number(process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
||||
};
|
||||
export const TICK_INTERVAL_MS = 30_000;
|
||||
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
|
||||
export const DEDUPE_TTL_MS = 5 * 60_000;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js";
|
||||
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||
import {
|
||||
connectReq,
|
||||
getFreePort,
|
||||
@@ -28,10 +28,21 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
|
||||
describe("gateway server auth/connect", () => {
|
||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||
vi.useRealTimers();
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
|
||||
expect(closed).toBe(true);
|
||||
await server.close();
|
||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
|
||||
try {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
|
||||
expect(closed).toBe(true);
|
||||
await server.close();
|
||||
} finally {
|
||||
if (prevHandshakeTimeout === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
|
||||
|
||||
import type { ResolvedGatewayAuth } from "../auth.js";
|
||||
import { isLoopbackAddress } from "../net.js";
|
||||
import { HANDSHAKE_TIMEOUT_MS } from "../server-constants.js";
|
||||
import { getHandshakeTimeoutMs } from "../server-constants.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import { logWs } from "../ws-log.js";
|
||||
@@ -210,6 +210,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
close();
|
||||
});
|
||||
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const handshakeTimer = setTimeout(() => {
|
||||
if (!client) {
|
||||
handshakeState = "failed";
|
||||
@@ -219,7 +220,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
|
||||
close();
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
}, handshakeTimeoutMs);
|
||||
|
||||
attachGatewayWsMessageHandler({
|
||||
socket,
|
||||
|
||||
@@ -5,9 +5,13 @@ import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
analyzeArgvCommand,
|
||||
analyzeShellCommand,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeSafeBins,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
type ExecAllowlistEntry,
|
||||
@@ -18,7 +22,7 @@ function makeTempDir() {
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
it("matches by executable name (case-insensitive)", () => {
|
||||
it("ignores basename-only patterns", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
@@ -26,7 +30,7 @@ describe("exec approvals allowlist matching", () => {
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("RG");
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches by resolved path with **", () => {
|
||||
@@ -51,7 +55,7 @@ describe("exec approvals allowlist matching", () => {
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to raw executable when no resolved path", () => {
|
||||
it("requires a resolved path", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "bin/rg",
|
||||
resolvedPath: undefined,
|
||||
@@ -59,7 +63,7 @@ describe("exec approvals allowlist matching", () => {
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("bin/rg");
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +74,7 @@ describe("exec approvals command resolution", () => {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exe = path.join(binDir, "rg");
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
||||
expect(res?.resolvedPath).toBe(exe);
|
||||
expect(res?.executableName).toBe("rg");
|
||||
@@ -81,6 +86,7 @@ describe("exec approvals command resolution", () => {
|
||||
const script = path.join(cwd, "scripts", "run.sh");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
fs.chmodSync(script, 0o755);
|
||||
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
@@ -91,11 +97,81 @@ describe("exec approvals command resolution", () => {
|
||||
const script = path.join(cwd, "bin", "tool");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
fs.chmodSync(script, 0o755);
|
||||
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals shell parsing", () => {
|
||||
it("parses simple pipelines", () => {
|
||||
const res = analyzeShellCommand({ command: "echo ok | jq .foo" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]);
|
||||
});
|
||||
|
||||
it("rejects chained commands", () => {
|
||||
const res = analyzeShellCommand({ command: "ls && rm -rf /" });
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("parses argv commands", () => {
|
||||
const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals safe bins", () => {
|
||||
it("allows safe bins with non-path args", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exe = path.join(binDir, "jq");
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const res = analyzeShellCommand({
|
||||
command: "jq .foo",
|
||||
cwd: dir,
|
||||
env: { PATH: binDir },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const segment = res.segments[0];
|
||||
const ok = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
cwd: dir,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks safe bins with file args", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exe = path.join(binDir, "jq");
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const file = path.join(dir, "secret.json");
|
||||
fs.writeFileSync(file, "{}");
|
||||
const res = analyzeShellCommand({
|
||||
command: "jq .foo secret.json",
|
||||
cwd: dir,
|
||||
env: { PATH: binDir },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const segment = res.segments[0];
|
||||
const ok = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
cwd: dir,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals policy helpers", () => {
|
||||
it("minSecurity returns the more restrictive value", () => {
|
||||
expect(minSecurity("deny", "full")).toBe("deny");
|
||||
|
||||
+310
-13
@@ -60,6 +60,7 @@ const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
|
||||
const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
||||
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
||||
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
|
||||
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
|
||||
|
||||
function hashExecApprovalsRaw(raw: string | null): string {
|
||||
return crypto
|
||||
@@ -283,6 +284,19 @@ type CommandResolution = {
|
||||
executableName: string;
|
||||
};
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) return false;
|
||||
if (process.platform !== "win32") {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFirstToken(command: string): string | null {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -299,15 +313,26 @@ function parseFirstToken(command: string): string | null {
|
||||
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
|
||||
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
|
||||
if (expanded.includes("/") || expanded.includes("\\")) {
|
||||
if (path.isAbsolute(expanded)) return expanded;
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return isExecutableFile(expanded) ? expanded : undefined;
|
||||
}
|
||||
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
||||
return path.resolve(base, expanded);
|
||||
const candidate = path.resolve(base, expanded);
|
||||
return isExecutableFile(candidate) ? candidate : undefined;
|
||||
}
|
||||
const envPath = env?.PATH ?? process.env.PATH ?? "";
|
||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||
const extensions =
|
||||
process.platform === "win32"
|
||||
? (env?.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
||||
.split(";")
|
||||
.map((ext) => ext.toLowerCase())
|
||||
: [""];
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(entry, expanded);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
for (const ext of extensions) {
|
||||
const candidate = path.join(entry, expanded + ext);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -324,6 +349,18 @@ export function resolveCommandResolution(
|
||||
return { rawExecutable, resolvedPath, executableName };
|
||||
}
|
||||
|
||||
export function resolveCommandResolutionFromArgv(
|
||||
argv: string[],
|
||||
cwd?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): CommandResolution | null {
|
||||
const rawExecutable = argv[0]?.trim();
|
||||
if (!rawExecutable) return null;
|
||||
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
|
||||
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
|
||||
return { rawExecutable, resolvedPath, executableName };
|
||||
}
|
||||
|
||||
function normalizeMatchTarget(value: string): string {
|
||||
return value.replace(/\\\\/g, "/").toLowerCase();
|
||||
}
|
||||
@@ -370,24 +407,284 @@ export function matchAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
resolution: CommandResolution | null,
|
||||
): ExecAllowlistEntry | null {
|
||||
if (!entries.length || !resolution) return null;
|
||||
const rawExecutable = resolution.rawExecutable;
|
||||
if (!entries.length || !resolution?.resolvedPath) return null;
|
||||
const resolvedPath = resolution.resolvedPath;
|
||||
const executableName = resolution.executableName;
|
||||
for (const entry of entries) {
|
||||
const pattern = entry.pattern?.trim();
|
||||
if (!pattern) continue;
|
||||
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
|
||||
if (hasPath) {
|
||||
const target = resolvedPath ?? rawExecutable;
|
||||
if (target && matchesPattern(pattern, target)) return entry;
|
||||
continue;
|
||||
}
|
||||
if (executableName && matchesPattern(pattern, executableName)) return entry;
|
||||
if (!hasPath) continue;
|
||||
if (matchesPattern(pattern, resolvedPath)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ExecCommandSegment = {
|
||||
raw: string;
|
||||
argv: string[];
|
||||
resolution: CommandResolution | null;
|
||||
};
|
||||
|
||||
export type ExecCommandAnalysis = {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
segments: ExecCommandSegment[];
|
||||
};
|
||||
|
||||
const DISALLOWED_TOKENS = new Set([";", "&", ">", "<", "`", "\n", "\r", "(", ")"]);
|
||||
|
||||
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
|
||||
const segments: string[] = [];
|
||||
let buf = "";
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
|
||||
const pushSegment = () => {
|
||||
const trimmed = buf.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
segments.push(trimmed);
|
||||
buf = "";
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
const ch = command[i];
|
||||
if (escaped) {
|
||||
buf += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === "\\") {
|
||||
escaped = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (inSingle) {
|
||||
if (ch === "'") inSingle = false;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (inDouble) {
|
||||
if (ch === '"') inDouble = false;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
inSingle = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDouble = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "|" && command[i + 1] === "|") {
|
||||
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
|
||||
}
|
||||
if (ch === "|" && command[i + 1] === "&") {
|
||||
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
|
||||
}
|
||||
if (ch === "|") {
|
||||
if (!pushSegment()) {
|
||||
return { ok: false, reason: "empty pipeline segment", segments: [] };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (DISALLOWED_TOKENS.has(ch)) {
|
||||
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
||||
}
|
||||
if (ch === "$" && command[i + 1] === "(") {
|
||||
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
||||
}
|
||||
buf += ch;
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) {
|
||||
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
|
||||
}
|
||||
if (!pushSegment()) {
|
||||
return { ok: false, reason: "empty command", segments: [] };
|
||||
}
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
function tokenizeShellSegment(segment: string): string[] | null {
|
||||
const tokens: string[] = [];
|
||||
let buf = "";
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
|
||||
const pushToken = () => {
|
||||
if (buf.length > 0) {
|
||||
tokens.push(buf);
|
||||
buf = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < segment.length; i += 1) {
|
||||
const ch = segment[i];
|
||||
if (escaped) {
|
||||
buf += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inSingle) {
|
||||
if (ch === "'") {
|
||||
inSingle = false;
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inDouble) {
|
||||
if (ch === '"') {
|
||||
inDouble = false;
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
inSingle = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDouble = true;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
pushToken();
|
||||
continue;
|
||||
}
|
||||
buf += ch;
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) {
|
||||
return null;
|
||||
}
|
||||
pushToken();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function analyzeShellCommand(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ExecCommandAnalysis {
|
||||
const split = splitShellPipeline(params.command);
|
||||
if (!split.ok) {
|
||||
return { ok: false, reason: split.reason, segments: [] };
|
||||
}
|
||||
const segments: ExecCommandSegment[] = [];
|
||||
for (const raw of split.segments) {
|
||||
const argv = tokenizeShellSegment(raw);
|
||||
if (!argv || argv.length === 0) {
|
||||
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
||||
}
|
||||
segments.push({
|
||||
raw,
|
||||
argv,
|
||||
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
||||
});
|
||||
}
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
export function analyzeArgvCommand(params: {
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ExecCommandAnalysis {
|
||||
const argv = params.argv.filter((entry) => entry.trim().length > 0);
|
||||
if (argv.length === 0) {
|
||||
return { ok: false, reason: "empty argv", segments: [] };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: argv.join(" "),
|
||||
argv,
|
||||
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function isPathLikeToken(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === "-") return false;
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) return true;
|
||||
if (trimmed.startsWith("/")) return true;
|
||||
return /^[A-Za-z]:[\\/]/.test(trimmed);
|
||||
}
|
||||
|
||||
function defaultFileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSafeBins(entries?: string[]): Set<string> {
|
||||
if (!Array.isArray(entries)) return new Set();
|
||||
const normalized = entries
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return new Set(normalized);
|
||||
}
|
||||
|
||||
export function resolveSafeBins(entries?: string[] | null): Set<string> {
|
||||
if (entries === undefined) return normalizeSafeBins(DEFAULT_SAFE_BINS);
|
||||
return normalizeSafeBins(entries ?? []);
|
||||
}
|
||||
|
||||
export function isSafeBinUsage(params: {
|
||||
argv: string[];
|
||||
resolution: CommandResolution | null;
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
fileExists?: (filePath: string) => boolean;
|
||||
}): boolean {
|
||||
if (params.safeBins.size === 0) return false;
|
||||
const resolution = params.resolution;
|
||||
const execName = resolution?.executableName?.toLowerCase();
|
||||
if (!execName || !params.safeBins.has(execName)) return false;
|
||||
if (!resolution?.resolvedPath) return false;
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const exists = params.fileExists ?? defaultFileExists;
|
||||
const argv = params.argv.slice(1);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) continue;
|
||||
if (token === "-") continue;
|
||||
if (token.startsWith("-")) {
|
||||
const eqIndex = token.indexOf("=");
|
||||
if (eqIndex > 0) {
|
||||
const value = token.slice(eqIndex + 1);
|
||||
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isPathLikeToken(token)) return false;
|
||||
if (exists(path.resolve(cwd, token))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function recordAllowlistUse(
|
||||
approvals: ExecApprovalsFile,
|
||||
agentId: string | undefined,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
|
||||
|
||||
async function makeEnv() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-"));
|
||||
const configPath = path.join(dir, "clawdbot.json");
|
||||
await fs.writeFile(configPath, "{}", "utf8");
|
||||
return {
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWDBOT_STATE_DIR: dir,
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
},
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("gateway lock", () => {
|
||||
it("blocks concurrent acquisition until release", async () => {
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const lock = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await expect(
|
||||
acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GatewayLockError);
|
||||
|
||||
await lock?.release();
|
||||
const lock2 = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
});
|
||||
await lock2?.release();
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
const DEFAULT_STALE_MS = 30_000;
|
||||
|
||||
type LockPayload = {
|
||||
pid: number;
|
||||
createdAt: string;
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
export type GatewayLockHandle = {
|
||||
lockPath: string;
|
||||
configPath: string;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type GatewayLockOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
staleMs?: number;
|
||||
allowInTests?: boolean;
|
||||
};
|
||||
|
||||
export class GatewayLockError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -7,3 +37,117 @@ export class GatewayLockError extends Error {
|
||||
this.name = "GatewayLockError";
|
||||
}
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<LockPayload>;
|
||||
if (typeof parsed.pid !== "number") return null;
|
||||
if (typeof parsed.createdAt !== "string") return null;
|
||||
if (typeof parsed.configPath !== "string") return null;
|
||||
return {
|
||||
pid: parsed.pid,
|
||||
createdAt: parsed.createdAt,
|
||||
configPath: parsed.configPath,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayLockPath(env: NodeJS.ProcessEnv) {
|
||||
const stateDir = resolveStateDir(env);
|
||||
const configPath = resolveConfigPath(env, stateDir);
|
||||
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
|
||||
const lockPath = path.join(stateDir, `gateway.${hash}.lock`);
|
||||
return { lockPath, configPath };
|
||||
}
|
||||
|
||||
export async function acquireGatewayLock(
|
||||
opts: GatewayLockOptions = {},
|
||||
): Promise<GatewayLockHandle | null> {
|
||||
const env = opts.env ?? process.env;
|
||||
const allowInTests = opts.allowInTests === true;
|
||||
if (
|
||||
env.CLAWDBOT_ALLOW_MULTI_GATEWAY === "1" ||
|
||||
(!allowInTests && (env.VITEST || env.NODE_ENV === "test"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
||||
const { lockPath, configPath } = resolveGatewayLockPath(env);
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
const startedAt = Date.now();
|
||||
let lastPayload: LockPayload | null = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const handle = await fs.open(lockPath, "wx");
|
||||
const payload: LockPayload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
};
|
||||
await handle.writeFile(JSON.stringify(payload), "utf8");
|
||||
return {
|
||||
lockPath,
|
||||
configPath,
|
||||
release: async () => {
|
||||
await handle.close().catch(() => undefined);
|
||||
await fs.rm(lockPath, { force: true });
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (code !== "EEXIST") {
|
||||
throw new GatewayLockError(`failed to acquire gateway lock at ${lockPath}`, err);
|
||||
}
|
||||
|
||||
lastPayload = await readLockPayload(lockPath);
|
||||
const ownerPid = lastPayload?.pid;
|
||||
const ownerAlive = ownerPid ? isAlive(ownerPid) : false;
|
||||
if (!ownerAlive && ownerPid) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
if (!ownerAlive) {
|
||||
let stale = false;
|
||||
if (lastPayload?.createdAt) {
|
||||
const createdAt = Date.parse(lastPayload.createdAt);
|
||||
stale = Number.isFinite(createdAt) ? Date.now() - createdAt > staleMs : false;
|
||||
}
|
||||
if (!stale) {
|
||||
try {
|
||||
const st = await fs.stat(lockPath);
|
||||
stale = Date.now() - st.mtimeMs > staleMs;
|
||||
} catch {
|
||||
stale = true;
|
||||
}
|
||||
}
|
||||
if (stale) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
const owner = lastPayload?.pid ? ` (pid ${lastPayload.pid})` : "";
|
||||
throw new GatewayLockError(`gateway already running${owner}; lock timeout after ${timeoutMs}ms`);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function parseActiveHoursTime(raw: string | undefined, opts: { allow24: boolean }): number | null {
|
||||
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
|
||||
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
|
||||
const [hourStr, minuteStr] = raw.split(":");
|
||||
const hour = Number(hourStr);
|
||||
@@ -131,8 +131,8 @@ function isWithinActiveHours(
|
||||
const active = heartbeat?.activeHours;
|
||||
if (!active) return true;
|
||||
|
||||
const startMin = parseActiveHoursTime(active.start, { allow24: false });
|
||||
const endMin = parseActiveHoursTime(active.end, { allow24: true });
|
||||
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
|
||||
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
|
||||
if (startMin === null || endMin === null) return true;
|
||||
if (startMin === endMin) return true;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
import type { GatewayLockHandle } from "../infra/gateway-lock.js";
|
||||
|
||||
declare const __CLAWDBOT_VERSION__: string;
|
||||
|
||||
@@ -45,6 +46,7 @@ async function main() {
|
||||
{ startGatewayServer },
|
||||
{ setGatewayWsLogStyle },
|
||||
{ setVerbose },
|
||||
{ acquireGatewayLock, GatewayLockError },
|
||||
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
|
||||
{ defaultRuntime },
|
||||
{ enableConsoleCapture, setConsoleTimestampPrefix },
|
||||
@@ -53,10 +55,11 @@ async function main() {
|
||||
import("../gateway/server.js"),
|
||||
import("../gateway/ws-logging.js"),
|
||||
import("../globals.js"),
|
||||
import("../infra/gateway-lock.js"),
|
||||
import("../infra/restart.js"),
|
||||
import("../runtime.js"),
|
||||
import("../logging.js"),
|
||||
]);
|
||||
] as const);
|
||||
|
||||
enableConsoleCapture();
|
||||
setConsoleTimestampPrefix(true);
|
||||
@@ -103,6 +106,7 @@ async function main() {
|
||||
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||
let lock: GatewayLockHandle | null = null;
|
||||
let shuttingDown = false;
|
||||
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let restartResolver: (() => void) | null = null;
|
||||
@@ -177,6 +181,15 @@ async function main() {
|
||||
process.on("SIGUSR1", onSigusr1);
|
||||
|
||||
try {
|
||||
try {
|
||||
lock = await acquireGatewayLock();
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayLockError) {
|
||||
defaultRuntime.error(`Gateway start blocked: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
@@ -191,6 +204,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await (lock as GatewayLockHandle | null)?.release();
|
||||
cleanupSignals();
|
||||
}
|
||||
}
|
||||
|
||||
+66
-13
@@ -4,12 +4,16 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
type ExecAllowlistEntry,
|
||||
addAllowlistEntry,
|
||||
analyzeArgvCommand,
|
||||
analyzeShellCommand,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
normalizeExecApprovals,
|
||||
recordAllowlistUse,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
resolveSafeBins,
|
||||
ensureExecApprovals,
|
||||
readExecApprovalsSnapshot,
|
||||
resolveExecApprovalsSocketPath,
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
@@ -110,7 +115,6 @@ const execHostFallbackAllowed =
|
||||
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
||||
|
||||
const blockedEnvKeys = new Set([
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
@@ -156,10 +160,24 @@ function sanitizeEnv(
|
||||
): Record<string, string> | undefined {
|
||||
if (!overrides) return undefined;
|
||||
const merged = { ...process.env } as Record<string, string>;
|
||||
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
const key = rawKey.trim();
|
||||
if (!key) continue;
|
||||
const upper = key.toUpperCase();
|
||||
if (upper === "PATH") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!basePath || trimmed === basePath) {
|
||||
merged[key] = trimmed;
|
||||
continue;
|
||||
}
|
||||
const suffix = `${path.delimiter}${basePath}`;
|
||||
if (trimmed.endsWith(suffix)) {
|
||||
merged[key] = trimmed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockedEnvKeys.has(upper)) continue;
|
||||
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
|
||||
merged[key] = value;
|
||||
@@ -567,12 +585,32 @@ async function handleInvoke(
|
||||
const sessionKey = params.sessionKey?.trim() || "node";
|
||||
const runId = crypto.randomUUID();
|
||||
const env = sanitizeEnv(params.env ?? undefined);
|
||||
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
|
||||
const allowlistMatch =
|
||||
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||
const analysis = rawCommand
|
||||
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
|
||||
: analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
|
||||
const cfg = loadConfig();
|
||||
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
||||
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
||||
const skillAllow =
|
||||
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
||||
const allowlistSatisfied =
|
||||
security === "allowlist" && analysis.ok && analysis.segments.length > 0
|
||||
? analysis.segments.every((segment) => {
|
||||
const match = matchAllowlist(approvals.allowlist, segment.resolution);
|
||||
if (match) allowlistMatches.push(match);
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins,
|
||||
cwd: params.cwd ?? undefined,
|
||||
});
|
||||
const skillAllow =
|
||||
autoAllowSkills && segment.resolution?.executableName
|
||||
? bins.has(segment.resolution.executableName)
|
||||
: false;
|
||||
return Boolean(match || safe || skillAllow);
|
||||
})
|
||||
: false;
|
||||
|
||||
const useMacAppExec = process.platform === "darwin";
|
||||
if (useMacAppExec) {
|
||||
@@ -678,7 +716,7 @@ async function handleInvoke(
|
||||
|
||||
const requiresAsk =
|
||||
ask === "always" ||
|
||||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
|
||||
(ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied));
|
||||
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
@@ -704,11 +742,15 @@ async function handleInvoke(
|
||||
return;
|
||||
}
|
||||
if (approvalDecision === "allow-always" && security === "allowlist") {
|
||||
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
|
||||
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
if (analysis.ok) {
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
|
||||
if (security === "allowlist" && (!analysis.ok || !allowlistSatisfied) && !approvedByAsk) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
@@ -727,8 +769,19 @@ async function handleInvoke(
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowlistMatch) {
|
||||
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
|
||||
if (allowlistMatches.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const match of allowlistMatches) {
|
||||
if (!match?.pattern || seen.has(match.pattern)) continue;
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
agentId,
|
||||
match,
|
||||
cmdText,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.needsScreenRecording === true) {
|
||||
|
||||
Reference in new issue
Block a user