@nebutra/agent-runtime 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -676
  2. package/dist/adapters/index.d.ts +24 -5
  3. package/dist/adapters/index.js +27 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/chunk-Q62VKHIT.js +178 -0
  6. package/dist/chunk-Q62VKHIT.js.map +1 -0
  7. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  8. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +14 -17
  11. package/dist/index.js.map +1 -1
  12. package/dist/orchestration.d.ts +42 -1
  13. package/dist/orchestration.js +7 -3
  14. package/dist/sandbox.js +1 -1
  15. package/package.json +80 -30
  16. package/.turbo/turbo-build.log +0 -130
  17. package/.turbo/turbo-test.log +0 -47
  18. package/.turbo/turbo-typecheck.log +0 -4
  19. package/CHANGELOG.md +0 -250
  20. package/dist/chunk-MX2WL43P.js +0 -90
  21. package/dist/chunk-MX2WL43P.js.map +0 -1
  22. package/examples/pulsar-quickstart.ts +0 -35
  23. package/examples/resume-branch.ts +0 -45
  24. package/examples/subagent-fanout.ts +0 -20
  25. package/src/adapters/dispatcher-sse.test.ts +0 -218
  26. package/src/adapters/dispatcher-sse.ts +0 -222
  27. package/src/adapters/index.ts +0 -18
  28. package/src/adapters/mcp-catalog.test.ts +0 -213
  29. package/src/adapters/mcp-catalog.ts +0 -188
  30. package/src/adapters/prisma-rollout.test.ts +0 -153
  31. package/src/adapters/prisma-rollout.ts +0 -104
  32. package/src/agent-runtime.test.ts +0 -176
  33. package/src/artifact-stream.test.ts +0 -330
  34. package/src/artifact-stream.ts +0 -453
  35. package/src/channel-gateway.test.ts +0 -432
  36. package/src/channel-gateway.ts +0 -357
  37. package/src/cli.ts +0 -49
  38. package/src/code-review.test.ts +0 -501
  39. package/src/code-review.ts +0 -495
  40. package/src/command-suggestions.test.ts +0 -251
  41. package/src/command-suggestions.ts +0 -338
  42. package/src/commands.test.ts +0 -184
  43. package/src/commands.ts +0 -140
  44. package/src/commit-message.test.ts +0 -249
  45. package/src/commit-message.ts +0 -180
  46. package/src/context-compaction.test.ts +0 -522
  47. package/src/context-compaction.ts +0 -438
  48. package/src/definitions.test.ts +0 -78
  49. package/src/definitions.ts +0 -190
  50. package/src/deployment-status.test.ts +0 -215
  51. package/src/deployment-status.ts +0 -227
  52. package/src/design-context.test.ts +0 -195
  53. package/src/design-context.ts +0 -198
  54. package/src/dispatcher.test.ts +0 -234
  55. package/src/dispatcher.ts +0 -189
  56. package/src/durable-turn.test.ts +0 -209
  57. package/src/durable-turn.ts +0 -135
  58. package/src/edit-planner.test.ts +0 -204
  59. package/src/edit-planner.ts +0 -325
  60. package/src/fuzzy-match.test.ts +0 -311
  61. package/src/fuzzy-match.ts +0 -444
  62. package/src/hook-pipeline.test.ts +0 -279
  63. package/src/hook-pipeline.ts +0 -373
  64. package/src/inbound-admission.test.ts +0 -394
  65. package/src/inbound-admission.ts +0 -246
  66. package/src/index.ts +0 -49
  67. package/src/loop.test.ts +0 -161
  68. package/src/loop.ts +0 -211
  69. package/src/mcp-bridge.test.ts +0 -165
  70. package/src/mcp-bridge.ts +0 -81
  71. package/src/memory-provider.test.ts +0 -232
  72. package/src/memory-provider.ts +0 -257
  73. package/src/model.ts +0 -168
  74. package/src/orchestration.test.ts +0 -53
  75. package/src/orchestration.ts +0 -146
  76. package/src/permission-ruleset.test.ts +0 -301
  77. package/src/permission-ruleset.ts +0 -1
  78. package/src/policy.ts +0 -151
  79. package/src/project-repo.test.ts +0 -232
  80. package/src/project-repo.ts +0 -311
  81. package/src/protocol.ts +0 -159
  82. package/src/pulsar.test.ts +0 -156
  83. package/src/pulsar.ts +0 -322
  84. package/src/rollout-store-persistent.test.ts +0 -217
  85. package/src/rollout-store-persistent.ts +0 -166
  86. package/src/rollout.ts +0 -150
  87. package/src/sandbox.ts +0 -113
  88. package/src/session-share.test.ts +0 -360
  89. package/src/session-share.ts +0 -310
  90. package/src/skill-distillation.test.ts +0 -177
  91. package/src/skill-distillation.ts +0 -369
  92. package/src/skills.test.ts +0 -277
  93. package/src/skills.ts +0 -277
  94. package/src/subagents.test.ts +0 -290
  95. package/src/subagents.ts +0 -332
  96. package/src/tools.test.ts +0 -12
  97. package/src/tools.ts +0 -129
  98. package/src/workbench.test.ts +0 -0
  99. package/src/workbench.ts +0 -0
  100. package/tsconfig.json +0 -12
  101. package/tsup.config.ts +0 -36
@@ -1,188 +0,0 @@
1
- /**
2
- * MCP catalog adapter — concrete wiring of `@nebutra/mcp` into
3
- * `@nebutra/agent-runtime`'s injectable ports.
4
- *
5
- * `@nebutra/agent-runtime` is intentionally dependency-free: it declares the
6
- * `McpServerCatalogPort` / `McpClientLike` ports and an `activateMcpTools`
7
- * composer, but never imports `@nebutra/mcp`. This module supplies the only
8
- * concrete bridge so apps/backends reuse it instead of re-implementing glue.
9
- *
10
- * Invariants:
11
- * - tenantId is mandatory and validated *before* any registry/client call
12
- * (fail-closed): an empty/blank tenant can never enumerate or execute.
13
- * - Plan/tenant gating is delegated to `MCPServerRegistry` — a tenant only
14
- * ever sees the tools its plan/tenant policy permits.
15
- * - MCP `parameters` are translated into a Zod `inputSchema`; a tool with no
16
- * usable schema still yields a permissive, never-crashing definition.
17
- * - The MCP `ToolExecutionResult` envelope is unwrapped: a non-success
18
- * result throws (fail-closed) rather than leaking a falsy value upstream.
19
- *
20
- * Both factories accept an injected dependency (registry / client) and default
21
- * to the `@nebutra/mcp` singletons, keeping the module free of hidden globals
22
- * and fully unit-testable.
23
- */
24
-
25
- import type { MCPContext, MCPServerRegistry, ToolExecutionResult } from "@nebutra/mcp";
26
- import { mcpClient, serverRegistry } from "@nebutra/mcp";
27
- import { z } from "zod";
28
- import type { McpServerCatalogPort } from "../mcp-bridge";
29
- import type { McpClientLike, ToolDefinition } from "../tools";
30
-
31
- /** Narrow structural shapes — we depend on behaviour, not concrete classes. */
32
-
33
- /** The slice of `MCPServerRegistry` the catalog port needs. */
34
- export type McpRegistryLike = Pick<MCPServerRegistry, "getAccessibleTools">;
35
-
36
- /** The slice of `mcpClient` the client port needs. */
37
- export interface McpExecutorLike {
38
- executeTool(
39
- name: string,
40
- args: Record<string, unknown>,
41
- ctx: MCPContext,
42
- ): Promise<ToolExecutionResult>;
43
- }
44
-
45
- type McpParameter = {
46
- type?: "string" | "number" | "boolean" | "object" | "array";
47
- description?: string;
48
- required?: boolean;
49
- default?: unknown;
50
- };
51
-
52
- type McpToolShape = {
53
- name: string;
54
- description?: string;
55
- parameters?: Record<string, McpParameter> | undefined;
56
- serverId: string;
57
- };
58
-
59
- /** Fail-closed tenant guard — the single chokepoint for both ports. */
60
- const tenantSchema = z
61
- .string({ message: "tenantId is required (fail-closed)" })
62
- .trim()
63
- .min(1, "tenantId is required (fail-closed)");
64
-
65
- const catalogCtxSchema = z.object({
66
- tenantId: tenantSchema,
67
- plan: z.string().min(1).optional(),
68
- });
69
-
70
- const clientCtxSchema = z.object({
71
- requestId: z.string().min(1, "requestId is required"),
72
- tenantId: tenantSchema,
73
- });
74
-
75
- /** Map one MCP parameter to its Zod leaf type. Unknown → permissive. */
76
- function paramToZod(param: McpParameter): z.ZodTypeAny {
77
- switch (param.type) {
78
- case "string":
79
- return z.string();
80
- case "number":
81
- return z.number();
82
- case "boolean":
83
- return z.boolean();
84
- case "array":
85
- return z.array(z.unknown());
86
- case "object":
87
- return z.record(z.string(), z.unknown());
88
- default:
89
- return z.unknown();
90
- }
91
- }
92
-
93
- /**
94
- * Build a Zod `inputSchema` from an MCP tool's `parameters`. A tool with no
95
- * declared parameters becomes a permissive passthrough object (never throws on
96
- * unknown keys) so schema-less MCP tools remain dispatchable.
97
- */
98
- function toInputSchema(parameters: Record<string, McpParameter> | undefined): z.ZodType {
99
- const entries = parameters ? Object.entries(parameters) : [];
100
- if (entries.length === 0) {
101
- return z.record(z.string(), z.unknown());
102
- }
103
-
104
- const shape: Record<string, z.ZodTypeAny> = {};
105
- for (const [key, param] of entries) {
106
- let leaf = paramToZod(param);
107
- if (param.required !== true) {
108
- leaf = leaf.optional();
109
- }
110
- shape[key] = leaf;
111
- }
112
- // `passthrough` keeps unknown keys: MCP schemas are advisory, not strict.
113
- return z.object(shape).passthrough();
114
- }
115
-
116
- /** Adapt one registry tool entry into an agent-runtime `ToolDefinition`. */
117
- function toToolDefinition(tool: McpToolShape): ToolDefinition {
118
- return {
119
- name: tool.name,
120
- description: tool.description ?? tool.name,
121
- inputSchema: toInputSchema(tool.parameters),
122
- };
123
- }
124
-
125
- /**
126
- * Adapt `MCPServerRegistry`'s plan/tenant-gated tool listing into the
127
- * agent-runtime `McpServerCatalogPort`.
128
- *
129
- * @param registry Defaults to `@nebutra/mcp`'s `serverRegistry` singleton;
130
- * inject a fresh `MCPServerRegistry` for tests/multi-tenant
131
- * isolation.
132
- */
133
- export function createMcpServerCatalog(
134
- registry: McpRegistryLike = serverRegistry,
135
- ): McpServerCatalogPort {
136
- return {
137
- async listTools(ctx) {
138
- const scope = catalogCtxSchema.parse(ctx); // fail-closed first
139
-
140
- const mcpCtx: MCPContext = {
141
- requestId: "catalog-list",
142
- tenantId: scope.tenantId,
143
- ...(scope.plan === undefined ? {} : { plan: scope.plan }),
144
- };
145
-
146
- const tools = registry.getAccessibleTools(mcpCtx) as McpToolShape[];
147
-
148
- return tools.map((tool) => ({
149
- server: tool.serverId,
150
- definition: toToolDefinition(tool),
151
- }));
152
- },
153
- };
154
- }
155
-
156
- /**
157
- * Adapt `@nebutra/mcp`'s `mcpClient` into agent-runtime's `McpClientLike`.
158
- *
159
- * Maps `{ requestId, tenantId }` → the richer `MCPContext`, and unwraps the
160
- * `ToolExecutionResult` envelope: a non-success result throws so a failed MCP
161
- * call never silently surfaces as a falsy tool output.
162
- *
163
- * @param client Defaults to `@nebutra/mcp`'s `mcpClient` singleton;
164
- * inject a fake for tests.
165
- */
166
- export function createMcpClientPort(client: McpExecutorLike = mcpClient): McpClientLike {
167
- return {
168
- async executeTool(name, args, ctx) {
169
- const scope = clientCtxSchema.parse(ctx); // fail-closed first
170
-
171
- const mcpCtx: MCPContext = {
172
- requestId: scope.requestId,
173
- tenantId: scope.tenantId,
174
- };
175
-
176
- const result = await client.executeTool(
177
- name,
178
- (args ?? {}) as Record<string, unknown>,
179
- mcpCtx,
180
- );
181
-
182
- if (!result.success) {
183
- throw new Error(`MCP tool "${name}" failed: ${result.error ?? "unknown error"}`);
184
- }
185
- return result.result;
186
- },
187
- };
188
- }
@@ -1,153 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { type ApprovalGate, type ModelInvoker, runTurn } from "../loop";
3
- import { type RolloutLine, replay } from "../rollout";
4
- import { PersistentRolloutStore } from "../rollout-store-persistent";
5
- import { ToolRegistry } from "../tools";
6
- import { createPrismaRolloutPersistence, type PrismaRolloutDelegate } from "./prisma-rollout.js";
7
-
8
- /** In-memory fake mirroring the agent_rollout_lines table semantics. */
9
- function fakeDelegate() {
10
- const rows: { tenantId: string; threadId: string; seq: number; at: Date; payload: string }[] = [];
11
- const delegate: PrismaRolloutDelegate = {
12
- async create({ data }) {
13
- if (
14
- rows.some(
15
- (r) => r.tenantId === data.tenantId && r.threadId === data.threadId && r.seq === data.seq,
16
- )
17
- ) {
18
- // Mirrors the unique (tenant,thread,seq) constraint — fail loud.
19
- throw new Error("unique constraint violation");
20
- }
21
- rows.push({ ...data });
22
- return data;
23
- },
24
- async findMany({ where }) {
25
- return rows
26
- .filter((r) => r.tenantId === where.tenantId && r.threadId === where.threadId)
27
- .sort((a, b) => a.seq - b.seq)
28
- .map((r) => ({ seq: r.seq, payload: r.payload }));
29
- },
30
- };
31
- return { delegate, rows };
32
- }
33
-
34
- const line = (tenantId: string, threadId: string): RolloutLine => ({
35
- tenantId,
36
- threadId,
37
- type: "session_meta",
38
- config: {
39
- model: "m",
40
- provider: "p",
41
- approvalPolicy: "on_request",
42
- capabilityPolicy: "external_sandbox",
43
- },
44
- at: new Date().toISOString(),
45
- });
46
-
47
- describe("createPrismaRolloutPersistence", () => {
48
- it("round-trips through PersistentRolloutStore and replays faithfully", async () => {
49
- const { delegate } = fakeDelegate();
50
- const store = new PersistentRolloutStore(createPrismaRolloutPersistence(delegate));
51
- await store.append(line("org_a", "th_1"));
52
- await store.append({
53
- tenantId: "org_a",
54
- threadId: "th_1",
55
- type: "event",
56
- at: new Date().toISOString(),
57
- event: { type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } },
58
- });
59
- const proj = replay(await store.read("org_a", "th_1"));
60
- expect(proj?.items.map((i) => i.id)).toEqual(["i1"]);
61
- });
62
-
63
- it("isolates tenants — B cannot read A's thread", async () => {
64
- const { delegate } = fakeDelegate();
65
- const store = new PersistentRolloutStore(createPrismaRolloutPersistence(delegate));
66
- await store.append(line("org_a", "th_1"));
67
- expect(await store.read("org_b", "th_1")).toHaveLength(0);
68
- });
69
-
70
- it("fails loud on a persistence rejection (never silently drops)", async () => {
71
- const failing: PrismaRolloutDelegate = {
72
- async create() {
73
- throw new Error("db down");
74
- },
75
- async findMany() {
76
- return [];
77
- },
78
- };
79
- const port = createPrismaRolloutPersistence(failing);
80
- await expect(
81
- port.put({
82
- tenantId: "t",
83
- threadId: "th",
84
- seq: 0,
85
- at: new Date().toISOString(),
86
- payload: "{}",
87
- }),
88
- ).rejects.toThrow("db down");
89
- });
90
-
91
- it("fails closed on empty tenant / bad timestamp", async () => {
92
- const { delegate } = fakeDelegate();
93
- const port = createPrismaRolloutPersistence(delegate);
94
- await expect(
95
- port.put({ tenantId: "", threadId: "th", seq: 0, at: "x", payload: "{}" }),
96
- ).rejects.toBeTruthy();
97
- await expect(
98
- port.put({ tenantId: "t", threadId: "th", seq: 0, at: "not-a-date", payload: "{}" }),
99
- ).rejects.toThrow(/invalid timestamp/);
100
- });
101
-
102
- it("resolves a per-tenant delegate (RLS client per call)", async () => {
103
- const a = fakeDelegate();
104
- const b = fakeDelegate();
105
- const port = createPrismaRolloutPersistence((tenantId) =>
106
- tenantId === "org_a" ? a.delegate : b.delegate,
107
- );
108
- await port.put({
109
- tenantId: "org_a",
110
- threadId: "th",
111
- seq: 0,
112
- at: new Date().toISOString(),
113
- payload: "{}",
114
- });
115
- expect(a.rows).toHaveLength(1);
116
- expect(b.rows).toHaveLength(0);
117
- });
118
-
119
- it("drives a full turn whose rollout survives in the durable backend", async () => {
120
- const { delegate, rows } = fakeDelegate();
121
- const store = new PersistentRolloutStore(createPrismaRolloutPersistence(delegate));
122
- const model: ModelInvoker = {
123
- async invoke() {
124
- return { emissions: [{ kind: "text", text: "done" }] };
125
- },
126
- };
127
- const gate: ApprovalGate = {
128
- async request() {
129
- return { kind: "denied" };
130
- },
131
- };
132
- const events = [];
133
- for await (const e of runTurn("hi", {
134
- tenantId: "org_a",
135
- threadId: "th_x",
136
- config: {
137
- model: "m",
138
- provider: "p",
139
- approvalPolicy: "on_request",
140
- capabilityPolicy: "external_sandbox",
141
- },
142
- approvalPolicy: { kind: "on_request" },
143
- model,
144
- tools: new ToolRegistry(),
145
- store,
146
- approvalGate: gate,
147
- })) {
148
- events.push(e);
149
- }
150
- expect(rows.length).toBe(events.length);
151
- expect(rows.every((r) => r.tenantId === "org_a")).toBe(true);
152
- });
153
- });
@@ -1,104 +0,0 @@
1
- /**
2
- * Durable rollout-store backend: `RolloutPersistencePort` over Prisma.
3
- *
4
- * This is the fail-loud system-of-record adapter (table `agent_rollout_lines`,
5
- * migration `20260519000000_add_agent_rollout_store`). Unlike a best-effort
6
- * audit log it never swallows a failed write — a rejected `put` propagates so
7
- * the caller's "never silently drop" contract holds.
8
- *
9
- * Reusability: it depends only on a minimal injected delegate (the
10
- * `PrismaAuditDelegate` pattern), NOT on the generated Prisma client or
11
- * `@nebutra/db`. Any tenant-scoped Prisma client (`getTenantDb(orgId)`)
12
- * structurally satisfies it, so this package stays dependency-light.
13
- */
14
-
15
- import { z } from "zod";
16
- import type { RolloutPersistencePort } from "../rollout-store-persistent";
17
-
18
- /**
19
- * Minimal structural slice of `prisma.agentRolloutLine`. Decoupled from the
20
- * generated client on purpose — only these two operations are needed.
21
- */
22
- export interface PrismaRolloutDelegate {
23
- create(args: {
24
- data: {
25
- tenantId: string;
26
- threadId: string;
27
- seq: number;
28
- at: Date;
29
- payload: string;
30
- };
31
- }): Promise<unknown>;
32
- findMany(args: {
33
- where: { tenantId: string; threadId: string };
34
- orderBy: { seq: "asc" };
35
- select: { seq: true; payload: true };
36
- }): Promise<{ seq: number; payload: string }[]>;
37
- }
38
-
39
- /** Resolves the delegate per call so a request-scoped (RLS) client can be used. */
40
- export type RolloutDelegateResolver =
41
- | PrismaRolloutDelegate
42
- | ((tenantId: string) => PrismaRolloutDelegate | Promise<PrismaRolloutDelegate>);
43
-
44
- const recordSchema = z.object({
45
- tenantId: z.string().min(1, "tenantId is required"),
46
- threadId: z.string().min(1, "threadId is required"),
47
- seq: z.number().int().nonnegative(),
48
- at: z.string().min(1),
49
- payload: z.string(),
50
- });
51
-
52
- const querySchema = z.object({
53
- tenantId: z.string().min(1, "tenantId is required"),
54
- threadId: z.string().min(1, "threadId is required"),
55
- });
56
-
57
- async function resolve(
58
- resolver: RolloutDelegateResolver,
59
- tenantId: string,
60
- ): Promise<PrismaRolloutDelegate> {
61
- return typeof resolver === "function" ? resolver(tenantId) : resolver;
62
- }
63
-
64
- /**
65
- * Build a {@link RolloutPersistencePort} backed by Prisma.
66
- *
67
- * @param delegate a `PrismaRolloutDelegate`, or a resolver mapping a tenantId
68
- * to one (use the latter to obtain a per-tenant RLS client per call).
69
- */
70
- export function createPrismaRolloutPersistence(
71
- delegate: RolloutDelegateResolver,
72
- ): RolloutPersistencePort {
73
- return {
74
- async put(record): Promise<void> {
75
- const r = recordSchema.parse(record);
76
- const at = new Date(r.at);
77
- if (Number.isNaN(at.getTime())) {
78
- throw new Error(`invalid timestamp: ${r.at}`);
79
- }
80
- const db = await resolve(delegate, r.tenantId);
81
- // Fail-loud: any persistence rejection propagates to the caller.
82
- await db.create({
83
- data: {
84
- tenantId: r.tenantId,
85
- threadId: r.threadId,
86
- seq: r.seq,
87
- at,
88
- payload: r.payload,
89
- },
90
- });
91
- },
92
-
93
- async list(tenantId, threadId): Promise<{ seq: number; payload: string }[]> {
94
- const q = querySchema.parse({ tenantId, threadId });
95
- const db = await resolve(delegate, q.tenantId);
96
- const rows = await db.findMany({
97
- where: { tenantId: q.tenantId, threadId: q.threadId },
98
- orderBy: { seq: "asc" },
99
- select: { seq: true, payload: true },
100
- });
101
- return rows.map((row) => ({ seq: row.seq, payload: row.payload }));
102
- },
103
- };
104
- }
@@ -1,176 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { z } from "zod";
3
- import { isTurnTerminal, mergeTurnConfig, type TurnConfig } from "./model";
4
- import { DEFAULT_CAPABILITY_POLICY, DENIED, isApproval, resolveRuleDecision } from "./policy";
5
- import { METHOD_REGISTRY, resolveScope, scopeKey } from "./protocol";
6
- import {
7
- InMemoryRolloutStore,
8
- PERSISTED_OUTPUT_MAX_BYTES,
9
- replay,
10
- sanitizeForPersist,
11
- } from "./rollout";
12
- import {
13
- assertSafePosture,
14
- createHttpSandbox,
15
- NoExecutorConfiguredError,
16
- REFUSING_SANDBOX,
17
- SandboxDelegationError,
18
- } from "./sandbox";
19
- import { ToolRegistry } from "./tools";
20
-
21
- const baseConfig: TurnConfig = {
22
- model: "m",
23
- provider: "p",
24
- approvalPolicy: "on_request",
25
- capabilityPolicy: "external_sandbox",
26
- };
27
-
28
- describe("model", () => {
29
- it("merges overrides immutably", () => {
30
- const merged = mergeTurnConfig(baseConfig, { model: "x" });
31
- expect(merged.model).toBe("x");
32
- expect(baseConfig.model).toBe("m");
33
- });
34
- it("detects terminal turn events", () => {
35
- expect(
36
- isTurnTerminal({
37
- type: "turn.completed",
38
- usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
39
- }),
40
- ).toBe(true);
41
- expect(isTurnTerminal({ type: "turn.started" })).toBe(false);
42
- });
43
- });
44
-
45
- describe("policy", () => {
46
- it("defaults to external_sandbox posture", () => {
47
- expect(DEFAULT_CAPABILITY_POLICY.kind).toBe("external_sandbox");
48
- });
49
- it("forbidden rule auto-rejects regardless of policy", () => {
50
- expect(resolveRuleDecision("forbidden", { kind: "on_request" })).toBe("auto_reject");
51
- });
52
- it("prompt rule under never policy auto-rejects", () => {
53
- expect(resolveRuleDecision("prompt", { kind: "never" })).toBe("auto_reject");
54
- });
55
- it("prompt rule under granular respects the rules gate", () => {
56
- expect(
57
- resolveRuleDecision("prompt", {
58
- kind: "granular",
59
- config: {
60
- sandboxApproval: true,
61
- rules: false,
62
- skillApproval: false,
63
- requestPermissions: false,
64
- mcpElicitations: true,
65
- },
66
- }),
67
- ).toBe("auto_reject");
68
- });
69
- it("denied is not an approval", () => {
70
- expect(isApproval(DENIED)).toBe(false);
71
- expect(isApproval({ kind: "approved_for_session" })).toBe(true);
72
- });
73
- });
74
-
75
- describe("protocol", () => {
76
- it("scope key is tenant-prefixed and thread-isolated", () => {
77
- const a = resolveScope(METHOD_REGISTRY.turnStart, "t1", { threadId: "th1" });
78
- const b = resolveScope(METHOD_REGISTRY.turnStart, "t2", { threadId: "th1" });
79
- expect(scopeKey(a)).not.toBe(scopeKey(b)); // cross-tenant never shares a lane
80
- expect(scopeKey(a)).toContain("t:t1");
81
- });
82
- it("thread-scoped method without threadId fails closed", () => {
83
- expect(() => resolveScope(METHOD_REGISTRY.turnStart, "t1", {})).toThrow();
84
- });
85
- });
86
-
87
- describe("rollout", () => {
88
- it("caps oversized command output", () => {
89
- const big = "x".repeat(PERSISTED_OUTPUT_MAX_BYTES + 100);
90
- const out = sanitizeForPersist({
91
- id: "1",
92
- type: "command_execution",
93
- command: "c",
94
- aggregatedOutput: big,
95
- status: "completed",
96
- });
97
- expect((out as { aggregatedOutput: string }).aggregatedOutput.length).toBeLessThan(big.length);
98
- });
99
- it("replays an append-only log into derived state, bounded by compaction", async () => {
100
- const store = new InMemoryRolloutStore();
101
- const at = new Date().toISOString();
102
- await store.append({
103
- tenantId: "t",
104
- threadId: "th",
105
- type: "session_meta",
106
- config: baseConfig,
107
- at,
108
- });
109
- await store.append({
110
- tenantId: "t",
111
- threadId: "th",
112
- type: "event",
113
- at,
114
- event: { type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } },
115
- });
116
- await store.append({
117
- tenantId: "t",
118
- threadId: "th",
119
- type: "compacted",
120
- summary: "summary",
121
- droppedThrough: "i1",
122
- at,
123
- });
124
- const proj = replay(await store.read("t", "th"));
125
- expect(proj?.compactionSummary).toBe("summary");
126
- expect(proj?.items).toHaveLength(0);
127
- });
128
- });
129
-
130
- describe("sandbox", () => {
131
- it("refuses execution by default (no in-process untrusted exec)", async () => {
132
- await expect(
133
- REFUSING_SANDBOX.exec({
134
- tenantId: "t",
135
- threadId: "th",
136
- command: "rm -rf /",
137
- capabilityPolicy: DEFAULT_CAPABILITY_POLICY,
138
- }),
139
- ).rejects.toBeInstanceOf(NoExecutorConfiguredError);
140
- });
141
- it("refuses danger_full_access without explicit opt-in", () => {
142
- expect(() => assertSafePosture({ kind: "danger_full_access" })).toThrow();
143
- expect(() => assertSafePosture({ kind: "danger_full_access" }, true)).not.toThrow();
144
- });
145
- it("surfaces a fail-closed isolator refusal as an error, never a fake result", async () => {
146
- const fakeFetch = (async () =>
147
- new Response(JSON.stringify({ error: "execution_refused" }), {
148
- status: 403,
149
- })) as unknown as typeof fetch;
150
- const sandbox = createHttpSandbox("http://isolator:8020", fakeFetch);
151
- await expect(
152
- sandbox.exec({
153
- tenantId: "org_a",
154
- threadId: "th_1",
155
- command: "rm -rf /",
156
- capabilityPolicy: DEFAULT_CAPABILITY_POLICY,
157
- }),
158
- ).rejects.toBeInstanceOf(SandboxDelegationError);
159
- });
160
- });
161
-
162
- describe("tools", () => {
163
- it("validates input and dispatches with tenant context", async () => {
164
- const reg = new ToolRegistry();
165
- reg.register(
166
- { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
167
- async (input: { v: string }, ctx) => `${ctx.tenantId}:${input.v}`,
168
- );
169
- expect(await reg.dispatch("echo", { v: "hi" }, { tenantId: "t1", threadId: "th" })).toBe(
170
- "t1:hi",
171
- );
172
- await expect(
173
- reg.dispatch("echo", { v: 1 }, { tenantId: "t1", threadId: "th" }),
174
- ).rejects.toBeTruthy();
175
- });
176
- });