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

@@ -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;