create-windy 0.8.1 → 0.8.3

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 (95) hide show
  1. package/dist/cli.js +52 -34
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
  5. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
  6. package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +16 -5
  8. package/template/apps/agent-server/tests/test_structured_logging.py +37 -0
  9. package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
  10. package/template/apps/server/src/agent/admin-catalog-bootstrap.ts +25 -0
  11. package/template/apps/server/src/agent/agent-session-bootstrap.ts +7 -8
  12. package/template/apps/server/src/agent/agent-session-facade.ts +12 -0
  13. package/template/apps/server/src/agent/agent-session-validation.ts +43 -0
  14. package/template/apps/server/src/agent/operation-catalog.test.ts +202 -0
  15. package/template/apps/server/src/agent/operation-catalog.ts +135 -0
  16. package/template/apps/server/src/agent/run-browser-visibility.test.ts +59 -0
  17. package/template/apps/server/src/agent/run-browser-visibility.ts +11 -0
  18. package/template/apps/server/src/agent/run-operation-policy.ts +1 -0
  19. package/template/apps/server/src/agent/run-route-response.ts +35 -0
  20. package/template/apps/server/src/agent/run-routes.ts +46 -43
  21. package/template/apps/server/src/agent/runtime-bootstrap.ts +21 -20
  22. package/template/apps/server/src/agent/runtime-disabled-composition.ts +28 -0
  23. package/template/apps/server/src/agent/session-projection-facade.ts +8 -9
  24. package/template/apps/server/src/agent/session-projection-governance.ts +10 -0
  25. package/template/apps/server/src/agent/session-projection-view.ts +19 -16
  26. package/template/apps/server/src/agent/session-recording-reader.contract.test.ts +131 -0
  27. package/template/apps/server/src/agent/session-recording-reader.ts +62 -0
  28. package/template/apps/server/src/agent/tool-catalog-audit.ts +22 -0
  29. package/template/apps/server/src/agent/tool-catalog-availability.ts +21 -0
  30. package/template/apps/server/src/agent/tool-catalog-routes.test.ts +103 -0
  31. package/template/apps/server/src/agent/tool-catalog-routes.ts +62 -0
  32. package/template/apps/server/src/agent/tool-catalog.test.ts +122 -0
  33. package/template/apps/server/src/agent/tool-catalog.ts +44 -0
  34. package/template/apps/server/src/agent/tool-registry.ts +45 -14
  35. package/template/apps/server/src/agent/workbench-bootstrap.ts +39 -0
  36. package/template/apps/server/src/agent/workbench-event-projection.ts +115 -0
  37. package/template/apps/server/src/agent/workbench-facade.contract.test.ts +189 -0
  38. package/template/apps/server/src/agent/workbench-facade.ts +339 -0
  39. package/template/apps/server/src/agent/workbench-recording.contract.test.ts +328 -0
  40. package/template/apps/server/src/agent/workbench-routes.ts +97 -0
  41. package/template/apps/server/src/agent/workbench-run-integration.test.ts +231 -0
  42. package/template/apps/server/src/agent/workbench-run-reader.ts +187 -0
  43. package/template/apps/server/src/agent/workbench-safe-result.ts +61 -0
  44. package/template/apps/server/src/agent/workbench-sse.ts +50 -0
  45. package/template/apps/server/src/agent-workflow/query-service.ts +36 -0
  46. package/template/apps/server/src/agent-workflow/recording-projection.contract.test.ts +196 -0
  47. package/template/apps/server/src/agent-workflow/recording-projection.ts +86 -0
  48. package/template/apps/web/src/pages/system/AgentDebugPage.vue +14 -0
  49. package/template/apps/web/src/pages/system/AgentSessionPage.vue +29 -8
  50. package/template/apps/web/src/pages/system/AgentToolsPage.vue +76 -0
  51. package/template/apps/web/src/pages/system/components/agent-workbench/AgentWorkbench.vue +236 -0
  52. package/template/apps/web/src/pages/system/components/agent-workbench/DraftLeaveDialog.vue +34 -0
  53. package/template/apps/web/src/pages/system/components/agent-workbench/ReplayEventDetail.vue +175 -0
  54. package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.vue +142 -0
  55. package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.webtest.ts +242 -0
  56. package/template/apps/web/src/pages/system/components/agent-workbench/TestRunComposer.vue +97 -0
  57. package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.vue +100 -0
  58. package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.webtest.ts +55 -0
  59. package/template/apps/web/src/pages/system/components/agent-workbench/ToolRuleDetail.vue +81 -0
  60. package/template/apps/web/src/pages/system/components/agent-workbench/WorkbenchSessionList.vue +57 -0
  61. package/template/apps/web/src/pages/system/components/agent-workbench/catalog-presenter.ts +20 -0
  62. package/template/apps/web/src/pages/system/components/agent-workbench/replay-presenter.ts +94 -0
  63. package/template/apps/web/src/pages/system/composables/useAgentWorkbench.ts +298 -0
  64. package/template/apps/web/src/pages/system/composables/useAgentWorkbench.webtest.ts +164 -0
  65. package/template/apps/web/src/pages/system/composables/useSessionReplay.ts +48 -0
  66. package/template/apps/web/src/router/index.ts +4 -0
  67. package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
  68. package/template/apps/web/src/services/agent-admin-api.ts +155 -0
  69. package/template/apps/web/src/services/agent-admin-api.webtest.ts +120 -0
  70. package/template/apps/web/src/services/agent-workbench-event.ts +60 -0
  71. package/template/docs/architecture/agent-harness.md +4 -0
  72. package/template/docs/architecture/log-governance-implementation.md +20 -3
  73. package/template/docs/architecture/log-governance-plan.md +2 -2
  74. package/template/docs/platform/agent-runtime.md +18 -0
  75. package/template/packages/modules/src/ai-operation-composition.ts +9 -0
  76. package/template/packages/modules/src/ai-operation-visibility.test.ts +33 -0
  77. package/template/packages/modules/src/manifest.ts +2 -0
  78. package/template/packages/modules/src/system-admin-routes.ts +16 -0
  79. package/template/packages/modules/src/system-admin-ui-catalog.ts +7 -1
  80. package/template/packages/modules/src/system-agent-admin-bindings.ts +42 -0
  81. package/template/packages/modules/src/system-agent-admin-menus.ts +26 -0
  82. package/template/packages/modules/src/system-agent-admin-registration.test.ts +52 -0
  83. package/template/packages/modules/src/system-agent-audit-actions.ts +5 -0
  84. package/template/packages/modules/src/system-ai-api-bindings.ts +2 -0
  85. package/template/packages/modules/src/system-audit-actions.ts +25 -0
  86. package/template/packages/modules/src/system-features.ts +19 -0
  87. package/template/packages/modules/src/system-module-catalog.ts +14 -12
  88. package/template/packages/modules/src/system-module-ownership.ts +12 -0
  89. package/template/packages/modules/src/system-modules.ts +2 -0
  90. package/template/packages/modules/src/system-permissions.ts +5 -0
  91. package/template/packages/server-sdk/index.ts +2 -0
  92. package/template/packages/server-sdk/src/agent-session-provider.ts +19 -0
  93. package/template/packages/shared/index.ts +2 -0
  94. package/template/packages/shared/src/agent-admin-catalog.ts +73 -0
  95. package/template/packages/shared/src/agent-workbench.ts +100 -0
@@ -0,0 +1,131 @@
1
+ import { expect, test } from "bun:test";
2
+ import { SessionEventLogService } from "./session-log/service.js";
3
+ import { InMemorySessionEventRepository } from "./session-log/in-memory-repository.js";
4
+ import { projectSessionRecording } from "./session-recording-reader.js";
5
+
6
+ test("fork replay reuses AH1 recorded-only segments and original parent/child sequences", async () => {
7
+ const repository = new InMemorySessionEventRepository();
8
+ const service = new SessionEventLogService(repository);
9
+ const governance = {
10
+ actorId: "actor-1",
11
+ scopeDigest: `sha256:${"a".repeat(64)}` as const,
12
+ };
13
+ await service.create({
14
+ sessionId: "session_parent",
15
+ createdEventId: "session_event_parent",
16
+ governance,
17
+ });
18
+ await service.append({
19
+ sessionId: "session_parent",
20
+ expectedSequence: 1,
21
+ events: [
22
+ service.newEvent({
23
+ type: "turn.started",
24
+ turnId: "session_turn_parent",
25
+ cause: "user-input",
26
+ input: {
27
+ sourceType: "agent-run-input",
28
+ sourceId: "binding_1",
29
+ sourceVersion: "business.answer",
30
+ contentDigest: `sha256:${"b".repeat(64)}`,
31
+ },
32
+ }),
33
+ service.newEvent({
34
+ type: "run.linked",
35
+ turnId: "session_turn_parent",
36
+ runId: "run_parent",
37
+ }),
38
+ service.newEvent({
39
+ type: "turn.completed",
40
+ turnId: "session_turn_parent",
41
+ outcome: "completed",
42
+ }),
43
+ ],
44
+ });
45
+ await service.checkpoint({
46
+ sessionId: "session_parent",
47
+ expectedSequence: 4,
48
+ checkpointId: "session_checkpoint_parent",
49
+ eventId: "session_event_checkpoint",
50
+ });
51
+ await service.fork({
52
+ parentSessionId: "session_parent",
53
+ expectedParentSequence: 5,
54
+ checkpointId: "session_checkpoint_parent",
55
+ childSessionId: "session_child",
56
+ parentEventId: "session_event_fork",
57
+ childCreatedEventId: "session_event_child",
58
+ governance,
59
+ });
60
+ await service.append({
61
+ sessionId: "session_child",
62
+ expectedSequence: 1,
63
+ events: [
64
+ service.newEvent({
65
+ type: "turn.started",
66
+ turnId: "session_turn_child",
67
+ cause: "user-input",
68
+ input: {
69
+ sourceType: "agent-run-input",
70
+ sourceId: "binding_2",
71
+ sourceVersion: "business.answer",
72
+ contentDigest: `sha256:${"c".repeat(64)}`,
73
+ },
74
+ }),
75
+ service.newEvent({
76
+ type: "run.linked",
77
+ turnId: "session_turn_child",
78
+ runId: "run_child",
79
+ }),
80
+ service.newEvent({
81
+ type: "turn.completed",
82
+ turnId: "session_turn_child",
83
+ outcome: "completed",
84
+ }),
85
+ ],
86
+ });
87
+ const replay = await service.replay({ sessionId: "session_child" });
88
+ const projected = projectSessionRecording({
89
+ ...replay,
90
+ segments: await Promise.all(
91
+ replay.segments.map(async (segment) => ({
92
+ ...segment,
93
+ session: (await repository.get(segment.sessionId))!,
94
+ })),
95
+ ),
96
+ });
97
+ expect(replay.sideEffectPolicy).toBe("recorded-only");
98
+ expect(projected.segments.map(({ sessionId }) => sessionId)).toEqual([
99
+ "session_parent",
100
+ "session_child",
101
+ ]);
102
+ expect(
103
+ projected.segments.map(({ timeline }) =>
104
+ timeline.map(({ sequence }) => sequence),
105
+ ),
106
+ ).toEqual([
107
+ [1, 2, 3, 4],
108
+ [1, 2, 3, 4],
109
+ ]);
110
+ expect(
111
+ projected.runRefs.map(({ runId, sessionId, operationKey }) => ({
112
+ runId,
113
+ sessionId,
114
+ operationKey,
115
+ })),
116
+ ).toEqual([
117
+ {
118
+ runId: "run_parent",
119
+ sessionId: "session_parent",
120
+ operationKey: "business.answer",
121
+ },
122
+ {
123
+ runId: "run_child",
124
+ sessionId: "session_child",
125
+ operationKey: "business.answer",
126
+ },
127
+ ]);
128
+ expect(projected.truncated).toBe(false);
129
+ expect((await repository.get("session_parent"))?.headSequence).toBe(6);
130
+ expect((await repository.get("session_child"))?.headSequence).toBe(4);
131
+ });
@@ -0,0 +1,62 @@
1
+ import type { SessionRecord } from "./session-log/domain.js";
2
+ import type { ReadOnlySessionReplay } from "./session-log/replay-policy.js";
3
+ import {
4
+ projectAgentSession,
5
+ projectAgentSessionTimelineEvent,
6
+ } from "./session-projection-view.js";
7
+
8
+ /** Already-authorized replay segments preserve parent/child sequence domains. */
9
+ export function projectSessionRecording(
10
+ replay: Omit<ReadOnlySessionReplay, "segments"> & {
11
+ segments: readonly {
12
+ sessionId: string;
13
+ throughSequence: number;
14
+ events: ReadOnlySessionReplay["segments"][number]["events"];
15
+ session: SessionRecord;
16
+ }[];
17
+ },
18
+ ) {
19
+ const current = replay.segments.find(
20
+ ({ sessionId }) => sessionId === replay.sessionId,
21
+ );
22
+ if (!current) throw new Error("Current replay segment missing");
23
+ const segments = replay.segments.map((segment) => ({
24
+ ...segment,
25
+ events: segment.events.slice(0, 10_000),
26
+ }));
27
+ const runRefs = segments.flatMap(({ sessionId, events }) => {
28
+ const operations = new Map(
29
+ events.flatMap(({ data }) =>
30
+ data.type === "turn.started" &&
31
+ data.input.sourceType === "agent-run-input"
32
+ ? [[data.turnId, data.input.sourceVersion] as const]
33
+ : [],
34
+ ),
35
+ );
36
+ return events.flatMap(({ data }) =>
37
+ data.type === "run.linked"
38
+ ? [
39
+ {
40
+ runId: data.runId,
41
+ sessionId,
42
+ turnId: data.turnId,
43
+ operationKey: operations.get(data.turnId) ?? "",
44
+ },
45
+ ]
46
+ : [],
47
+ );
48
+ });
49
+ return {
50
+ session: projectAgentSession(current.session),
51
+ truncated: replay.segments.some(({ events }) => events.length > 10_000),
52
+ segments: segments.map(({ sessionId, throughSequence, events }) => ({
53
+ sessionId,
54
+ throughSequence,
55
+ timeline: events.map(projectAgentSessionTimelineEvent),
56
+ })),
57
+ timeline: segments
58
+ .find(({ sessionId }) => sessionId === replay.sessionId)!
59
+ .events.map(projectAgentSessionTimelineEvent),
60
+ runRefs,
61
+ };
62
+ }
@@ -0,0 +1,22 @@
1
+ import { randomUUIDv7 } from "bun";
2
+ import { auditActor, type RequestGuardContext } from "../guards.js";
3
+ import type { createServerRuntime } from "../runtime.js";
4
+
5
+ export function auditAgentAdminCatalogRead(
6
+ runtime: ReturnType<typeof createServerRuntime>,
7
+ context: RequestGuardContext,
8
+ kind: "tools" | "operations",
9
+ itemCount: number,
10
+ ): void {
11
+ runtime.auditSink.recordEvent({
12
+ id: randomUUIDv7(),
13
+ action:
14
+ kind === "tools" ? "system.agent-tools.read" : "system.agent-debug.read",
15
+ actor: auditActor(context),
16
+ outcome: "success",
17
+ target: { type: "agent-admin-catalog", id: kind },
18
+ requestId: context.requestId,
19
+ metadata: { itemCount },
20
+ occurredAt: new Date().toISOString(),
21
+ });
22
+ }
@@ -0,0 +1,21 @@
1
+ import type { AgentAdminAvailabilityReason } from "@southwind-ai/shared";
2
+ import type { GuardDecisionReason } from "../guards.js";
3
+
4
+ export function catalogGuardReason(
5
+ reason: GuardDecisionReason,
6
+ ): AgentAdminAvailabilityReason {
7
+ switch (reason) {
8
+ case "allowed":
9
+ case "anonymous":
10
+ return "allowed";
11
+ case "feature-disabled":
12
+ case "dependency-missing":
13
+ return "feature-unavailable";
14
+ case "license-restricted":
15
+ case "license-version-not-allowed":
16
+ case "entitlement-not-granted":
17
+ return "license-unavailable";
18
+ default:
19
+ return "permission-denied";
20
+ }
21
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { featureKey } from "@southwind-ai/shared";
3
+ import { Elysia } from "elysia";
4
+ import { createStaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
5
+ import type { RouteApp } from "../system/routes.js";
6
+ import {
7
+ createRunFacadeFixture,
8
+ runFacadeManifest,
9
+ } from "./run-facade.test-support.js";
10
+ import { registerAgentAdminCatalogRoutes } from "./tool-catalog-routes.js";
11
+ import { GovernedToolRegistry } from "./tool-registry.js";
12
+
13
+ async function fixture() {
14
+ const run = await createRunFacadeFixture();
15
+ const module = runFacadeManifest();
16
+ module.features.push(
17
+ ...["system.agent-tools", "system.agent-debug"].map((key) => ({
18
+ ...module.features[0]!,
19
+ key: featureKey(key),
20
+ })),
21
+ );
22
+ module.apiPermissions.push(
23
+ ...["tools", "operations"].map((kind) => ({
24
+ method: "GET" as const,
25
+ path: `/api/agent/admin/${kind}`,
26
+ permissionKey:
27
+ kind === "tools"
28
+ ? "system.agent-tools.read"
29
+ : "system.agent-debug.read",
30
+ featureKey: featureKey(
31
+ kind === "tools" ? "system.agent-tools" : "system.agent-debug",
32
+ ),
33
+ })),
34
+ );
35
+ const registry = new GovernedToolRegistry(
36
+ [module],
37
+ new Map([
38
+ [
39
+ "business.records.list",
40
+ {
41
+ scopeMode: "request" as const,
42
+ execute: () => ({ items: [] }),
43
+ },
44
+ ],
45
+ ]),
46
+ run.runtime,
47
+ );
48
+ const app = new Elysia().derive(() => ({ guardContext: run.context }));
49
+ app.group("/api", (group) => {
50
+ registerAgentAdminCatalogRoutes(group as unknown as RouteApp, {
51
+ modules: [module],
52
+ hosts: run.hosts,
53
+ runtime: run.runtime,
54
+ registry,
55
+ authorizer: createStaticRouteAuthorizer(run.runtime, [module]),
56
+ runtimeAvailable: () => true,
57
+ });
58
+ return group;
59
+ });
60
+ return { ...run, app };
61
+ }
62
+
63
+ describe("Manifest-authorized Admin catalog endpoints", () => {
64
+ test("Read permission is required and is independent from execution permission", async () => {
65
+ const { app, context, runtime } = await fixture();
66
+ const request = () =>
67
+ app.handle(new Request("http://localhost/api/agent/admin/tools"));
68
+ expect((await request()).status).toBe(403);
69
+ context.actor.permissionKeys = ["system.agent-tools.read"];
70
+ const response = await request();
71
+ expect(response.status).toBe(200);
72
+ const items = await response.json();
73
+ expect(items[0]).toMatchObject({
74
+ key: "business.records.list",
75
+ allowed: false,
76
+ availabilityReason: "permission-denied",
77
+ });
78
+ expect(runtime.auditSink.list().at(-1)).toMatchObject({
79
+ action: "system.agent-tools.read",
80
+ metadata: { itemCount: 1 },
81
+ });
82
+ });
83
+
84
+ test("Operation metadata access cannot start runs and successful reads are audited", async () => {
85
+ const { app, context, remote, prepared, runtime } = await fixture();
86
+ context.actor.permissionKeys = ["system.agent-debug.read"];
87
+ const response = await app.handle(
88
+ new Request("http://localhost/api/agent/admin/operations"),
89
+ );
90
+ expect(response.status).toBe(200);
91
+ const items = await response.json();
92
+ expect(items[0]).toMatchObject({
93
+ key: "business.answer",
94
+ selectable: false,
95
+ });
96
+ expect(remote.started).toHaveLength(0);
97
+ expect(prepared).toHaveLength(0);
98
+ expect(runtime.auditSink.list().at(-1)).toMatchObject({
99
+ action: "system.agent-debug.read",
100
+ metadata: { itemCount: 1 },
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,62 @@
1
+ import type { ModuleManifest } from "@southwind-ai/modules";
2
+ import type { RequestGuardContext } from "../guards.js";
3
+ import type { StaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
4
+ import type { RouteApp } from "../system/routes.js";
5
+ import {
6
+ AgentAdminOperationCatalog,
7
+ type AgentAdminOperationCatalogInput,
8
+ } from "./operation-catalog.js";
9
+ import type { GovernedToolRegistry } from "./tool-registry.js";
10
+ import { projectAdminToolCatalog } from "./tool-catalog.js";
11
+ import { auditAgentAdminCatalogRead } from "./tool-catalog-audit.js";
12
+
13
+ export interface AgentAdminCatalogRouteInput extends AgentAdminOperationCatalogInput {
14
+ readonly modules: readonly ModuleManifest[];
15
+ readonly registry: GovernedToolRegistry;
16
+ readonly authorizer: StaticRouteAuthorizer;
17
+ }
18
+
19
+ export function registerAgentAdminCatalogRoutes(
20
+ app: RouteApp,
21
+ input: AgentAdminCatalogRouteInput,
22
+ ): void {
23
+ const operations = new AgentAdminOperationCatalog(input);
24
+ app.get("/agent/admin/tools", async (raw) => {
25
+ const { guardContext } = raw as { guardContext: RequestGuardContext };
26
+ const denied = await input.authorizer.authorize(
27
+ guardContext,
28
+ "GET",
29
+ "/api/agent/admin/tools",
30
+ );
31
+ if (denied) return denied;
32
+ const items = await projectAdminToolCatalog(
33
+ input.registry,
34
+ input.modules,
35
+ guardContext,
36
+ );
37
+ auditAgentAdminCatalogRead(
38
+ input.runtime,
39
+ guardContext,
40
+ "tools",
41
+ items.length,
42
+ );
43
+ return items;
44
+ });
45
+ app.get("/agent/admin/operations", async (raw) => {
46
+ const { guardContext } = raw as { guardContext: RequestGuardContext };
47
+ const denied = await input.authorizer.authorize(
48
+ guardContext,
49
+ "GET",
50
+ "/api/agent/admin/operations",
51
+ );
52
+ if (denied) return denied;
53
+ const items = await operations.list(guardContext);
54
+ auditAgentAdminCatalogRead(
55
+ input.runtime,
56
+ guardContext,
57
+ "operations",
58
+ items.length,
59
+ );
60
+ return items;
61
+ });
62
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServerRuntime } from "../runtime.js";
3
+ import { guardContext, runFacadeManifest } from "./run-facade.test-support.js";
4
+ import { projectAdminToolCatalog } from "./tool-catalog.js";
5
+ import {
6
+ GovernedToolRegistry,
7
+ type ModuleToolHandlers,
8
+ } from "./tool-registry.js";
9
+
10
+ function fixture(options: { enabled?: boolean; sandbox?: boolean } = {}) {
11
+ const module = runFacadeManifest();
12
+ module.features = module.features.map((feature) => ({
13
+ ...feature,
14
+ enabled: options.enabled ?? true,
15
+ }));
16
+ const runtime = createServerRuntime({}, { featureCatalog: module.features });
17
+ const handlers: ModuleToolHandlers = new Map([
18
+ [
19
+ "business.records.list",
20
+ {
21
+ scopeMode: "request",
22
+ executionMode: options.sandbox ? "sandbox" : "host",
23
+ execute: () => ({ items: [] }),
24
+ },
25
+ ],
26
+ ]);
27
+ return {
28
+ module,
29
+ registry: new GovernedToolRegistry([module], handlers, runtime),
30
+ };
31
+ }
32
+
33
+ describe("Admin dynamic Tool catalogue", () => {
34
+ test("Business-owned declaration, Schema and installed execution mode match actual registry", async () => {
35
+ const { module, registry } = fixture({ sandbox: true });
36
+ const [tool] = await projectAdminToolCatalog(
37
+ registry,
38
+ [module],
39
+ guardContext(["business.records.read"]),
40
+ );
41
+ const definition = registry.definition("business.records.list")!;
42
+ expect(tool).toMatchObject({
43
+ key: definition.key,
44
+ module: "business",
45
+ label: definition.label,
46
+ description: definition.description,
47
+ featureKey: definition.featureKey,
48
+ permissionKey: definition.permissionKey,
49
+ scopeMode: definition.scopeMode,
50
+ executionMode: "sandbox",
51
+ approval: definition.approval,
52
+ idempotency: definition.idempotency,
53
+ limits: definition.limits,
54
+ inputSchemaKey: definition.inputSchemaKey,
55
+ outputSchemaKey: definition.outputSchemaKey,
56
+ inputSchema: definition.inputSchema,
57
+ outputSchema: definition.outputSchema,
58
+ allowedOperationKeys: ["business.answer"],
59
+ allowed: true,
60
+ availabilityReason: "allowed",
61
+ });
62
+ expect(tool.inputSchema).not.toBe(definition.inputSchema);
63
+ });
64
+
65
+ test("Catalogued but unauthorized tools do not gain execution rights", async () => {
66
+ const { module, registry } = fixture();
67
+ const context = guardContext(["system.agent-tools.read"]);
68
+ const tools = await projectAdminToolCatalog(registry, [module], context);
69
+ expect(tools).toHaveLength(1);
70
+ expect(tools[0]).toMatchObject({
71
+ allowed: false,
72
+ availabilityReason: "permission-denied",
73
+ });
74
+ await expect(
75
+ registry.execute("business.records.list", {}, context),
76
+ ).rejects.toMatchObject({ code: "TOOL_FORBIDDEN" });
77
+ });
78
+
79
+ test("Current runtime feature and server-created scope determine callability", async () => {
80
+ const disabled = fixture({ enabled: false });
81
+ const context = guardContext(["business.records.read"]);
82
+ const [off] = await projectAdminToolCatalog(
83
+ disabled.registry,
84
+ [disabled.module],
85
+ context,
86
+ );
87
+ expect(off).toMatchObject({
88
+ allowed: false,
89
+ availabilityReason: "feature-unavailable",
90
+ });
91
+ const { registry, module } = fixture();
92
+ delete context.actor.scopeContext;
93
+ const [noScope] = await projectAdminToolCatalog(
94
+ registry,
95
+ [module],
96
+ context,
97
+ );
98
+ expect(noScope).toMatchObject({
99
+ allowed: false,
100
+ availabilityReason: "scope-unavailable",
101
+ });
102
+ expect(await registry.allowed("business.records.list", context)).toBe(
103
+ false,
104
+ );
105
+ await expect(
106
+ registry.execute("business.records.list", {}, context),
107
+ ).rejects.toMatchObject({ code: "TOOL_FORBIDDEN" });
108
+ });
109
+
110
+ test("License refusal is bounded and contains no raw guard event", async () => {
111
+ const { registry, module } = fixture();
112
+ const context = guardContext(["business.records.read"]);
113
+ context.license = { status: "expired", restricted: true };
114
+ context.licenseEnforced = true;
115
+ const [tool] = await projectAdminToolCatalog(registry, [module], context);
116
+ expect(tool).toMatchObject({
117
+ allowed: false,
118
+ availabilityReason: "license-unavailable",
119
+ });
120
+ expect(tool).not.toHaveProperty("auditEvent");
121
+ });
122
+ });
@@ -0,0 +1,44 @@
1
+ import type { ModuleManifest } from "@southwind-ai/modules";
2
+ import type { AgentAdminToolCatalogItem } from "@southwind-ai/shared";
3
+ import type { RequestGuardContext } from "../guards.js";
4
+ import type { GovernedToolRegistry } from "./tool-registry.js";
5
+
6
+ export async function projectAdminToolCatalog(
7
+ registry: GovernedToolRegistry,
8
+ modules: readonly ModuleManifest[],
9
+ context: RequestGuardContext,
10
+ ): Promise<readonly AgentAdminToolCatalogItem[]> {
11
+ const operations = modules.flatMap(({ aiOperations }) => aiOperations ?? []);
12
+ return Promise.all(
13
+ registry.definitions().map(async (tool) => ({
14
+ key: tool.key,
15
+ label: tool.label,
16
+ description: tool.description,
17
+ module: tool.module,
18
+ operation: tool.operation,
19
+ featureKey: tool.featureKey,
20
+ permissionKey: tool.permissionKey,
21
+ auditAction: tool.auditAction,
22
+ scopeMode: tool.scopeMode,
23
+ executionMode: registry.executionMode(tool.key)!,
24
+ dataClassification: tool.dataClassification,
25
+ approval: tool.approval,
26
+ ...(tool.approvalRoles
27
+ ? { approvalRoles: structuredClone(tool.approvalRoles) }
28
+ : {}),
29
+ ...(tool.approvalTimeoutSeconds
30
+ ? { approvalTimeoutSeconds: tool.approvalTimeoutSeconds }
31
+ : {}),
32
+ idempotency: tool.idempotency,
33
+ limits: { ...tool.limits },
34
+ inputSchemaKey: tool.inputSchemaKey,
35
+ outputSchemaKey: tool.outputSchemaKey,
36
+ inputSchema: structuredClone(tool.inputSchema),
37
+ outputSchema: structuredClone(tool.outputSchema),
38
+ allowedOperationKeys: operations
39
+ .filter(({ allowedToolKeys }) => allowedToolKeys.includes(tool.key))
40
+ .map(({ key }) => key),
41
+ ...(await registry.availability(tool.key, context)),
42
+ })),
43
+ );
44
+ }
@@ -7,13 +7,17 @@ import type {
7
7
  ModuleToolInput,
8
8
  ModuleToolResult,
9
9
  } from "@southwind-ai/server-sdk";
10
- import type { FeatureFlagDefinition } from "@southwind-ai/shared";
10
+ import type {
11
+ AgentAdminAvailabilityReason,
12
+ FeatureFlagDefinition,
13
+ } from "@southwind-ai/shared";
11
14
  import Ajv, { type ValidateFunction } from "ajv";
12
15
  import { evaluateAccessGuards } from "../access-guards.js";
13
16
  import { isServerScopeContext } from "../data-access/context.js";
14
17
  import type { RequestGuardContext } from "../guards.js";
15
18
  import type { createServerRuntime } from "../runtime.js";
16
19
  import { sandboxFailureEvent, toolEvent, toolJsonBytes } from "./tool-audit.js";
20
+ import { catalogGuardReason } from "./tool-catalog-availability.js";
17
21
  import type { GovernedToolExecutionWithAuditReceipt } from "./tool-execution-receipt.js";
18
22
  import {
19
23
  collectRegisteredTools,
@@ -108,20 +112,47 @@ export class GovernedToolRegistry {
108
112
  return this.#tools.get(key)?.definition;
109
113
  }
110
114
 
111
- async allowed(key: string, context: RequestGuardContext): Promise<boolean> {
115
+ executionMode(key: string): "host" | "sandbox" | undefined {
112
116
  const tool = this.#tools.get(key);
113
- if (!tool) return false;
114
- return (
115
- await evaluateAccessGuards(
116
- this.runtime,
117
- context,
118
- {
119
- permission: { permissionKey: tool.definition.permissionKey },
120
- feature: tool.definition.feature,
121
- },
122
- { record: false },
123
- )
124
- ).decision.allowed;
117
+ return tool ? (tool.handler.executionMode ?? "host") : undefined;
118
+ }
119
+
120
+ async availability(
121
+ key: string,
122
+ context: RequestGuardContext,
123
+ ): Promise<{
124
+ readonly allowed: boolean;
125
+ readonly availabilityReason: AgentAdminAvailabilityReason;
126
+ }> {
127
+ const tool = this.#tools.get(key);
128
+ if (!tool)
129
+ return { allowed: false, availabilityReason: "handler-unavailable" };
130
+ const access = await evaluateAccessGuards(
131
+ this.runtime,
132
+ context,
133
+ {
134
+ permission: { permissionKey: tool.definition.permissionKey },
135
+ feature: tool.definition.feature,
136
+ },
137
+ { record: false },
138
+ );
139
+ if (!access.decision.allowed) {
140
+ return {
141
+ allowed: false,
142
+ availabilityReason: catalogGuardReason(access.decision.reason),
143
+ };
144
+ }
145
+ if (
146
+ tool.handler.scopeMode === "request" &&
147
+ !isServerScopeContext(context.actor.scopeContext)
148
+ ) {
149
+ return { allowed: false, availabilityReason: "scope-unavailable" };
150
+ }
151
+ return { allowed: true, availabilityReason: "allowed" };
152
+ }
153
+
154
+ async allowed(key: string, context: RequestGuardContext): Promise<boolean> {
155
+ return (await this.availability(key, context)).allowed;
125
156
  }
126
157
 
127
158
  async execute(
@@ -0,0 +1,39 @@
1
+ import type { AgentPlatformRuntimeInput } from "./runtime-bootstrap-input.js";
2
+ import { AgentWorkbenchFacade } from "./workbench-facade.js";
3
+ import { registerAgentWorkbenchRoutes } from "./workbench-routes.js";
4
+ import { AgentWorkbenchRunReader } from "./workbench-run-reader.js";
5
+ import type { RouteApp } from "../system/routes.js";
6
+ import type { AgentRunFacade } from "./run-facade.js";
7
+ import type { AgentRuntimePort } from "./run-contracts.js";
8
+ import type { AgentSessionFacadeV1 } from "./agent-session-facade.js";
9
+ import type { AgentSessionProjectionFacade } from "./session-projection-facade.js";
10
+ import type { AgentWorkflowQueryService } from "../agent-workflow/query-service.js";
11
+
12
+ export function registerAgentWorkbenchRuntime(
13
+ input: AgentPlatformRuntimeInput,
14
+ app: RouteApp,
15
+ ports: {
16
+ readonly agentFacade?: AgentRunFacade;
17
+ readonly remote?: AgentRuntimePort;
18
+ readonly business?: AgentSessionFacadeV1;
19
+ readonly sessions?: AgentSessionProjectionFacade;
20
+ readonly workflows?: AgentWorkflowQueryService;
21
+ } = {},
22
+ ) {
23
+ registerAgentWorkbenchRoutes(
24
+ app,
25
+ new AgentWorkbenchFacade({
26
+ modules: input.modules,
27
+ runtime: input.runtime,
28
+ sessions: ports.sessions,
29
+ business: input.hosts.agentSessionProviders.length
30
+ ? ports.business
31
+ : undefined,
32
+ workflows: ports.workflows,
33
+ runs:
34
+ ports.agentFacade && ports.remote
35
+ ? new AgentWorkbenchRunReader(ports.agentFacade, ports.remote)
36
+ : undefined,
37
+ }),
38
+ );
39
+ }