feat: sandbox session tool visibility
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
|
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
|
||||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||||
|
- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
|
||||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||||
- Control UI: animate reading indicator dots (honors reduced-motion).
|
- Control UI: animate reading indicator dots (honors reduced-motion).
|
||||||
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Parameters:
|
|||||||
Behavior:
|
Behavior:
|
||||||
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
|
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
|
||||||
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
|
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
|
||||||
|
- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below).
|
||||||
|
|
||||||
Row shape (JSON):
|
Row shape (JSON):
|
||||||
- `key`: session key (string)
|
- `key`: session key (string)
|
||||||
@@ -131,5 +132,23 @@ Parameters:
|
|||||||
Behavior:
|
Behavior:
|
||||||
- Starts a new `subagent:<uuid>` session with `deliver: false`.
|
- Starts a new `subagent:<uuid>` session with `deliver: false`.
|
||||||
- Sub-agents default to the full tool surface **minus session tools** (configurable via `agent.subagents.tools`).
|
- Sub-agents default to the full tool surface **minus session tools** (configurable via `agent.subagents.tools`).
|
||||||
|
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
||||||
- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat surface.
|
- After completion (or best-effort wait), Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat surface.
|
||||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||||
|
|
||||||
|
## Sandbox Session Visibility
|
||||||
|
|
||||||
|
Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
sandbox: {
|
||||||
|
// default: "spawned"
|
||||||
|
sessionToolsVisibility: "spawned" // or "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Primary goals:
|
|||||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||||
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
||||||
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
|
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
|
||||||
|
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
|
||||||
|
|
||||||
## Tool
|
## Tool
|
||||||
|
|
||||||
@@ -69,4 +70,3 @@ Sub-agents use a dedicated in-process queue lane:
|
|||||||
|
|
||||||
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
|
- Sub-agent announce is **best-effort**. If the gateway restarts, pending “announce back” work is lost.
|
||||||
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
|
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function createClawdbotTools(options?: {
|
|||||||
browserControlUrl?: string;
|
browserControlUrl?: string;
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentSurface?: string;
|
agentSurface?: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
const imageTool = createImageTool({ config: options?.config });
|
const imageTool = createImageTool({ config: options?.config });
|
||||||
@@ -28,15 +29,23 @@ export function createClawdbotTools(options?: {
|
|||||||
createDiscordTool(),
|
createDiscordTool(),
|
||||||
createSlackTool(),
|
createSlackTool(),
|
||||||
createGatewayTool(),
|
createGatewayTool(),
|
||||||
createSessionsListTool(),
|
createSessionsListTool({
|
||||||
createSessionsHistoryTool(),
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
sandboxed: options?.sandboxed,
|
||||||
|
}),
|
||||||
|
createSessionsHistoryTool({
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
sandboxed: options?.sandboxed,
|
||||||
|
}),
|
||||||
createSessionsSendTool({
|
createSessionsSendTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
agentSurface: options?.agentSurface,
|
agentSurface: options?.agentSurface,
|
||||||
|
sandboxed: options?.sandboxed,
|
||||||
}),
|
}),
|
||||||
createSessionsSpawnTool({
|
createSessionsSpawnTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
agentSurface: options?.agentSurface,
|
agentSurface: options?.agentSurface,
|
||||||
|
sandboxed: options?.sandboxed,
|
||||||
}),
|
}),
|
||||||
...(imageTool ? [imageTool] : []),
|
...(imageTool ? [imageTool] : []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -556,6 +556,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||||
agentSessionKey: options?.sessionKey,
|
agentSessionKey: options?.sessionKey,
|
||||||
agentSurface: options?.surface,
|
agentSurface: options?.surface,
|
||||||
|
sandboxed: !!sandbox,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-";
|
|||||||
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
|
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
|
||||||
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
|
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
|
||||||
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
|
const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
|
||||||
const DEFAULT_TOOL_ALLOW = ["bash", "process", "read", "write", "edit"];
|
const DEFAULT_TOOL_ALLOW = [
|
||||||
|
"bash",
|
||||||
|
"process",
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
"edit",
|
||||||
|
"sessions_list",
|
||||||
|
"sessions_history",
|
||||||
|
"sessions_send",
|
||||||
|
"sessions_spawn",
|
||||||
|
];
|
||||||
const DEFAULT_TOOL_DENY = [
|
const DEFAULT_TOOL_DENY = [
|
||||||
"browser",
|
"browser",
|
||||||
"canvas",
|
"canvas",
|
||||||
|
|||||||
@@ -17,7 +17,37 @@ const SessionsHistoryToolSchema = Type.Object({
|
|||||||
includeTools: Type.Optional(Type.Boolean()),
|
includeTools: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createSessionsHistoryTool(): AnyAgentTool {
|
function resolveSandboxSessionToolsVisibility(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
) {
|
||||||
|
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSpawnedSessionAllowed(params: {
|
||||||
|
requesterSessionKey: string;
|
||||||
|
targetSessionKey: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const list = (await callGateway({
|
||||||
|
method: "sessions.list",
|
||||||
|
params: {
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
|
limit: 500,
|
||||||
|
spawnedBy: params.requesterSessionKey,
|
||||||
|
},
|
||||||
|
})) as { sessions?: Array<Record<string, unknown>> };
|
||||||
|
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||||
|
return sessions.some((entry) => entry?.key === params.targetSessionKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionsHistoryTool(opts?: {
|
||||||
|
agentSessionKey?: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Session History",
|
label: "Session History",
|
||||||
name: "sessions_history",
|
name: "sessions_history",
|
||||||
@@ -30,11 +60,37 @@ export function createSessionsHistoryTool(): AnyAgentTool {
|
|||||||
});
|
});
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
|
const visibility = resolveSandboxSessionToolsVisibility(cfg);
|
||||||
|
const requesterInternalKey =
|
||||||
|
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
||||||
|
? resolveInternalSessionKey({
|
||||||
|
key: opts.agentSessionKey,
|
||||||
|
alias,
|
||||||
|
mainKey,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
const resolvedKey = resolveInternalSessionKey({
|
const resolvedKey = resolveInternalSessionKey({
|
||||||
key: sessionKey,
|
key: sessionKey,
|
||||||
alias,
|
alias,
|
||||||
mainKey,
|
mainKey,
|
||||||
});
|
});
|
||||||
|
const restrictToSpawned =
|
||||||
|
opts?.sandboxed === true &&
|
||||||
|
visibility === "spawned" &&
|
||||||
|
requesterInternalKey &&
|
||||||
|
!requesterInternalKey.toLowerCase().startsWith("subagent:");
|
||||||
|
if (restrictToSpawned) {
|
||||||
|
const ok = await isSpawnedSessionAllowed({
|
||||||
|
requesterSessionKey: requesterInternalKey,
|
||||||
|
targetSessionKey: resolvedKey,
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
return jsonResult({
|
||||||
|
status: "forbidden",
|
||||||
|
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const limit =
|
const limit =
|
||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? Math.max(1, Math.floor(params.limit))
|
? Math.max(1, Math.floor(params.limit))
|
||||||
|
|||||||
@@ -44,7 +44,16 @@ const SessionsListToolSchema = Type.Object({
|
|||||||
messageLimit: Type.Optional(Type.Integer({ minimum: 0 })),
|
messageLimit: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createSessionsListTool(): AnyAgentTool {
|
function resolveSandboxSessionToolsVisibility(
|
||||||
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
|
) {
|
||||||
|
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionsListTool(opts?: {
|
||||||
|
agentSessionKey?: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Sessions",
|
label: "Sessions",
|
||||||
name: "sessions_list",
|
name: "sessions_list",
|
||||||
@@ -54,6 +63,20 @@ export function createSessionsListTool(): AnyAgentTool {
|
|||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
|
const visibility = resolveSandboxSessionToolsVisibility(cfg);
|
||||||
|
const requesterInternalKey =
|
||||||
|
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
||||||
|
? resolveInternalSessionKey({
|
||||||
|
key: opts.agentSessionKey,
|
||||||
|
alias,
|
||||||
|
mainKey,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const restrictToSpawned =
|
||||||
|
opts?.sandboxed === true &&
|
||||||
|
visibility === "spawned" &&
|
||||||
|
requesterInternalKey &&
|
||||||
|
!requesterInternalKey.toLowerCase().startsWith("subagent:");
|
||||||
|
|
||||||
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
|
||||||
value.trim().toLowerCase(),
|
value.trim().toLowerCase(),
|
||||||
@@ -86,8 +109,9 @@ export function createSessionsListTool(): AnyAgentTool {
|
|||||||
params: {
|
params: {
|
||||||
limit,
|
limit,
|
||||||
activeMinutes,
|
activeMinutes,
|
||||||
includeGlobal: true,
|
includeGlobal: !restrictToSpawned,
|
||||||
includeUnknown: true,
|
includeUnknown: !restrictToSpawned,
|
||||||
|
spawnedBy: restrictToSpawned ? requesterInternalKey : undefined,
|
||||||
},
|
},
|
||||||
})) as {
|
})) as {
|
||||||
path?: string;
|
path?: string;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const SessionsSendToolSchema = Type.Object({
|
|||||||
export function createSessionsSendTool(opts?: {
|
export function createSessionsSendTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentSurface?: string;
|
agentSurface?: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Session Send",
|
label: "Session Send",
|
||||||
@@ -47,11 +48,64 @@ export function createSessionsSendTool(opts?: {
|
|||||||
const message = readStringParam(params, "message", { required: true });
|
const message = readStringParam(params, "message", { required: true });
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
|
const visibility =
|
||||||
|
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
|
||||||
|
const requesterInternalKey =
|
||||||
|
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
|
||||||
|
? resolveInternalSessionKey({
|
||||||
|
key: opts.agentSessionKey,
|
||||||
|
alias,
|
||||||
|
mainKey,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
const resolvedKey = resolveInternalSessionKey({
|
const resolvedKey = resolveInternalSessionKey({
|
||||||
key: sessionKey,
|
key: sessionKey,
|
||||||
alias,
|
alias,
|
||||||
mainKey,
|
mainKey,
|
||||||
});
|
});
|
||||||
|
const restrictToSpawned =
|
||||||
|
opts?.sandboxed === true &&
|
||||||
|
visibility === "spawned" &&
|
||||||
|
requesterInternalKey &&
|
||||||
|
!requesterInternalKey.toLowerCase().startsWith("subagent:");
|
||||||
|
if (restrictToSpawned) {
|
||||||
|
try {
|
||||||
|
const list = (await callGateway({
|
||||||
|
method: "sessions.list",
|
||||||
|
params: {
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
|
limit: 500,
|
||||||
|
spawnedBy: requesterInternalKey,
|
||||||
|
},
|
||||||
|
})) as { sessions?: Array<Record<string, unknown>> };
|
||||||
|
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
||||||
|
const ok = sessions.some((entry) => entry?.key === resolvedKey);
|
||||||
|
if (!ok) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
|
||||||
|
sessionKey: resolveDisplaySessionKey({
|
||||||
|
key: sessionKey,
|
||||||
|
alias,
|
||||||
|
mainKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
|
||||||
|
sessionKey: resolveDisplaySessionKey({
|
||||||
|
key: sessionKey,
|
||||||
|
alias,
|
||||||
|
mainKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const timeoutSeconds =
|
const timeoutSeconds =
|
||||||
typeof params.timeoutSeconds === "number" &&
|
typeof params.timeoutSeconds === "number" &&
|
||||||
Number.isFinite(params.timeoutSeconds)
|
Number.isFinite(params.timeoutSeconds)
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ async function runSubagentAnnounceFlow(params: {
|
|||||||
export function createSessionsSpawnTool(opts?: {
|
export function createSessionsSpawnTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentSurface?: string;
|
agentSurface?: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Sessions",
|
label: "Sessions",
|
||||||
@@ -185,6 +186,15 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
const requesterSessionKey = opts?.agentSessionKey;
|
const requesterSessionKey = opts?.agentSessionKey;
|
||||||
|
if (
|
||||||
|
typeof requesterSessionKey === "string" &&
|
||||||
|
requesterSessionKey.trim().toLowerCase().startsWith("subagent:")
|
||||||
|
) {
|
||||||
|
return jsonResult({
|
||||||
|
status: "forbidden",
|
||||||
|
error: "sessions_spawn is not allowed from sub-agent sessions",
|
||||||
|
});
|
||||||
|
}
|
||||||
const requesterInternalKey = requesterSessionKey
|
const requesterInternalKey = requesterSessionKey
|
||||||
? resolveInternalSessionKey({
|
? resolveInternalSessionKey({
|
||||||
key: requesterSessionKey,
|
key: requesterSessionKey,
|
||||||
@@ -199,6 +209,17 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const childSessionKey = `subagent:${crypto.randomUUID()}`;
|
const childSessionKey = `subagent:${crypto.randomUUID()}`;
|
||||||
|
if (opts?.sandboxed === true) {
|
||||||
|
try {
|
||||||
|
await callGateway({
|
||||||
|
method: "sessions.patch",
|
||||||
|
params: { key: childSessionKey, spawnedBy: requesterInternalKey },
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort; scoping relies on this metadata but spawning still works without it
|
||||||
|
}
|
||||||
|
}
|
||||||
const childSystemPrompt = buildSubagentSystemPrompt({
|
const childSystemPrompt = buildSubagentSystemPrompt({
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
requesterSurface: opts?.agentSurface,
|
requesterSurface: opts?.agentSurface,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type SessionChatType = "direct" | "group" | "room";
|
|||||||
export type SessionEntry = {
|
export type SessionEntry = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
|
||||||
|
spawnedBy?: string;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
chatType?: SessionChatType;
|
chatType?: SessionChatType;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export type AgentElevatedAllowFromConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WhatsAppConfig = {
|
export type WhatsAppConfig = {
|
||||||
|
/** Optional per-account WhatsApp configuration (multi-account). */
|
||||||
|
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
/** Optional allowlist for WhatsApp group senders (E.164). */
|
/** Optional allowlist for WhatsApp group senders (E.164). */
|
||||||
@@ -98,6 +100,23 @@ export type WhatsAppConfig = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WhatsAppAccountConfig = {
|
||||||
|
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Override auth directory (Baileys multi-file auth state). */
|
||||||
|
authDir?: string;
|
||||||
|
allowFrom?: string[];
|
||||||
|
groupAllowFrom?: string[];
|
||||||
|
groupPolicy?: GroupPolicy;
|
||||||
|
textChunkLimit?: number;
|
||||||
|
groups?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
requireMention?: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
export type BrowserProfileConfig = {
|
export type BrowserProfileConfig = {
|
||||||
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
||||||
cdpPort?: number;
|
cdpPort?: number;
|
||||||
@@ -488,6 +507,37 @@ export type RoutingConfig = {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
};
|
};
|
||||||
groupChat?: GroupChatConfig;
|
groupChat?: GroupChatConfig;
|
||||||
|
/** Default agent id when no binding matches. Default: "main". */
|
||||||
|
defaultAgentId?: string;
|
||||||
|
agentToAgent?: {
|
||||||
|
/** Enable agent-to-agent messaging tools. Default: false. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Allowlist of agent ids or patterns (implementation-defined). */
|
||||||
|
allow?: string[];
|
||||||
|
};
|
||||||
|
agents?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
workspace?: string;
|
||||||
|
agentDir?: string;
|
||||||
|
model?: string;
|
||||||
|
sandbox?: {
|
||||||
|
mode?: "off" | "non-main" | "all";
|
||||||
|
perSession?: boolean;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
bindings?: Array<{
|
||||||
|
agentId: string;
|
||||||
|
match: {
|
||||||
|
surface: string;
|
||||||
|
surfaceAccountId?: string;
|
||||||
|
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
||||||
|
guildId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
queue?: {
|
queue?: {
|
||||||
mode?: QueueMode;
|
mode?: QueueMode;
|
||||||
bySurface?: QueueModeBySurface;
|
bySurface?: QueueModeBySurface;
|
||||||
@@ -836,6 +886,12 @@ export type ClawdbotConfig = {
|
|||||||
sandbox?: {
|
sandbox?: {
|
||||||
/** Enable sandboxing for sessions. */
|
/** Enable sandboxing for sessions. */
|
||||||
mode?: "off" | "non-main" | "all";
|
mode?: "off" | "non-main" | "all";
|
||||||
|
/**
|
||||||
|
* Session tools visibility for sandboxed sessions.
|
||||||
|
* - "spawned": only allow session tools to target sessions spawned from this session (default)
|
||||||
|
* - "all": allow session tools to target any session
|
||||||
|
*/
|
||||||
|
sessionToolsVisibility?: "spawned" | "all";
|
||||||
/** Use one container per session (recommended for hard isolation). */
|
/** Use one container per session (recommended for hard isolation). */
|
||||||
perSession?: boolean;
|
perSession?: boolean;
|
||||||
/** Root directory for sandbox workspaces. */
|
/** Root directory for sandbox workspaces. */
|
||||||
|
|||||||
@@ -201,6 +201,61 @@ const RoutingSchema = z
|
|||||||
.object({
|
.object({
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
transcribeAudio: TranscribeAudioSchema,
|
transcribeAudio: TranscribeAudioSchema,
|
||||||
|
defaultAgentId: z.string().optional(),
|
||||||
|
agentToAgent: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
allow: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
agents: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
workspace: z.string().optional(),
|
||||||
|
agentDir: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
sandbox: z
|
||||||
|
.object({
|
||||||
|
mode: z
|
||||||
|
.union([
|
||||||
|
z.literal("off"),
|
||||||
|
z.literal("non-main"),
|
||||||
|
z.literal("all"),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
perSession: z.boolean().optional(),
|
||||||
|
workspaceRoot: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
bindings: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
agentId: z.string(),
|
||||||
|
match: z.object({
|
||||||
|
surface: z.string(),
|
||||||
|
surfaceAccountId: z.string().optional(),
|
||||||
|
peer: z
|
||||||
|
.object({
|
||||||
|
kind: z.union([
|
||||||
|
z.literal("dm"),
|
||||||
|
z.literal("group"),
|
||||||
|
z.literal("channel"),
|
||||||
|
]),
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
guildId: z.string().optional(),
|
||||||
|
teamId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
queue: z
|
queue: z
|
||||||
.object({
|
.object({
|
||||||
mode: QueueModeSchema.optional(),
|
mode: QueueModeSchema.optional(),
|
||||||
@@ -504,6 +559,9 @@ export const ClawdbotSchema = z.object({
|
|||||||
mode: z
|
mode: z
|
||||||
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
sessionToolsVisibility: z
|
||||||
|
.union([z.literal("spawned"), z.literal("all")])
|
||||||
|
.optional(),
|
||||||
perSession: z.boolean().optional(),
|
perSession: z.boolean().optional(),
|
||||||
workspaceRoot: z.string().optional(),
|
workspaceRoot: z.string().optional(),
|
||||||
docker: z
|
docker: z
|
||||||
@@ -608,6 +666,32 @@ export const ClawdbotSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
whatsapp: z
|
whatsapp: z
|
||||||
.object({
|
.object({
|
||||||
|
accounts: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
||||||
|
authDir: z.string().optional(),
|
||||||
|
allowFrom: z.array(z.string()).optional(),
|
||||||
|
groupAllowFrom: z.array(z.string()).optional(),
|
||||||
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
groups: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
groupAllowFrom: z.array(z.string()).optional(),
|
groupAllowFrom: z.array(z.string()).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export const SessionsListParamsSchema = Type.Object(
|
|||||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@@ -322,6 +323,7 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
|
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
sendPolicy: Type.Optional(
|
sendPolicy: Type.Optional(
|
||||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -349,6 +349,52 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
}
|
}
|
||||||
: { sessionId: randomUUID(), updatedAt: now };
|
: { sessionId: randomUUID(), updatedAt: now };
|
||||||
|
|
||||||
|
if ("spawnedBy" in p) {
|
||||||
|
const raw = p.spawnedBy;
|
||||||
|
if (raw === null) {
|
||||||
|
if (existing?.spawnedBy) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "spawnedBy cannot be cleared once set",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (raw !== undefined) {
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "invalid spawnedBy: empty",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!key.startsWith("subagent:")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message:
|
||||||
|
"spawnedBy is only supported for subagent:* sessions",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "spawnedBy cannot be changed once set",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
next.spawnedBy = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ("thinkingLevel" in p) {
|
if ("thinkingLevel" in p) {
|
||||||
const raw = p.thinkingLevel;
|
const raw = p.thinkingLevel;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
|
|||||||
@@ -110,6 +110,56 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
: { sessionId: randomUUID(), updatedAt: now };
|
: { sessionId: randomUUID(), updatedAt: now };
|
||||||
|
|
||||||
|
if ("spawnedBy" in p) {
|
||||||
|
const raw = p.spawnedBy;
|
||||||
|
if (raw === null) {
|
||||||
|
if (existing?.spawnedBy) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"spawnedBy cannot be cleared once set",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (raw !== undefined) {
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, "invalid spawnedBy: empty"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!key.startsWith("subagent:")) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"spawnedBy is only supported for subagent:* sessions",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"spawnedBy cannot be changed once set",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next.spawnedBy = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ("thinkingLevel" in p) {
|
if ("thinkingLevel" in p) {
|
||||||
const raw = p.thinkingLevel;
|
const raw = p.thinkingLevel;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ describe("gateway server sessions", () => {
|
|||||||
updatedAt: now - 120_000,
|
updatedAt: now - 120_000,
|
||||||
totalTokens: 50,
|
totalTokens: 50,
|
||||||
},
|
},
|
||||||
|
"subagent:one": {
|
||||||
|
sessionId: "sess-subagent",
|
||||||
|
updatedAt: now - 120_000,
|
||||||
|
spawnedBy: "main",
|
||||||
|
},
|
||||||
global: {
|
global: {
|
||||||
sessionId: "sess-global",
|
sessionId: "sess-global",
|
||||||
updatedAt: now - 10_000,
|
updatedAt: now - 10_000,
|
||||||
@@ -148,6 +153,31 @@ describe("gateway server sessions", () => {
|
|||||||
expect(main2?.verboseLevel).toBeUndefined();
|
expect(main2?.verboseLevel).toBeUndefined();
|
||||||
expect(main2?.sendPolicy).toBe("deny");
|
expect(main2?.sendPolicy).toBe("deny");
|
||||||
|
|
||||||
|
const spawnedOnly = await rpcReq<{
|
||||||
|
sessions: Array<{ key: string }>;
|
||||||
|
}>(ws, "sessions.list", {
|
||||||
|
includeGlobal: true,
|
||||||
|
includeUnknown: true,
|
||||||
|
spawnedBy: "main",
|
||||||
|
});
|
||||||
|
expect(spawnedOnly.ok).toBe(true);
|
||||||
|
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual([
|
||||||
|
"subagent:one",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const spawnedPatched = await rpcReq<{
|
||||||
|
ok: true;
|
||||||
|
entry: { spawnedBy?: string };
|
||||||
|
}>(ws, "sessions.patch", { key: "subagent:two", spawnedBy: "main" });
|
||||||
|
expect(spawnedPatched.ok).toBe(true);
|
||||||
|
expect(spawnedPatched.payload?.entry.spawnedBy).toBe("main");
|
||||||
|
|
||||||
|
const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", {
|
||||||
|
key: "main",
|
||||||
|
spawnedBy: "main",
|
||||||
|
});
|
||||||
|
expect(spawnedPatchedInvalidKey.ok).toBe(false);
|
||||||
|
|
||||||
piSdkMock.enabled = true;
|
piSdkMock.enabled = true;
|
||||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||||
const modelPatched = await rpcReq<{
|
const modelPatched = await rpcReq<{
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ export function listSessionsFromStore(params: {
|
|||||||
|
|
||||||
const includeGlobal = opts.includeGlobal === true;
|
const includeGlobal = opts.includeGlobal === true;
|
||||||
const includeUnknown = opts.includeUnknown === true;
|
const includeUnknown = opts.includeUnknown === true;
|
||||||
|
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||||
const activeMinutes =
|
const activeMinutes =
|
||||||
typeof opts.activeMinutes === "number" &&
|
typeof opts.activeMinutes === "number" &&
|
||||||
Number.isFinite(opts.activeMinutes)
|
Number.isFinite(opts.activeMinutes)
|
||||||
@@ -239,6 +240,11 @@ export function listSessionsFromStore(params: {
|
|||||||
if (!includeUnknown && key === "unknown") return false;
|
if (!includeUnknown && key === "unknown") return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
|
.filter(([key, entry]) => {
|
||||||
|
if (!spawnedBy) return true;
|
||||||
|
if (key === "unknown" || key === "global") return false;
|
||||||
|
return entry?.spawnedBy === spawnedBy;
|
||||||
|
})
|
||||||
.map(([key, entry]) => {
|
.map(([key, entry]) => {
|
||||||
const updatedAt = entry?.updatedAt ?? null;
|
const updatedAt = entry?.updatedAt ?? null;
|
||||||
const input = entry?.inputTokens ?? 0;
|
const input = entry?.inputTokens ?? 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user