feat: show node PATH and bootstrap node host env
This commit is contained in:
@@ -4,7 +4,14 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
## 2026.1.21
|
## 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
|
### 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.
|
- 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.
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ function formatNodeVersions(node: {
|
|||||||
return parts.length > 0 ? parts.join(" · ") : null;
|
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 {
|
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||||
if (raw === undefined || raw === null) return undefined;
|
if (raw === undefined || raw === null) return undefined;
|
||||||
const value =
|
const value =
|
||||||
@@ -126,11 +135,13 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
|
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
|
||||||
const perms = formatPermissions(n.permissions);
|
const perms = formatPermissions(n.permissions);
|
||||||
const versions = formatNodeVersions(n);
|
const versions = formatNodeVersions(n);
|
||||||
|
const pathEnv = formatPathEnv(n.pathEnv);
|
||||||
const detailParts = [
|
const detailParts = [
|
||||||
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
|
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
|
||||||
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
|
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
|
||||||
perms ? `perms: ${perms}` : null,
|
perms ? `perms: ${perms}` : null,
|
||||||
versions,
|
versions,
|
||||||
|
pathEnv ? `path: ${pathEnv}` : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
const caps = Array.isArray(n.caps)
|
const caps = Array.isArray(n.caps)
|
||||||
? n.caps.map(String).filter(Boolean).sort().join(", ")
|
? 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 family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
||||||
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
||||||
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
||||||
|
const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null;
|
||||||
const versions = formatNodeVersions(
|
const versions = formatNodeVersions(
|
||||||
obj as {
|
obj as {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
@@ -223,6 +235,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
model ? { Field: "Model", Value: model } : null,
|
model ? { Field: "Model", Value: model } : null,
|
||||||
perms ? { Field: "Perms", Value: perms } : null,
|
perms ? { Field: "Perms", Value: perms } : null,
|
||||||
versions ? { Field: "Version", Value: versions } : null,
|
versions ? { Field: "Version", Value: versions } : null,
|
||||||
|
pathEnv ? { Field: "PATH", Value: pathEnv } : null,
|
||||||
{ Field: "Status", Value: status },
|
{ Field: "Status", Value: status },
|
||||||
{ Field: "Caps", Value: caps ? caps.join(", ") : "?" },
|
{ Field: "Caps", Value: caps ? caps.join(", ") : "?" },
|
||||||
].filter(Boolean) as Array<{ Field: string; Value: string }>;
|
].filter(Boolean) as Array<{ Field: string; Value: string }>;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type NodeListNode = {
|
|||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
deviceFamily?: string;
|
deviceFamily?: string;
|
||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
|
pathEnv?: string;
|
||||||
caps?: string[];
|
caps?: string[];
|
||||||
commands?: string[];
|
commands?: string[];
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type GatewayClientOptions = {
|
|||||||
caps?: string[];
|
caps?: string[];
|
||||||
commands?: string[];
|
commands?: string[];
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
pathEnv?: string;
|
||||||
deviceIdentity?: DeviceIdentity;
|
deviceIdentity?: DeviceIdentity;
|
||||||
minProtocol?: number;
|
minProtocol?: number;
|
||||||
maxProtocol?: number;
|
maxProtocol?: number;
|
||||||
@@ -232,6 +233,7 @@ export class GatewayClient {
|
|||||||
this.opts.permissions && typeof this.opts.permissions === "object"
|
this.opts.permissions && typeof this.opts.permissions === "object"
|
||||||
? this.opts.permissions
|
? this.opts.permissions
|
||||||
: undefined,
|
: undefined,
|
||||||
|
pathEnv: this.opts.pathEnv,
|
||||||
auth,
|
auth,
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type NodeSession = {
|
|||||||
caps: string[];
|
caps: string[];
|
||||||
commands: string[];
|
commands: string[];
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
pathEnv?: string;
|
||||||
connectedAtMs: number;
|
connectedAtMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +52,10 @@ export class NodeRegistry {
|
|||||||
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
|
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
|
||||||
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
|
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const pathEnv =
|
||||||
|
typeof (connect as { pathEnv?: string }).pathEnv === "string"
|
||||||
|
? (connect as { pathEnv?: string }).pathEnv
|
||||||
|
: undefined;
|
||||||
const session: NodeSession = {
|
const session: NodeSession = {
|
||||||
nodeId,
|
nodeId,
|
||||||
connId: client.connId,
|
connId: client.connId,
|
||||||
@@ -66,6 +71,7 @@ export class NodeRegistry {
|
|||||||
caps,
|
caps,
|
||||||
commands,
|
commands,
|
||||||
permissions,
|
permissions,
|
||||||
|
pathEnv,
|
||||||
connectedAtMs: Date.now(),
|
connectedAtMs: Date.now(),
|
||||||
};
|
};
|
||||||
this.nodesById.set(nodeId, session);
|
this.nodesById.set(nodeId, session);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const ConnectParamsSchema = Type.Object(
|
|||||||
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
||||||
commands: Type.Optional(Type.Array(NonEmptyString)),
|
commands: Type.Optional(Type.Array(NonEmptyString)),
|
||||||
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
|
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
|
||||||
|
pathEnv: Type.Optional(Type.String()),
|
||||||
role: Type.Optional(NonEmptyString),
|
role: Type.Optional(NonEmptyString),
|
||||||
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||||
device: Type.Optional(
|
device: Type.Optional(
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
||||||
caps,
|
caps,
|
||||||
commands,
|
commands,
|
||||||
|
pathEnv: live?.pathEnv,
|
||||||
permissions: live?.permissions ?? paired?.permissions,
|
permissions: live?.permissions ?? paired?.permissions,
|
||||||
connectedAtMs: live?.connectedAtMs,
|
connectedAtMs: live?.connectedAtMs,
|
||||||
paired: Boolean(paired),
|
paired: Boolean(paired),
|
||||||
@@ -338,6 +339,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
||||||
caps,
|
caps,
|
||||||
commands,
|
commands,
|
||||||
|
pathEnv: live?.pathEnv,
|
||||||
permissions: live?.permissions,
|
permissions: live?.permissions,
|
||||||
connectedAtMs: live?.connectedAtMs,
|
connectedAtMs: live?.connectedAtMs,
|
||||||
paired: Boolean(paired),
|
paired: Boolean(paired),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.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_CAP = 200_000;
|
||||||
const OUTPUT_EVENT_TAIL = 20_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 execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
|
||||||
const execHostFallbackAllowed =
|
const execHostFallbackAllowed =
|
||||||
@@ -287,10 +289,18 @@ function resolveEnvPath(env?: Record<string, string>): string[] {
|
|||||||
(env as Record<string, string>)?.Path ??
|
(env as Record<string, string>)?.Path ??
|
||||||
process.env.PATH ??
|
process.env.PATH ??
|
||||||
process.env.Path ??
|
process.env.Path ??
|
||||||
"";
|
DEFAULT_NODE_PATH;
|
||||||
return raw.split(path.delimiter).filter(Boolean);
|
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<string, string>) {
|
function resolveExecutable(bin: string, env?: Record<string, string>) {
|
||||||
if (bin.includes("/") || bin.includes("\\")) return null;
|
if (bin.includes("/") || bin.includes("\\")) return null;
|
||||||
const extensions =
|
const extensions =
|
||||||
@@ -369,6 +379,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|||||||
const port = gateway.port ?? 18789;
|
const port = gateway.port ?? 18789;
|
||||||
const scheme = gateway.tls ? "wss" : "ws";
|
const scheme = gateway.tls ? "wss" : "ws";
|
||||||
const url = `${scheme}://${host}:${port}`;
|
const url = `${scheme}://${host}:${port}`;
|
||||||
|
const pathEnv = ensureNodePathEnv();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`node host PATH: ${pathEnv}`);
|
||||||
|
|
||||||
const client = new GatewayClient({
|
const client = new GatewayClient({
|
||||||
url,
|
url,
|
||||||
@@ -389,6 +402,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|||||||
"system.execApprovals.get",
|
"system.execApprovals.get",
|
||||||
"system.execApprovals.set",
|
"system.execApprovals.set",
|
||||||
],
|
],
|
||||||
|
pathEnv,
|
||||||
permissions: undefined,
|
permissions: undefined,
|
||||||
deviceIdentity: loadOrCreateDeviceIdentity(),
|
deviceIdentity: loadOrCreateDeviceIdentity(),
|
||||||
tlsFingerprint: gateway.tlsFingerprint,
|
tlsFingerprint: gateway.tlsFingerprint,
|
||||||
|
|||||||
Reference in New Issue
Block a user