mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(agents): make requested threads sidebar-visible (#118073)
* fix(agents): make requested threads sidebar-visible * fix(agents): preserve visible spawn authority
This commit is contained in:
@@ -255,7 +255,7 @@ their latest assistant turn back to the requester; external delivery stays with
|
||||
the parent/requester agent.
|
||||
</Warning>
|
||||
|
||||
With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. Visible spawning is rejected when the requester was itself spawned with an inherited tool allowlist or denylist; that restriction is fixed at spawn time and has no config override. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior.
|
||||
With `visible: true`, `model`, `cwd`, and a same-agent `context: "fork"` are supported. Use this mode when the user asks to create or open a thread that should appear in the sidebar. A sandboxed target restricts `cwd` to that agent's workspace. Thread binding, `mode`, thinking overrides, `lightContext`, `attachments`, and `attachAs` are unavailable on this path because visible sessions are persistent dashboard sessions created through `sessions.create`. The new dashboard child inherits the requester's effective tool-policy ceiling before its first turn. Session listing and addressing obey `tools.sessions.visibility`; the default `tree` scope covers the current session and its own spawn subtree. See [Managed worktrees](/concepts/managed-worktrees) for checkout naming, setup, cleanup, and restore behavior.
|
||||
|
||||
### Task names and targeting
|
||||
|
||||
|
||||
@@ -101,6 +101,36 @@ describe("resolveRequesterToolPolicies", () => {
|
||||
expect(result.subagentPolicy).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses a persisted projection for a spawn-owned dashboard child", async () => {
|
||||
const parentSessionKey = "agent:main:main";
|
||||
const childSessionKey = "agent:main:dashboard:visible-child";
|
||||
await writeSession(childSessionKey, {
|
||||
spawnedBy: parentSessionKey,
|
||||
parentSessionKey,
|
||||
spawnDepth: 1,
|
||||
inheritedToolPolicyVersion: 1,
|
||||
inheritedToolAllow: ["read", "sessions_spawn"],
|
||||
inheritedToolDeny: ["exec"],
|
||||
});
|
||||
|
||||
const result = resolveRequesterToolPolicies({
|
||||
config: config(),
|
||||
agentId: "main",
|
||||
sessionKey: childSessionKey,
|
||||
spawnedBy: parentSessionKey,
|
||||
});
|
||||
|
||||
expect(result.delegated).toBe(true);
|
||||
expect(result.requesterPolicySource).toBe("persisted-child");
|
||||
expect(result.senderPolicy).toBeUndefined();
|
||||
expect(result.groupPolicy).toBeUndefined();
|
||||
expect(result.inheritedToolPolicy).toEqual({
|
||||
allow: ["read", "sessions_spawn"],
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(result.subagentPolicy).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps the sender snapshot while applying current non-sender restrictions", async () => {
|
||||
const childSessionKey = "agent:main:subagent:web-search";
|
||||
await writeSession(childSessionKey, {
|
||||
@@ -417,6 +447,61 @@ describe("resolveRequesterToolPolicies", () => {
|
||||
expect(controllerResult.requesterPolicySource).toBe("current-request");
|
||||
});
|
||||
|
||||
it("restores a visible dashboard child completion to its immutable owner", async () => {
|
||||
const controllerSessionKey = "agent:main:discord:direct:alice";
|
||||
const completionOwnerSessionKey = "agent:main:main";
|
||||
const childSessionKey = "agent:main:dashboard:visible-child";
|
||||
await writeSession(childSessionKey, {
|
||||
spawnedBy: controllerSessionKey,
|
||||
completionOwnerSessionKey,
|
||||
spawnDepth: 1,
|
||||
inheritedToolPolicyVersion: 1,
|
||||
inheritedToolAllow: ["read", "message"],
|
||||
inheritedToolDeny: ["exec"],
|
||||
});
|
||||
|
||||
const result = resolveRequesterToolPolicies({
|
||||
config: config(),
|
||||
agentId: "main",
|
||||
sessionKey: completionOwnerSessionKey,
|
||||
...completionHandoffFacts(childSessionKey, completionOwnerSessionKey),
|
||||
inputProvenance: {
|
||||
kind: "inter_session",
|
||||
sourceSessionKey: childSessionKey,
|
||||
sourceTool: "subagent_announce",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
delegated: true,
|
||||
requesterPolicySource: "completion-handoff",
|
||||
inheritedToolPolicy: {
|
||||
allow: ["read", "message"],
|
||||
deny: ["exec"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat an ordinary dashboard key as a completion authority", async () => {
|
||||
const childSessionKey = "agent:main:dashboard:operator-thread";
|
||||
await writeSession(childSessionKey, { spawnDepth: 0 });
|
||||
|
||||
const result = resolveRequesterToolPolicies({
|
||||
config: config(),
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
...completionHandoffFacts(childSessionKey, "agent:main:main"),
|
||||
inputProvenance: {
|
||||
kind: "inter_session",
|
||||
sourceSessionKey: childSessionKey,
|
||||
sourceTool: "subagent_announce",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.delegated).toBe(false);
|
||||
expect(result.requesterPolicySource).toBe("current-request");
|
||||
});
|
||||
|
||||
it("walks nested lineage to the projection captured from the target requester", async () => {
|
||||
const requesterSessionKey = "agent:main:discord:direct:alice";
|
||||
const parentChildSessionKey = "agent:main:subagent:parent-child";
|
||||
|
||||
@@ -123,8 +123,14 @@ function resolveDelegatedPolicy(
|
||||
return { delegated: false };
|
||||
}
|
||||
visited.add(currentSessionKey);
|
||||
// The signed handoff authorizes the one store lookup needed for dashboard
|
||||
// children; the persisted envelope still has to prove lineage and depth.
|
||||
const completionStore = resolveSubagentCapabilityStore(currentSessionKey, {
|
||||
cfg: params.config,
|
||||
});
|
||||
const envelope = resolvePersistedSubagentToolPolicyEnvelope(currentSessionKey, {
|
||||
cfg: params.config,
|
||||
store: completionStore,
|
||||
});
|
||||
if (!envelope) {
|
||||
return { delegated: false };
|
||||
|
||||
@@ -92,6 +92,20 @@ function shouldInspectStoredSubagentEnvelope(sessionKey: string): boolean {
|
||||
return isSubagentSessionKey(sessionKey) || isAcpSessionKey(sessionKey);
|
||||
}
|
||||
|
||||
function isDashboardSessionKey(sessionKey: string): boolean {
|
||||
return parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true;
|
||||
}
|
||||
|
||||
function canInspectStoredSubagentEnvelope(
|
||||
sessionKey: string,
|
||||
store?: SessionCapabilityStore,
|
||||
): boolean {
|
||||
return (
|
||||
shouldInspectStoredSubagentEnvelope(sessionKey) ||
|
||||
(Boolean(store) && isDashboardSessionKey(sessionKey))
|
||||
);
|
||||
}
|
||||
|
||||
function isSameAgentSessionStore(leftSessionKey: string, rightSessionKey: string): boolean {
|
||||
const leftAgentId = normalizeOptionalLowercaseString(
|
||||
parseAgentSessionKey(leftSessionKey)?.agentId,
|
||||
@@ -140,7 +154,13 @@ export function resolveSubagentCapabilityStore(
|
||||
if (opts?.store) {
|
||||
return opts.store;
|
||||
}
|
||||
if (!opts?.cfg || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
|
||||
// Dashboard key shape permits only a store lookup. Callers still require a
|
||||
// persisted spawn envelope before granting subagent authority.
|
||||
if (
|
||||
!opts?.cfg ||
|
||||
(!shouldInspectStoredSubagentEnvelope(normalizedSessionKey) &&
|
||||
!isDashboardSessionKey(normalizedSessionKey))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(normalizedSessionKey);
|
||||
@@ -204,7 +224,8 @@ function isStoredSubagentEnvelopeSession(
|
||||
if (isSubagentSessionKey(normalizedSessionKey)) {
|
||||
return true;
|
||||
}
|
||||
if (!isAcpSessionKey(normalizedSessionKey)) {
|
||||
const dashboardSession = isDashboardSessionKey(normalizedSessionKey);
|
||||
if (!isAcpSessionKey(normalizedSessionKey) && !dashboardSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -215,6 +236,14 @@ function isStoredSubagentEnvelopeSession(
|
||||
cfg: params.cfg,
|
||||
store: params.store,
|
||||
});
|
||||
if (dashboardSession) {
|
||||
return (
|
||||
typeof entry?.spawnDepth === "number" &&
|
||||
Number.isInteger(entry.spawnDepth) &&
|
||||
entry.spawnDepth >= 1 &&
|
||||
Boolean(normalizeOptionalString(entry.spawnedBy))
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalizeSubagentRole(entry?.subagentRole) ||
|
||||
normalizeSubagentControlScope(entry?.subagentControlScope)
|
||||
@@ -257,7 +286,10 @@ export function isSubagentEnvelopeSession(
|
||||
if (isSubagentSessionKey(normalizedSessionKey)) {
|
||||
return true;
|
||||
}
|
||||
if (!isAcpSessionKey(normalizedSessionKey)) {
|
||||
if (!isAcpSessionKey(normalizedSessionKey) && !isDashboardSessionKey(normalizedSessionKey)) {
|
||||
return false;
|
||||
}
|
||||
if (isDashboardSessionKey(normalizedSessionKey) && !opts?.entry && !opts?.store) {
|
||||
return false;
|
||||
}
|
||||
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
|
||||
@@ -282,7 +314,10 @@ export function resolvePersistedSubagentToolPolicyEnvelope(
|
||||
},
|
||||
): PersistedSubagentToolPolicyEnvelope | undefined {
|
||||
const normalizedSessionKey = normalizeOptionalString(sessionKey);
|
||||
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
|
||||
if (
|
||||
!normalizedSessionKey ||
|
||||
!canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
|
||||
@@ -382,7 +417,10 @@ export function resolveStoredSubagentInheritedToolDenylist(
|
||||
},
|
||||
): string[] {
|
||||
const normalizedSessionKey = normalizeOptionalString(sessionKey);
|
||||
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
|
||||
if (
|
||||
!normalizedSessionKey ||
|
||||
!canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
|
||||
@@ -403,7 +441,10 @@ export function resolveStoredSubagentInheritedToolAllowlist(
|
||||
},
|
||||
): string[] {
|
||||
const normalizedSessionKey = normalizeOptionalString(sessionKey);
|
||||
if (!normalizedSessionKey || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) {
|
||||
if (
|
||||
!normalizedSessionKey ||
|
||||
!canInspectStoredSubagentEnvelope(normalizedSessionKey, opts?.store)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts);
|
||||
|
||||
@@ -109,7 +109,7 @@ export function describeSessionsSpawnTool(options?: {
|
||||
? '`mode="run"` one-shot; `mode="session"` persistent/thread-bound only on supporting requester channel.'
|
||||
: '`mode="run"` one-shot background.',
|
||||
"`agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require.",
|
||||
'`visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`.',
|
||||
'`visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`.',
|
||||
visibilityLine,
|
||||
...(options?.swarmEnabled
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { AgentRuntimeSessionSpawnContext } from "../../gateway/agent-runtime-identity-token.js";
|
||||
|
||||
const sessionSpawnContext = new AsyncLocalStorage<AgentRuntimeSessionSpawnContext>();
|
||||
|
||||
/** Scope signed session-creation authority to one local Gateway tool call. */
|
||||
export function runWithGatewaySessionSpawnContext<T>(
|
||||
context: AgentRuntimeSessionSpawnContext,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return sessionSpawnContext.run(context, run);
|
||||
}
|
||||
|
||||
export function getGatewaySessionSpawnContext(): AgentRuntimeSessionSpawnContext | undefined {
|
||||
return sessionSpawnContext.getStore();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
revokeMessageActionTurnCapability,
|
||||
} from "../../gateway/message-action-turn-capability.js";
|
||||
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
|
||||
import { callGatewayTool, resolveMessageActionAgentRuntimeIdentityToken } from "./gateway.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -65,6 +66,37 @@ describe("gateway tool runtime identity", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("scopes signed session-spawn authority to its Gateway call", async () => {
|
||||
mocks.callGateway.mockResolvedValueOnce({ key: "agent:ops:dashboard:child" });
|
||||
|
||||
await withGatewayToolCallerIdentity(
|
||||
{ agentId: "ops", sessionKey: "agent:ops:main" },
|
||||
async () =>
|
||||
await runWithGatewaySessionSpawnContext(
|
||||
{
|
||||
completionOwnerSessionKey: "agent:ops:discord:direct:alice",
|
||||
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
|
||||
},
|
||||
() =>
|
||||
callGatewayTool(
|
||||
"sessions.create",
|
||||
{},
|
||||
{ parentSessionKey: "agent:ops:main", spawnDepth: 1 },
|
||||
{ requireAgentRuntimeIdentity: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyAgentRuntimeIdentityToken(capturedGatewayCall().agentRuntimeIdentityToken),
|
||||
).resolves.toMatchObject({
|
||||
sessionSpawnContext: {
|
||||
completionOwnerSessionKey: "agent:ops:discord:direct:alice",
|
||||
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("mints message action identity only for an exact admitted source turn", async () => {
|
||||
const capabilityInput = {
|
||||
agentId: "ops",
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { readPositiveIntegerParam, readStringParam } from "./common.js";
|
||||
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
|
||||
|
||||
/** Optional gateway connection overrides accepted by agent tools. */
|
||||
export type GatewayCallOptions = {
|
||||
@@ -366,7 +367,11 @@ async function resolveAgentRuntimeIdentityTokenForGatewayTool(params: {
|
||||
throw new Error("agent gateway calls require the trusted local gateway context");
|
||||
}
|
||||
try {
|
||||
return await mintAgentRuntimeIdentityToken(identity);
|
||||
const sessionSpawnContext = getGatewaySessionSpawnContext();
|
||||
return await mintAgentRuntimeIdentityToken({
|
||||
...identity,
|
||||
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (optionalLocalIdentity && !params.required) {
|
||||
return undefined;
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("../../gateway/server-plugins.js", () => ({
|
||||
|
||||
vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool }));
|
||||
|
||||
import { getGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
|
||||
import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js";
|
||||
|
||||
describe("trusted in-process Gateway session creation", () => {
|
||||
@@ -55,4 +56,43 @@ describe("trusted in-process Gateway session creation", () => {
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("carries visible-spawn policy through signed identity on fallback dispatch", async () => {
|
||||
mocks.hasContext = false;
|
||||
const inheritedToolPolicy = {
|
||||
version: 1 as const,
|
||||
allow: ["read", "sessions_spawn"],
|
||||
deny: ["exec"],
|
||||
};
|
||||
|
||||
mocks.callGatewayTool.mockImplementationOnce(async () => {
|
||||
expect(getGatewaySessionSpawnContext()).toEqual({
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
inheritedToolPolicy,
|
||||
});
|
||||
return { key: "agent:main:dashboard:child" };
|
||||
});
|
||||
|
||||
await callInProcessGatewayToolWithCreation(
|
||||
"sessions.create",
|
||||
{ agentId: "main", parentSessionKey: "agent:main:main", spawnDepth: 1 },
|
||||
{
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: "agent:main:main" },
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
inheritedToolPolicy,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"sessions.create",
|
||||
{},
|
||||
{ agentId: "main", parentSessionKey: "agent:main:main", spawnDepth: 1 },
|
||||
{
|
||||
scopes: ["operator.write"],
|
||||
requireAgentRuntimeIdentity: true,
|
||||
},
|
||||
);
|
||||
expect(getGatewaySessionSpawnContext()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getInProcessGatewayRequestContext,
|
||||
hasInProcessGatewayContext,
|
||||
} from "../../gateway/server-plugins.js";
|
||||
import { runWithGatewaySessionSpawnContext } from "./gateway-session-spawn-context.js";
|
||||
import { callGatewayTool } from "./gateway.js";
|
||||
|
||||
export type InProcessGatewayCaller = <T = Record<string, unknown>>(
|
||||
@@ -49,6 +50,22 @@ export async function callInProcessGatewayToolWithCreation<T = Record<string, un
|
||||
syntheticScopes: scopes,
|
||||
});
|
||||
}
|
||||
// The fallback is a real Gateway request; trusted creation metadata never crosses the wire.
|
||||
return await callGatewayTool<T>(method, {}, params, { scopes });
|
||||
// The fallback is a real local Gateway request. Carry spawn policy only in
|
||||
// the signed agent-runtime identity token, never in model-authored params.
|
||||
if (creation.via !== "spawn" || !creation.inheritedToolPolicy) {
|
||||
return await callGatewayTool<T>(method, {}, params, { scopes });
|
||||
}
|
||||
return await runWithGatewaySessionSpawnContext(
|
||||
{
|
||||
...(creation.completionOwnerSessionKey
|
||||
? { completionOwnerSessionKey: creation.completionOwnerSessionKey }
|
||||
: {}),
|
||||
inheritedToolPolicy: creation.inheritedToolPolicy,
|
||||
},
|
||||
() =>
|
||||
callGatewayTool<T>(method, {}, params, {
|
||||
scopes,
|
||||
requireAgentRuntimeIdentity: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
|
||||
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
|
||||
} from "../swarm-code-mode.js";
|
||||
import type { InProcessGatewayCaller } from "./in-process-gateway.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const spawnSubagentDirectMock = vi.fn();
|
||||
@@ -388,11 +389,12 @@ describe("sessions_spawn tool", () => {
|
||||
};
|
||||
|
||||
expect(schema.properties?.visible?.description).toBe(
|
||||
"Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
|
||||
"Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
|
||||
);
|
||||
expect(tool.description).toContain("`visible=true`: persistent dashboard session");
|
||||
expect(tool.description).toContain("`visible=true`: persistent sidebar dashboard session");
|
||||
expect(tool.description).toContain("when the user asks to create/open a thread");
|
||||
expect(tool.description).toContain('no `mode="run"`');
|
||||
expect(tool.description).toContain("inherited tool allow/denylist");
|
||||
expect(tool.description).toContain("inherits the caller tool-policy ceiling");
|
||||
expect(tool.description).toContain("`tools.sessions.visibility`");
|
||||
expect(schema.properties?.runtime?.description).toContain("visible=true");
|
||||
expect(schema.properties?.mode?.description).toContain("Omit with visible=true");
|
||||
@@ -718,26 +720,49 @@ describe("sessions_spawn tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("denies visible sessions when tool restrictions cannot carry forward", async () => {
|
||||
const callGateway = vi.fn();
|
||||
it("creates visible sessions while carrying inherited tool restrictions forward", async () => {
|
||||
const callGateway = vi.fn(async () => ({
|
||||
key: "agent:main:dashboard:restricted-child",
|
||||
runStarted: true,
|
||||
runId: "run-visible-restricted",
|
||||
})) as InProcessGatewayCaller;
|
||||
const registerRun = vi.fn();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: { agents: { list: [{ id: "main" }] } },
|
||||
inheritedToolAllowlist: ["read", "sessions_spawn"],
|
||||
inheritedToolDenylist: ["exec"],
|
||||
callGateway,
|
||||
registerRun,
|
||||
countActiveRuns: () => 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("visible-restricted", {
|
||||
task: "inspect",
|
||||
label: "Track upstream fix",
|
||||
visible: true,
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "forbidden",
|
||||
error:
|
||||
"Visible sessions unavailable with inherited tool restrictions. This session was spawned with a tool allow/denylist; visible sessions require an unrestricted session.",
|
||||
status: "accepted",
|
||||
childSessionKey: "agent:main:dashboard:restricted-child",
|
||||
runId: "run-visible-restricted",
|
||||
});
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
"sessions.create",
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
label: "Track upstream fix",
|
||||
parentSessionKey: "agent:main:main",
|
||||
spawnDepth: 1,
|
||||
}),
|
||||
);
|
||||
expect(registerRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childSessionKey: "agent:main:dashboard:restricted-child",
|
||||
runId: "run-visible-restricted",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks unsandboxed visible targets for a sandboxed caller runtime", async () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const VISIBLE_SESSIONS_SPAWN_SCHEMA = {
|
||||
visible: Type.Optional(
|
||||
Type.Boolean({
|
||||
description:
|
||||
"Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
|
||||
"Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
|
||||
}),
|
||||
),
|
||||
worktree: Type.Optional(Type.Boolean({ description: "Visible session worktree" })),
|
||||
@@ -161,16 +161,6 @@ export async function maybeSpawnVisibleSession(params: {
|
||||
}
|
||||
|
||||
const cfg = params.options?.config ?? getRuntimeConfig();
|
||||
if (
|
||||
(params.options?.inheritedToolAllowlist?.length ?? 0) > 0 ||
|
||||
(params.options?.inheritedToolDenylist?.length ?? 0) > 0
|
||||
) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
error:
|
||||
"Visible sessions unavailable with inherited tool restrictions. This session was spawned with a tool allow/denylist; visible sessions require an unrestricted session.",
|
||||
};
|
||||
}
|
||||
const ownership = resolveSubagentSpawnOwnership({
|
||||
cfg,
|
||||
agentSessionKey: params.options?.agentSessionKey,
|
||||
@@ -288,6 +278,12 @@ export async function maybeSpawnVisibleSession(params: {
|
||||
callInProcessGatewayToolWithCreation(method, requestParams, {
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: requesterKey },
|
||||
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
|
||||
inheritedToolPolicy: {
|
||||
version: 1,
|
||||
allow: [...(params.options?.inheritedToolAllowlist ?? [])],
|
||||
deny: [...(params.options?.inheritedToolDenylist ?? [])],
|
||||
},
|
||||
}));
|
||||
const response = await createGatewayCall<{
|
||||
key?: string;
|
||||
|
||||
@@ -84,6 +84,37 @@ describe("agent runtime identity token", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a signed visible-session spawn policy", async () => {
|
||||
useTempHome();
|
||||
const runtimeToken = await importRuntimeTokenModule();
|
||||
const token = await runtimeToken.mintAgentRuntimeIdentityToken({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionSpawnContext: {
|
||||
completionOwnerSessionKey: " agent:main:discord:direct:alice ",
|
||||
inheritedToolPolicy: {
|
||||
version: 1,
|
||||
allow: [" read ", "sessions_spawn"],
|
||||
deny: ["exec"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toMatchObject({
|
||||
kind: "agentRuntime",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionSpawnContext: {
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
inheritedToolPolicy: {
|
||||
version: 1,
|
||||
allow: ["read", "sessions_spawn"],
|
||||
deny: ["exec"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a short-lived cron self-management capability", async () => {
|
||||
useTempHome();
|
||||
const runtimeToken = await importRuntimeTokenModule();
|
||||
|
||||
@@ -28,6 +28,16 @@ export type AgentRuntimeIdentity = {
|
||||
turnSourceAccountId?: string;
|
||||
messageActionContext?: AgentRuntimeMessageActionContext;
|
||||
cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext;
|
||||
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
|
||||
};
|
||||
|
||||
export type AgentRuntimeSessionSpawnContext = {
|
||||
completionOwnerSessionKey?: string;
|
||||
inheritedToolPolicy: {
|
||||
version: 1;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type AgentRuntimeIdentityTokenPayload = {
|
||||
@@ -37,8 +47,36 @@ type AgentRuntimeIdentityTokenPayload = {
|
||||
turnSourceAccountId?: string;
|
||||
messageActionContext?: AgentRuntimeMessageActionContext;
|
||||
cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext;
|
||||
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
|
||||
};
|
||||
|
||||
function decodeStringList(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
||||
return undefined;
|
||||
}
|
||||
return value.map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function decodeSessionSpawnContext(value: unknown): AgentRuntimeSessionSpawnContext | undefined {
|
||||
if (!isRecord(value) || !isRecord(value.inheritedToolPolicy)) {
|
||||
return undefined;
|
||||
}
|
||||
const policy = value.inheritedToolPolicy;
|
||||
const allow = decodeStringList(policy.allow);
|
||||
const deny = decodeStringList(policy.deny);
|
||||
if (policy.version !== 1 || !allow || !deny) {
|
||||
return undefined;
|
||||
}
|
||||
const completionOwnerSessionKey = normalizeOptionalString(value.completionOwnerSessionKey);
|
||||
if (value.completionOwnerSessionKey !== undefined && !completionOwnerSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(completionOwnerSessionKey ? { completionOwnerSessionKey } : {}),
|
||||
inheritedToolPolicy: { version: 1, allow, deny },
|
||||
};
|
||||
}
|
||||
|
||||
async function readSharedAgentRuntimeIdentitySecret(): Promise<string | null> {
|
||||
return (await loadExecApprovalsAsync()).socket?.token?.trim() || null;
|
||||
}
|
||||
@@ -175,6 +213,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
turnSourceAccountId?: unknown;
|
||||
messageActionContext?: unknown;
|
||||
cronSelfManagementContext?: unknown;
|
||||
sessionSpawnContext?: unknown;
|
||||
};
|
||||
if (
|
||||
raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND ||
|
||||
@@ -219,6 +258,13 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
if (rawCronSelfManagement !== undefined && !cronSelfManagementContext) {
|
||||
return undefined;
|
||||
}
|
||||
const sessionSpawnContext =
|
||||
raw.sessionSpawnContext === undefined
|
||||
? undefined
|
||||
: decodeSessionSpawnContext(raw.sessionSpawnContext);
|
||||
if (raw.sessionSpawnContext !== undefined && !sessionSpawnContext) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND,
|
||||
agentId,
|
||||
@@ -226,6 +272,7 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP
|
||||
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
|
||||
...(messageActionContext ? { messageActionContext } : {}),
|
||||
...(cronSelfManagementContext ? { cronSelfManagementContext } : {}),
|
||||
...(sessionSpawnContext ? { sessionSpawnContext } : {}),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -239,6 +286,7 @@ export async function mintAgentRuntimeIdentityToken(params: {
|
||||
turnSourceAccountId?: string;
|
||||
messageActionContext?: AgentRuntimeMessageActionContext;
|
||||
cronSelfManagementJobId?: string;
|
||||
sessionSpawnContext?: AgentRuntimeSessionSpawnContext;
|
||||
}): Promise<string> {
|
||||
if (
|
||||
params.messageActionContext?.sourceReplyFinal === true &&
|
||||
@@ -272,6 +320,7 @@ export async function mintAgentRuntimeIdentityToken(params: {
|
||||
...(turnSourceAccountId ? { turnSourceAccountId } : {}),
|
||||
...(messageActionContext ? { messageActionContext } : {}),
|
||||
...(cronSelfManagementContext ? { cronSelfManagementContext } : {}),
|
||||
...(params.sessionSpawnContext ? { sessionSpawnContext: params.sessionSpawnContext } : {}),
|
||||
});
|
||||
const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload);
|
||||
return `${payload}.${signature}`;
|
||||
@@ -307,5 +356,6 @@ export async function verifyAgentRuntimeIdentityToken(
|
||||
...(payload.cronSelfManagementContext
|
||||
? { cronSelfManagementContext: payload.cronSelfManagementContext }
|
||||
: {}),
|
||||
...(payload.sessionSpawnContext ? { sessionSpawnContext: payload.sessionSpawnContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4237,6 +4237,26 @@ describe("agent event handler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes spawnedBy in chat broadcasts for spawn-owned dashboard sessions", () => {
|
||||
mockSessionLineage("agent:main:dashboard:visible-child", "agent:main:discord:direct:alice");
|
||||
const { broadcast, handler, chatRunState } = createHarness({
|
||||
resolveSessionKeyForRun: () => "agent:main:dashboard:visible-child",
|
||||
});
|
||||
registerChatRun(
|
||||
chatRunState,
|
||||
"run-dashboard-child",
|
||||
"agent:main:dashboard:visible-child",
|
||||
"client-dashboard-child",
|
||||
);
|
||||
|
||||
emitAgentEvent(handler, "run-dashboard-child", "assistant", { text: "visible child" });
|
||||
|
||||
expectPayloadFields(chatBroadcastCalls(broadcast)[0]?.[1], {
|
||||
sessionKey: "agent:main:dashboard:visible-child",
|
||||
spawnedBy: "agent:main:discord:direct:alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips session row load entirely for session keys that cannot carry lineage", () => {
|
||||
const { broadcast, handler, chatRunState } = createHarness({
|
||||
resolveSessionKeyForRun: () => "agent:main:main",
|
||||
@@ -4254,10 +4274,9 @@ describe("agent event handler", () => {
|
||||
);
|
||||
}
|
||||
|
||||
// The chat delta path invokes resolveSpawnedBy only. Non-subagent,
|
||||
// non-acp keys cannot carry spawnedBy (see supportsSpawnLineage in
|
||||
// sessions-patch.ts), so resolveSpawnedBy must short-circuit without
|
||||
// ever calling loadGatewaySessionRow on this hot path.
|
||||
// The chat delta path invokes resolveSpawnedBy only. Main/channel keys
|
||||
// cannot carry spawn lineage, so resolveSpawnedBy must short-circuit
|
||||
// without calling loadGatewaySessionRow on this hot path.
|
||||
expect(loadGatewaySessionRow).not.toHaveBeenCalled();
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
isAcpSessionKey,
|
||||
isSubagentSessionKey,
|
||||
@@ -448,9 +449,9 @@ export function createAgentEventHandler({
|
||||
}
|
||||
};
|
||||
|
||||
// Only subagent/acp keys can carry spawnedBy (mirrors supportsSpawnLineage in
|
||||
// sessions-patch.ts). Short-circuit everyone else so high-volume chat streams
|
||||
// do not touch the session store. Results are cached per sessionKey because
|
||||
// Native, ACP, and spawn-owned dashboard sessions can carry spawnedBy.
|
||||
// Short-circuit everyone else so high-volume chat streams do not touch the
|
||||
// session store. Results are cached per sessionKey because
|
||||
// spawnedBy is immutable once set and resolveSpawnedBy sits on the hot event
|
||||
// path (delta, flush, final, agent, seq-gap).
|
||||
const spawnedByCache = new Map<string, string | null>();
|
||||
@@ -458,9 +459,11 @@ export function createAgentEventHandler({
|
||||
if (spawnedByCache.has(sessionKey)) {
|
||||
return spawnedByCache.get(sessionKey)!;
|
||||
}
|
||||
// Non-lineage keys return null without polluting the cache; only
|
||||
// subagent/ACP results (positive or null) are worth memoising.
|
||||
if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey)) {
|
||||
// Non-lineage keys return null without polluting the cache; only eligible
|
||||
// child-session results (positive or null) are worth memoising.
|
||||
const isDashboardSession =
|
||||
parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true;
|
||||
if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey) && !isDashboardSession) {
|
||||
return null;
|
||||
}
|
||||
let result: string | null = null;
|
||||
|
||||
@@ -2,10 +2,19 @@ import type {
|
||||
SessionCreatedActor,
|
||||
SessionCreatedVia,
|
||||
} from "../../config/sessions/session-entry-provenance.js";
|
||||
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
|
||||
|
||||
export type TrustedSessionCreation = {
|
||||
via: SessionCreatedVia;
|
||||
actor?: SessionCreatedActor;
|
||||
/** Immutable completion recipient for a spawn-owned visible session. */
|
||||
completionOwnerSessionKey?: string;
|
||||
/** Effective caller tool-policy snapshot for an in-process visible spawn. */
|
||||
inheritedToolPolicy?: {
|
||||
version: 1;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -14,7 +23,11 @@ export type TrustedSessionCreation = {
|
||||
*/
|
||||
type SessionCreationClient = {
|
||||
authenticatedUserProfile?: { profileId?: string } | null;
|
||||
internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation };
|
||||
internal?: {
|
||||
syntheticClient?: true;
|
||||
sessionCreation?: TrustedSessionCreation;
|
||||
agentRuntimeIdentity?: AgentRuntimeIdentity;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveOperatorSessionCreation(
|
||||
@@ -24,6 +37,20 @@ export function resolveOperatorSessionCreation(
|
||||
if (options.allowTrustedHint && client?.internal?.sessionCreation) {
|
||||
return client.internal.sessionCreation;
|
||||
}
|
||||
const agentRuntimeIdentity = client?.internal?.agentRuntimeIdentity;
|
||||
if (options.allowTrustedHint && agentRuntimeIdentity?.sessionSpawnContext) {
|
||||
return {
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: agentRuntimeIdentity.sessionKey },
|
||||
...(agentRuntimeIdentity.sessionSpawnContext.completionOwnerSessionKey
|
||||
? {
|
||||
completionOwnerSessionKey:
|
||||
agentRuntimeIdentity.sessionSpawnContext.completionOwnerSessionKey,
|
||||
}
|
||||
: {}),
|
||||
inheritedToolPolicy: agentRuntimeIdentity.sessionSpawnContext.inheritedToolPolicy,
|
||||
};
|
||||
}
|
||||
const profileId = client?.authenticatedUserProfile?.profileId;
|
||||
// Actor only when proven: a profile-less wire connection may be an agent-tool
|
||||
// client on a remote topology, so claiming a human actor would misattribute
|
||||
|
||||
@@ -408,6 +408,23 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
let runMeta: Record<string, unknown> | undefined;
|
||||
let messageSeq: number | undefined;
|
||||
const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
const sessionCreation = resolveOperatorSessionCreation(client, { allowTrustedHint: true });
|
||||
const spawnActorSessionKey =
|
||||
sessionCreation.via === "spawn" && sessionCreation.actor?.type === "agent"
|
||||
? normalizeOptionalString(sessionCreation.actor.id)
|
||||
: undefined;
|
||||
if (
|
||||
sessionCreation.inheritedToolPolicy &&
|
||||
spawnActorSessionKey &&
|
||||
normalizeOptionalString(p.parentSessionKey) !== spawnActorSessionKey
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "spawn parent must match the trusted agent caller"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const allowExistingModelSelection = authorizeOperatorScopesForRequiredScope(
|
||||
ADMIN_SCOPE,
|
||||
clientScopes,
|
||||
@@ -454,6 +471,15 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
allowExistingModelSelection,
|
||||
parentSessionKey: p.parentSessionKey,
|
||||
spawnDepth: p.spawnDepth,
|
||||
spawnToolPolicy:
|
||||
sessionCreation.via === "spawn" && sessionCreation.inheritedToolPolicy
|
||||
? {
|
||||
...sessionCreation.inheritedToolPolicy,
|
||||
...(sessionCreation.completionOwnerSessionKey
|
||||
? { completionOwnerSessionKey: sessionCreation.completionOwnerSessionKey }
|
||||
: {}),
|
||||
}
|
||||
: undefined,
|
||||
spawnedCwd: sessionCwd,
|
||||
worktree: sessionWorktree
|
||||
? {
|
||||
@@ -472,7 +498,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
emitCommandHooks: p.emitCommandHooks,
|
||||
resetMainWhenUnspecified: !hasInitialTurn,
|
||||
commandSource: "webchat",
|
||||
creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }),
|
||||
creation: sessionCreation,
|
||||
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
|
||||
loadGatewayModelCatalog: () =>
|
||||
context.loadGatewayModelCatalog({ agentId: modelCatalogAgentId }),
|
||||
|
||||
@@ -1566,6 +1566,175 @@ test("sessions.create persists declared spawn lineage for spawn-owned creations"
|
||||
expect(created.payload?.entry?.spawnDepth).toBe(2);
|
||||
});
|
||||
|
||||
test("sessions.create atomically persists trusted visible-spawn tool policy", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const parentSessionKey = "agent:main:main";
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
[parentSessionKey]: sessionStoreEntry("sess-visible-spawn-parent"),
|
||||
},
|
||||
});
|
||||
|
||||
const created = await directSessionReq<{
|
||||
key?: string;
|
||||
entry?: {
|
||||
label?: string;
|
||||
spawnedBy?: string;
|
||||
completionOwnerSessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
spawnDepth?: number;
|
||||
inheritedToolPolicyVersion?: number;
|
||||
inheritedToolAllow?: string[];
|
||||
inheritedToolDeny?: string[];
|
||||
};
|
||||
}>(
|
||||
"sessions.create",
|
||||
{
|
||||
agentId: "main",
|
||||
label: "Restricted visible child",
|
||||
parentSessionKey,
|
||||
spawnDepth: 1,
|
||||
},
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
internal: {
|
||||
syntheticClient: true,
|
||||
sessionCreation: {
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: parentSessionKey },
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
inheritedToolPolicy: {
|
||||
version: 1,
|
||||
allow: ["read", "sessions_spawn"],
|
||||
deny: ["exec"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(created.ok, JSON.stringify(created.error)).toBe(true);
|
||||
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
|
||||
expect(created.payload?.entry).toMatchObject({
|
||||
label: "Restricted visible child",
|
||||
spawnedBy: parentSessionKey,
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
parentSessionKey,
|
||||
spawnDepth: 1,
|
||||
inheritedToolPolicyVersion: 1,
|
||||
inheritedToolAllow: ["read", "sessions_spawn"],
|
||||
inheritedToolDeny: ["exec"],
|
||||
});
|
||||
const key = requireNonEmptyString(created.payload?.key, "visible child key");
|
||||
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
|
||||
spawnedBy: parentSessionKey,
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
inheritedToolPolicyVersion: 1,
|
||||
inheritedToolAllow: ["read", "sessions_spawn"],
|
||||
inheritedToolDeny: ["exec"],
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.create accepts a signed agent-runtime visible-spawn policy", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const parentSessionKey = "agent:main:main";
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
[parentSessionKey]: sessionStoreEntry("sess-runtime-spawn-parent"),
|
||||
},
|
||||
});
|
||||
|
||||
const created = await directSessionReq<{
|
||||
key?: string;
|
||||
entry?: {
|
||||
createdVia?: string;
|
||||
createdActor?: unknown;
|
||||
spawnedBy?: string;
|
||||
completionOwnerSessionKey?: string;
|
||||
inheritedToolAllow?: string[];
|
||||
inheritedToolDeny?: string[];
|
||||
};
|
||||
}>(
|
||||
"sessions.create",
|
||||
{
|
||||
agentId: "main",
|
||||
label: "Runtime visible child",
|
||||
parentSessionKey,
|
||||
spawnDepth: 1,
|
||||
},
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
internal: {
|
||||
agentRuntimeIdentity: {
|
||||
kind: "agentRuntime",
|
||||
agentId: "main",
|
||||
sessionKey: parentSessionKey,
|
||||
sessionSpawnContext: {
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:bob",
|
||||
inheritedToolPolicy: {
|
||||
version: 1,
|
||||
allow: ["read", "sessions_spawn"],
|
||||
deny: ["exec"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(created.ok, JSON.stringify(created.error)).toBe(true);
|
||||
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
|
||||
expect(created.payload?.entry).toMatchObject({
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: parentSessionKey },
|
||||
spawnedBy: parentSessionKey,
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:bob",
|
||||
inheritedToolAllow: ["read", "sessions_spawn"],
|
||||
inheritedToolDeny: ["exec"],
|
||||
});
|
||||
const key = requireNonEmptyString(created.payload?.key, "runtime visible child key");
|
||||
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
|
||||
spawnedBy: parentSessionKey,
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:bob",
|
||||
inheritedToolPolicyVersion: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.create rejects a trusted spawn whose parent differs from its agent caller", async () => {
|
||||
await createSessionStoreDir();
|
||||
|
||||
const created = await directSessionReq(
|
||||
"sessions.create",
|
||||
{
|
||||
agentId: "main",
|
||||
parentSessionKey: "agent:main:other",
|
||||
spawnDepth: 1,
|
||||
},
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
internal: {
|
||||
agentRuntimeIdentity: {
|
||||
kind: "agentRuntime",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionSpawnContext: {
|
||||
inheritedToolPolicy: { version: 1, allow: ["read"], deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(created.ok).toBe(false);
|
||||
expect(created.error?.message).toContain("spawn parent must match the trusted agent caller");
|
||||
});
|
||||
|
||||
test("sessions.create rejects spawnDepth without parentSessionKey", async () => {
|
||||
await createSessionStoreDir();
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ const ownedChildMetadata = {
|
||||
groupChannel: "dev",
|
||||
space: "hq",
|
||||
spawnedBy: "agent:main:main",
|
||||
completionOwnerSessionKey: "agent:main:discord:direct:alice",
|
||||
inheritedToolPolicyVersion: 1,
|
||||
inheritedToolAllow: ["read", "message"],
|
||||
inheritedToolDeny: ["exec"],
|
||||
spawnedWorkspaceDir: "/tmp/child-workspace",
|
||||
spawnedCwd: "/tmp/task-repo",
|
||||
parentSessionKey: "agent:main:main",
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js";
|
||||
import {
|
||||
normalizeInheritedToolAllowlist,
|
||||
normalizeInheritedToolDenylist,
|
||||
} from "../agents/inherited-tool-deny.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.types.js";
|
||||
import {
|
||||
resolveDefaultModelForAgent,
|
||||
@@ -289,6 +293,13 @@ export async function createGatewaySession(params: {
|
||||
* operator sessions and forks stay spawn-capable roots.
|
||||
*/
|
||||
spawnDepth?: number;
|
||||
/** Trusted effective policy captured by an in-process visible spawn. */
|
||||
spawnToolPolicy?: {
|
||||
version: 1;
|
||||
completionOwnerSessionKey?: string;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
};
|
||||
spawnedCwd?: string;
|
||||
/** Managed worktree bound to the new session; persisted alongside spawnedCwd. */
|
||||
worktree?: { id: string; branch: string; repoRoot: string };
|
||||
@@ -495,6 +506,12 @@ export async function createGatewaySession(params: {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (params.spawnToolPolicy && params.spawnDepth === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "spawn tool policy requires spawnDepth"),
|
||||
};
|
||||
}
|
||||
let canonicalParentSessionKey: string | undefined;
|
||||
let parentSessionEntry: SessionEntry | undefined;
|
||||
let parentSelectedAgentId: string | undefined;
|
||||
@@ -705,6 +722,17 @@ export async function createGatewaySession(params: {
|
||||
|
||||
let createdContext: CreatedGatewaySession | undefined;
|
||||
let createdNewEntry = false;
|
||||
const spawnToolPolicy =
|
||||
params.spawnToolPolicy && canonicalParentSessionKey
|
||||
? {
|
||||
completionOwnerSessionKey: normalizeOptionalString(
|
||||
params.spawnToolPolicy.completionOwnerSessionKey,
|
||||
),
|
||||
allow: normalizeInheritedToolAllowlist(params.spawnToolPolicy.allow),
|
||||
deny: normalizeInheritedToolDenylist(params.spawnToolPolicy.deny),
|
||||
parentSessionKey: canonicalParentSessionKey,
|
||||
}
|
||||
: undefined;
|
||||
const createChildSession = async (): Promise<CreateGatewaySessionResult> => {
|
||||
let currentParentSessionEntry = parentSessionEntry;
|
||||
if (
|
||||
@@ -857,6 +885,15 @@ export async function createGatewaySession(params: {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (spawnToolPolicy && existingEntry !== undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"spawn tool policy requires a new session",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (
|
||||
params.visibility &&
|
||||
existingEntry === undefined &&
|
||||
@@ -1030,6 +1067,21 @@ export async function createGatewaySession(params: {
|
||||
// and plugin sessions) persists as a depth-0 root. Reused entries keep
|
||||
// their stored depth.
|
||||
...(existingEntry === undefined ? { spawnDepth: params.spawnDepth ?? 0 } : {}),
|
||||
...(existingEntry === undefined && spawnToolPolicy
|
||||
? {
|
||||
spawnedBy: spawnToolPolicy.parentSessionKey,
|
||||
...(spawnToolPolicy.completionOwnerSessionKey
|
||||
? { completionOwnerSessionKey: spawnToolPolicy.completionOwnerSessionKey }
|
||||
: {}),
|
||||
inheritedToolPolicyVersion: 1 as const,
|
||||
...(spawnToolPolicy.allow.length > 0
|
||||
? { inheritedToolAllow: spawnToolPolicy.allow }
|
||||
: {}),
|
||||
...(spawnToolPolicy.deny.length > 0
|
||||
? { inheritedToolDeny: spawnToolPolicy.deny }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(existingEntry === undefined && incognito ? { incognito: true as const } : {}),
|
||||
};
|
||||
sessionEntries[target.canonicalKey] = initializedEntry;
|
||||
|
||||
@@ -1445,6 +1445,10 @@ export async function performGatewaySessionReset(params: {
|
||||
queueCap: currentEntry?.queueCap,
|
||||
queueDrop: currentEntry?.queueDrop,
|
||||
spawnedBy: currentEntry?.spawnedBy,
|
||||
completionOwnerSessionKey: currentEntry?.completionOwnerSessionKey,
|
||||
inheritedToolPolicyVersion: currentEntry?.inheritedToolPolicyVersion,
|
||||
inheritedToolAllow: currentEntry?.inheritedToolAllow,
|
||||
inheritedToolDeny: currentEntry?.inheritedToolDeny,
|
||||
spawnedWorkspaceDir: currentEntry?.spawnedWorkspaceDir,
|
||||
spawnedCwd: params.clearSpawnedCwd
|
||||
? undefined
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"base": "codex-dynamic-tools.telegram-direct.json",
|
||||
"replace": {
|
||||
"sessions_spawn": {
|
||||
"description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work. Run result returns; session output stays thread.",
|
||||
"description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot; `mode=\"session\"` persistent/thread-bound only on supporting requester channel. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work. Run result returns; session output stays thread.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"agentId": {
|
||||
@@ -101,7 +101,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"visible": {
|
||||
"description": "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
|
||||
"description": "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"worktree": {
|
||||
|
||||
+2
-2
@@ -138,7 +138,7 @@
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent dashboard session; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherited tool allow/denylist blocks it at spawn with no config override; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.",
|
||||
"description": "Spawn clean child; default `runtime=\"subagent\"`. `mode=\"run\"` one-shot background. `agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require. `visible=true`: persistent sidebar dashboard session; use when the user asks to create/open a thread; subagent only; omit `mode` (no `mode=\"run\"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. Session listing/addressing obeys `tools.sessions.visibility` (tree: current session + own spawn subtree; reads also cover any watched same-agent group sessions). Inherits parent workspace. Native task arrives as first `[Subagent Task]`. Native transcript needed: `context=\"fork\"`; else omit/isolated. Use fresh child for sidecar/parallel batch reads, multi-step search, data collection; avoid quick lookup/single read unless policy prefers. After spawn, do non-overlap work while run result returns.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"agentId": {
|
||||
@@ -233,7 +233,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"visible": {
|
||||
"description": "Persistent UI session; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs; unavailable with inherited tool allow/denylist.",
|
||||
"description": "Persistent sidebar UI session; use when the user asks to create or open a thread; subagent only; omit mode/thread/thinking/lightContext/attachments/attachAs.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"worktree": {
|
||||
|
||||
Vendored
+4
-4
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 61456,
|
||||
"roughTokens": 15364
|
||||
"chars": 61490,
|
||||
"roughTokens": 15373
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 3811,
|
||||
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 7049
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 89652,
|
||||
"roughTokens": 22413
|
||||
"chars": 89686,
|
||||
"roughTokens": 22422
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1300,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 61148,
|
||||
"roughTokens": 15287
|
||||
"chars": 61182,
|
||||
"roughTokens": 15296
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2702,
|
||||
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6679
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 87864,
|
||||
"roughTokens": 21966
|
||||
"chars": 87898,
|
||||
"roughTokens": 21975
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 929,
|
||||
|
||||
Vendored
+4
-4
@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 62682,
|
||||
"roughTokens": 15671
|
||||
"chars": 62716,
|
||||
"roughTokens": 15679
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2702,
|
||||
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6783
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 89814,
|
||||
"roughTokens": 22454
|
||||
"chars": 89848,
|
||||
"roughTokens": 22462
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1271,
|
||||
|
||||
Reference in New Issue
Block a user