diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f233c15..a681981a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ Docs: https://docs.clawd.bot ## 2026.1.21 +### Changes +- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. +- Exec approvals: support wildcard agent allowlists (`*`) across all agents. +- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. + ### Fixes +- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. +- macOS: exec approvals now respect wildcard agent allowlists (`*`). - UI: remove the chat stop button and keep the composer aligned to the bottom edge. - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 3c89cc0fc..dd69234a3 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -44,6 +44,15 @@ function formatNodeVersions(node: { return parts.length > 0 ? parts.join(" · ") : null; } +function formatPathEnv(raw?: string): string | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const parts = trimmed.split(":").filter(Boolean); + if (parts.length <= 3) return trimmed; + return `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`; +} + function parseSinceMs(raw: unknown, label: string): number | undefined { if (raw === undefined || raw === null) return undefined; const value = @@ -126,11 +135,13 @@ export function registerNodesStatusCommands(nodes: Command) { const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; const perms = formatPermissions(n.permissions); const versions = formatNodeVersions(n); + const pathEnv = formatPathEnv(n.pathEnv); const detailParts = [ n.deviceFamily ? `device: ${n.deviceFamily}` : null, n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, perms ? `perms: ${perms}` : null, versions, + pathEnv ? `path: ${pathEnv}` : null, ].filter(Boolean) as string[]; const caps = Array.isArray(n.caps) ? n.caps.map(String).filter(Boolean).sort().join(", ") @@ -201,6 +212,7 @@ export function registerNodesStatusCommands(nodes: Command) { const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; + const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null; const versions = formatNodeVersions( obj as { platform?: string; @@ -223,6 +235,7 @@ export function registerNodesStatusCommands(nodes: Command) { model ? { Field: "Model", Value: model } : null, perms ? { Field: "Perms", Value: perms } : null, versions ? { Field: "Version", Value: versions } : null, + pathEnv ? { Field: "PATH", Value: pathEnv } : null, { Field: "Status", Value: status }, { Field: "Caps", Value: caps ? caps.join(", ") : "?" }, ].filter(Boolean) as Array<{ Field: string; Value: string }>; diff --git a/src/cli/nodes-cli/types.ts b/src/cli/nodes-cli/types.ts index d2ecb6d03..77f10318e 100644 --- a/src/cli/nodes-cli/types.ts +++ b/src/cli/nodes-cli/types.ts @@ -53,6 +53,7 @@ export type NodeListNode = { remoteIp?: string; deviceFamily?: string; modelIdentifier?: string; + pathEnv?: string; caps?: string[]; commands?: string[]; permissions?: Record; diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 374420e19..23929f9a4 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -53,6 +53,7 @@ export type GatewayClientOptions = { caps?: string[]; commands?: string[]; permissions?: Record; + pathEnv?: string; deviceIdentity?: DeviceIdentity; minProtocol?: number; maxProtocol?: number; @@ -232,6 +233,7 @@ export class GatewayClient { this.opts.permissions && typeof this.opts.permissions === "object" ? this.opts.permissions : undefined, + pathEnv: this.opts.pathEnv, auth, role, scopes, diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index f04eab008..af881364f 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -17,6 +17,7 @@ export type NodeSession = { caps: string[]; commands: string[]; permissions?: Record; + pathEnv?: string; connectedAtMs: number; }; @@ -51,6 +52,10 @@ export class NodeRegistry { typeof (connect as { permissions?: Record }).permissions === "object" ? ((connect as { permissions?: Record }).permissions ?? undefined) : undefined; + const pathEnv = + typeof (connect as { pathEnv?: string }).pathEnv === "string" + ? (connect as { pathEnv?: string }).pathEnv + : undefined; const session: NodeSession = { nodeId, connId: client.connId, @@ -66,6 +71,7 @@ export class NodeRegistry { caps, commands, permissions, + pathEnv, connectedAtMs: Date.now(), }; this.nodesById.set(nodeId, session); diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index ca2f74753..a084e3433 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -37,6 +37,7 @@ export const ConnectParamsSchema = Type.Object( caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })), commands: Type.Optional(Type.Array(NonEmptyString)), permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())), + pathEnv: Type.Optional(Type.String()), role: Type.Optional(NonEmptyString), scopes: Type.Optional(Type.Array(NonEmptyString)), device: Type.Optional( diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index e434390d5..d8787cea1 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -275,6 +275,7 @@ export const nodeHandlers: GatewayRequestHandlers = { remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, + pathEnv: live?.pathEnv, permissions: live?.permissions ?? paired?.permissions, connectedAtMs: live?.connectedAtMs, paired: Boolean(paired), @@ -338,6 +339,7 @@ export const nodeHandlers: GatewayRequestHandlers = { remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, + pathEnv: live?.pathEnv, permissions: live?.permissions, connectedAtMs: live?.connectedAtMs, paired: Boolean(paired), diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 4ebafefb7..8d56881dc 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -25,6 +25,7 @@ import { import { getMachineDisplayName } from "../infra/machine-name.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadConfig } from "../config/config.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"; @@ -102,6 +103,7 @@ type NodeInvokeRequestPayload = { const OUTPUT_CAP = 200_000; const OUTPUT_EVENT_TAIL = 20_000; +const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; const execHostFallbackAllowed = @@ -287,10 +289,18 @@ function resolveEnvPath(env?: Record): string[] { (env as Record)?.Path ?? process.env.PATH ?? process.env.Path ?? - ""; + DEFAULT_NODE_PATH; return raw.split(path.delimiter).filter(Boolean); } +function ensureNodePathEnv(): string { + ensureClawdbotCliOnPath({ pathEnv: process.env.PATH ?? "" }); + const current = process.env.PATH ?? ""; + if (current.trim()) return current; + process.env.PATH = DEFAULT_NODE_PATH; + return DEFAULT_NODE_PATH; +} + function resolveExecutable(bin: string, env?: Record) { if (bin.includes("/") || bin.includes("\\")) return null; const extensions = @@ -369,6 +379,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const port = gateway.port ?? 18789; const scheme = gateway.tls ? "wss" : "ws"; const url = `${scheme}://${host}:${port}`; + const pathEnv = ensureNodePathEnv(); + // eslint-disable-next-line no-console + console.log(`node host PATH: ${pathEnv}`); const client = new GatewayClient({ url, @@ -389,6 +402,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { "system.execApprovals.get", "system.execApprovals.set", ], + pathEnv, permissions: undefined, deviceIdentity: loadOrCreateDeviceIdentity(), tlsFingerprint: gateway.tlsFingerprint,