feat: sandbox session tool visibility

This commit is contained in:
Peter Steinberger
2026-01-06 08:40:21 +00:00
parent ef58399fcd
commit 3693449d7e
18 changed files with 479 additions and 8 deletions

View File

@@ -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`.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- 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: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).

View File

@@ -35,6 +35,7 @@ Parameters:
Behavior:
- `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.
- When running in a **sandboxed** agent session, session tools default to **spawned-only visibility** (see below).
Row shape (JSON):
- `key`: session key (string)
@@ -131,5 +132,23 @@ Parameters:
Behavior:
- 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 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.
- 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"
}
}
}
```

View File

@@ -13,6 +13,7 @@ Primary goals:
- Parallelize “research / long task / slow tool” work without blocking the main run.
- 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.
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
## 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-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.

View File

@@ -17,6 +17,7 @@ export function createClawdbotTools(options?: {
browserControlUrl?: string;
agentSessionKey?: string;
agentSurface?: string;
sandboxed?: boolean;
config?: ClawdbotConfig;
}): AnyAgentTool[] {
const imageTool = createImageTool({ config: options?.config });
@@ -28,15 +29,23 @@ export function createClawdbotTools(options?: {
createDiscordTool(),
createSlackTool(),
createGatewayTool(),
createSessionsListTool(),
createSessionsHistoryTool(),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,
}),
createSessionsHistoryTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,
}),
createSessionsSendTool({
agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface,
sandboxed: options?.sandboxed,
}),
createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey,
agentSurface: options?.agentSurface,
sandboxed: options?.sandboxed,
}),
...(imageTool ? [imageTool] : []),
];

View File

@@ -556,6 +556,7 @@ export function createClawdbotCodingTools(options?: {
browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey,
agentSurface: options?.surface,
sandboxed: !!sandbox,
config: options?.config,
}),
];

View File

@@ -114,7 +114,17 @@ const DEFAULT_SANDBOX_CONTAINER_PREFIX = "clawdbot-sbx-";
const DEFAULT_SANDBOX_WORKDIR = "/workspace";
const DEFAULT_SANDBOX_IDLE_HOURS = 24;
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 = [
"browser",
"canvas",

View File

@@ -17,7 +17,37 @@ const SessionsHistoryToolSchema = Type.Object({
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 {
label: "Session History",
name: "sessions_history",
@@ -30,11 +60,37 @@ export function createSessionsHistoryTool(): AnyAgentTool {
});
const cfg = loadConfig();
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({
key: sessionKey,
alias,
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 =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))

View File

@@ -44,7 +44,16 @@ const SessionsListToolSchema = Type.Object({
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 {
label: "Sessions",
name: "sessions_list",
@@ -54,6 +63,20 @@ export function createSessionsListTool(): AnyAgentTool {
const params = args as Record<string, unknown>;
const cfg = loadConfig();
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) =>
value.trim().toLowerCase(),
@@ -86,8 +109,9 @@ export function createSessionsListTool(): AnyAgentTool {
params: {
limit,
activeMinutes,
includeGlobal: true,
includeUnknown: true,
includeGlobal: !restrictToSpawned,
includeUnknown: !restrictToSpawned,
spawnedBy: restrictToSpawned ? requesterInternalKey : undefined,
},
})) as {
path?: string;

View File

@@ -33,6 +33,7 @@ const SessionsSendToolSchema = Type.Object({
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return {
label: "Session Send",
@@ -47,11 +48,64 @@ export function createSessionsSendTool(opts?: {
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
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({
key: sessionKey,
alias,
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 =
typeof params.timeoutSeconds === "number" &&
Number.isFinite(params.timeoutSeconds)

View File

@@ -160,6 +160,7 @@ async function runSubagentAnnounceFlow(params: {
export function createSessionsSpawnTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return {
label: "Sessions",
@@ -185,6 +186,15 @@ export function createSessionsSpawnTool(opts?: {
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
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
? resolveInternalSessionKey({
key: requesterSessionKey,
@@ -199,6 +209,17 @@ export function createSessionsSpawnTool(opts?: {
});
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({
requesterSessionKey,
requesterSurface: opts?.agentSurface,

View File

@@ -26,6 +26,8 @@ export type SessionChatType = "direct" | "group" | "room";
export type SessionEntry = {
sessionId: string;
updatedAt: number;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
chatType?: SessionChatType;

View File

@@ -77,6 +77,8 @@ export type AgentElevatedAllowFromConfig = {
};
export type WhatsAppConfig = {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
/** 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 = {
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
cdpPort?: number;
@@ -488,6 +507,37 @@ export type RoutingConfig = {
timeoutSeconds?: number;
};
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?: {
mode?: QueueMode;
bySurface?: QueueModeBySurface;
@@ -836,6 +886,12 @@ export type ClawdbotConfig = {
sandbox?: {
/** Enable sandboxing for sessions. */
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). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */

View File

@@ -201,6 +201,61 @@ const RoutingSchema = z
.object({
groupChat: GroupChatSchema,
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
.object({
mode: QueueModeSchema.optional(),
@@ -504,6 +559,9 @@ export const ClawdbotSchema = z.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: z
@@ -608,6 +666,32 @@ export const ClawdbotSchema = z.object({
.optional(),
whatsapp: z
.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(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),

View File

@@ -311,6 +311,7 @@ export const SessionsListParamsSchema = Type.Object(
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
spawnedBy: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -322,6 +323,7 @@ export const SessionsPatchParamsSchema = Type.Object(
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
elevatedLevel: 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(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
),

View File

@@ -349,6 +349,52 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
}
: { 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) {
const raw = p.thinkingLevel;
if (raw === null) {

View File

@@ -110,6 +110,56 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
: { 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) {
const raw = p.thinkingLevel;
if (raw === null) {

View File

@@ -53,6 +53,11 @@ describe("gateway server sessions", () => {
updatedAt: now - 120_000,
totalTokens: 50,
},
"subagent:one": {
sessionId: "sess-subagent",
updatedAt: now - 120_000,
spawnedBy: "main",
},
global: {
sessionId: "sess-global",
updatedAt: now - 10_000,
@@ -148,6 +153,31 @@ describe("gateway server sessions", () => {
expect(main2?.verboseLevel).toBeUndefined();
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.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const modelPatched = await rpcReq<{

View File

@@ -227,6 +227,7 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const activeMinutes =
typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes)
@@ -239,6 +240,11 @@ export function listSessionsFromStore(params: {
if (!includeUnknown && key === "unknown") return false;
return true;
})
.filter(([key, entry]) => {
if (!spawnedBy) return true;
if (key === "unknown" || key === "global") return false;
return entry?.spawnedBy === spawnedBy;
})
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
const input = entry?.inputTokens ?? 0;