@jskit-ai/assistant-core 0.1.160 → 0.1.161

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.160",
3
+ "version": "0.1.161",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,16 +11,16 @@
11
11
  "./shared": "./src/shared/index.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/resource-core": "0.1.126",
15
- "@jskit-ai/resource-crud-core": "0.1.126",
14
+ "@jskit-ai/resource-core": "0.1.127",
15
+ "@jskit-ai/resource-crud-core": "0.1.127",
16
16
  "dompurify": "^3.4.13",
17
17
  "json-rest-schema": "^1.0.17",
18
18
  "marked": "^17.0.4",
19
19
  "openai": "^6.22.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "@jskit-ai/http-runtime": "0.1.182",
23
- "@jskit-ai/kernel": "0.1.184",
22
+ "@jskit-ai/http-runtime": "0.1.183",
23
+ "@jskit-ai/kernel": "0.1.185",
24
24
  "vue": "^3.5.13",
25
25
  "vuetify": "^4.0.0"
26
26
  },
@@ -159,6 +159,7 @@ function normalizeAssistantExtension(value) {
159
159
 
160
160
  return Object.freeze({
161
161
  description: normalizeText(source.description),
162
+ exclude: normalizeText(source.exclude),
162
163
  alwaysAvailable: source.alwaysAvailable === true,
163
164
  preflight: normalizePreflightIntents(source.preflight),
164
165
  output: Object.hasOwn(source, "output") ? source.output : null,
@@ -508,7 +509,7 @@ function canUseToolOnSurface(entry = {}, context = {}) {
508
509
  return true;
509
510
  }
510
511
 
511
- return toolSurfaces.includes(contextSurfaceId);
512
+ return toolSurfaces.includes("*") || toolSurfaces.includes(contextSurfaceId);
512
513
  }
513
514
 
514
515
  function resolveActionBackedToolEntries(actions) {
@@ -536,6 +537,10 @@ function resolveActionBackedToolEntries(actions) {
536
537
  continue;
537
538
  }
538
539
 
540
+ if (assistantExtension.exclude) {
541
+ continue;
542
+ }
543
+
539
544
  const outputDefinition = assistantExtension.output || action.output;
540
545
  const inputSchema = resolveStructuredSchemaTransportSchema(action.input, {
541
546
  context: `Action definition "${actionId}" input`,
@@ -614,6 +619,7 @@ function resolveActionToolEntries(
614
619
  outputSchema: actionEntry.outputSchema
615
620
  }),
616
621
  kind: actionEntry.kind,
622
+ acceptsWorkspaceSlug: Object.hasOwn(actionEntry.inputSchema.properties || {}, "workspaceSlug"),
617
623
  outputDefinition: actionEntry.outputDefinition,
618
624
  transformResult: actionEntry.transformResult,
619
625
  permission: actionEntry.permission,
@@ -809,17 +815,17 @@ function createServiceToolCatalog(
809
815
  return contract;
810
816
  }
811
817
 
812
- function createActionInput(value, context = {}) {
818
+ function createActionInput(entry, value, context = {}) {
813
819
  const actionInput = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
814
820
  const trustedWorkspaceSlug = resolveWorkspaceSlug(context);
815
- if (trustedWorkspaceSlug) {
821
+ if (trustedWorkspaceSlug && entry.acceptsWorkspaceSlug) {
816
822
  actionInput.workspaceSlug = trustedWorkspaceSlug;
817
823
  }
818
824
  return actionInput;
819
825
  }
820
826
 
821
827
  async function executeActionEntry(entry, input = {}, context = {}) {
822
- const actionInput = createActionInput(input, context);
828
+ const actionInput = createActionInput(entry, input, context);
823
829
  const executionContext = {
824
830
  ...context,
825
831
  channel: AUTOMATION_CHANNEL
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readdir } from "node:fs/promises";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
+ import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
6
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
7
+ import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
8
+ import { createAssistantActions } from "../../assistant-runtime/src/server/actions.js";
9
+ import { accountSettingsResponseFormatter } from "../../users-core/src/server/common/formatters/accountSettingsResponseFormatter.js";
10
+ import { DEFAULT_USER_SETTINGS } from "../../users-core/src/shared/settings.js";
11
+
12
+ const specifications = [];
13
+ for (const owner of ["users-core", "workspaces-core", "console-core"]) {
14
+ const directory = new URL(`../../${owner}/src/server/`, import.meta.url);
15
+ for (const path of await readdir(directory, { recursive: true })) {
16
+ if (!path.endsWith("Actions.js")) continue;
17
+ const module = await import(new URL(path, directory));
18
+ for (const [name, value] of Object.entries(module)) {
19
+ if (name.endsWith("ActionSpecifications")) specifications.push(...value);
20
+ }
21
+ }
22
+ }
23
+ specifications.push(...createAssistantActions({ assistantConfigService: {}, chatService: {}, config: {
24
+ surfaceDefinitions: { admin: {} },
25
+ assistantSurfaces: { admin: { settingsSurfaceId: "admin", configScope: "global" } }
26
+ }}).filter((action) => action.channels.includes("automation")));
27
+
28
+ const workspace = { id: "11", slug: "acme", name: "Acme", ownerUserId: "7", avatarUrl: "" };
29
+ const roleCatalog = { collaborationEnabled: true, defaultInviteRole: "member", roles: [], assignableRoleIds: [] };
30
+ const memberResult = { workspace, members: [], roleCatalog };
31
+ const invite = { id: "31", email: "invite@example.test", roleSid: "member", status: "pending", expiresAt: "2026-10-01" };
32
+ const invitesResult = { workspace, invites: [invite], roleCatalog };
33
+ const settings = accountSettingsResponseFormatter({
34
+ profile: { id: "7", displayName: "Alice", email: "alice@example.test" }, settings: DEFAULT_USER_SETTINGS
35
+ });
36
+ const workspaceSettings = {
37
+ workspace: { id: "11", slug: "acme", ownerUserId: "7" }, roleCatalog,
38
+ settings: { ...Object.fromEntries(["light", "dark"].flatMap((theme) =>
39
+ ["Primary", "Secondary", "Surface", "SurfaceVariant"].map((color) => [`${theme}${color}Color`, "#112233"]))),
40
+ invitesEnabled: true, invitesAvailable: true, invitesEffective: true }
41
+ };
42
+ // These are native service results, including HTTP wrappers and deliberately sensitive state.
43
+ const fixtures = {
44
+ "settings.read": [{}, returnJsonApiData(settings)],
45
+ "settings.profile.update": [{ displayName: "Alice" }, { session: { token: "SECRET" }, response: returnJsonApiData(settings) }],
46
+ "settings.profile.avatar.delete": [{}, returnJsonApiData(settings)],
47
+ "settings.preferences.update": [{ theme: "dark" }, returnJsonApiData(settings)],
48
+ "settings.notifications.update": [{ productUpdates: false }, returnJsonApiData(settings)],
49
+ "settings.security.password_method.toggle": [{ enabled: true }, returnJsonApiData({ securityStatus: settings.security, settings, session: "SECRET" })],
50
+ "settings.security.oauth.unlink": [{ provider: "google" }, returnJsonApiData({ securityStatus: settings.security, providerMessageId: "SECRET" })],
51
+ "settings.security.sessions.logout_others": [{}, null],
52
+ "workspace.workspaces.create": [{ name: "Acme" }, { ...workspace, isPersonal: false, createdAt: "2026-09-13", updatedAt: "2026-09-13", deletedAt: null }],
53
+ "workspace.workspaces.list": [{}, [{ ...workspace, roleSid: "owner", isAccessible: true, membershipStatus: "active", createdAt: "2026-09-13" }]],
54
+ "workspace.workspaces.read": [{ workspaceSlug: "evil" }, { ...workspace, isPersonal: false, createdAt: "2026-09-13" }],
55
+ "workspace.workspaces.update": [{ workspaceSlug: "evil", name: "Acme" }, workspace],
56
+ "workspace.settings.read": [{ workspaceSlug: "evil" }, workspaceSettings],
57
+ "workspace.settings.update": [{ workspaceSlug: "evil", invitesEnabled: false }, workspaceSettings],
58
+ "workspace.roles.list": [{ workspaceSlug: "evil" }, roleCatalog],
59
+ "workspace.members.list": [{ workspaceSlug: "evil" }, memberResult],
60
+ "workspace.member.role.update": [{ workspaceSlug: "evil", memberUserId: "8", roleSid: "member" }, memberResult],
61
+ "workspace.member.remove": [{ workspaceSlug: "evil", memberUserId: "8" }, memberResult],
62
+ "workspace.invites.list": [{ workspaceSlug: "evil" }, invitesResult],
63
+ "workspace.invite.create": [{ workspaceSlug: "evil", email: invite.email, roleSid: "member" }, {
64
+ ...invitesResult, createdInviteId: "31", inviteTokenPreview: "SECRET", inviteUrl: "https://example.test/invite?token=SECRET",
65
+ inviteDelivery: { status: "sent", message: "SECRET", providerMessageId: "SECRET" }
66
+ }],
67
+ "workspace.invite.revoke": [{ workspaceSlug: "evil", inviteId: "31" }, { ...invitesResult, revokedInviteId: "31" }],
68
+ "workspace.invitations.pending.list": [{}, [{ id: "31", workspaceId: "11", workspaceSlug: "acme", workspaceName: "Acme",
69
+ workspaceAvatarUrl: "", roleSid: "member", status: "pending", expiresAt: "2026-10-01", token: "SECRET" }]]
70
+ };
71
+
72
+ function catalogFor(spec, result, observe = () => {}) {
73
+ const actions = createActionCatalogue();
74
+ const service = new Proxy({}, { get: () => async (...args) => { observe(args); return result; } });
75
+ const { run, ...definition } = spec;
76
+ actions.register({ contributorId: "test.framework", domain: "framework", actions: [{
77
+ ...definition, events: [], execute: (input, context) => {
78
+ observe([input, context]);
79
+ return run(service, input, context);
80
+ }
81
+ }] });
82
+ return createServiceToolCatalog(actions);
83
+ }
84
+ const context = { actor: { id: "7" }, surface: "admin", workspace, workspaceSlug: "acme",
85
+ permissions: ["workspace.settings.view", "workspace.settings.update", "workspace.roles.view", "workspace.members.view",
86
+ "workspace.members.manage", "workspace.members.invite", "workspace.invites.revoke"] };
87
+
88
+ test("every account, workspace, console and assistant automation action has a contract or documented exclusion", () => {
89
+ assert.equal(specifications.length, 31);
90
+ let direct = 0;
91
+ for (const spec of specifications) {
92
+ const assistant = spec.extensions?.assistant;
93
+ assert.ok(assistant, spec.id);
94
+ if (assistant.exclude) { assert.ok(assistant.exclude.length > 20, spec.id); continue; }
95
+ direct++;
96
+ assert.ok(assistant.description, spec.id);
97
+ assert.equal(typeof assistant.transformResult, "function", spec.id);
98
+ assert.ok(resolveStructuredSchemaTransportSchema(assistant.output), spec.id);
99
+ assert.ok(fixtures[spec.id], `Add execution coverage for ${spec.id}`);
100
+ }
101
+ assert.equal(direct, 22);
102
+ });
103
+
104
+ for (const spec of specifications.filter((entry) => !entry.extensions?.assistant?.exclude)) {
105
+ test(`${spec.id}: discovers and executes through the action catalogue with validated safe output`, async () => {
106
+ const [input, result] = fixtures[spec.id];
107
+ const observed = [];
108
+ const catalog = catalogFor(spec, result, (args) => observed.push(args));
109
+ const toolSet = catalog.resolveToolSet(context);
110
+ assert.equal(toolSet.tools.length, 1);
111
+ const response = await catalog.executeToolCall({ toolName: toolSet.tools[0].name,
112
+ argumentsText: JSON.stringify(input), context, toolSet });
113
+ assert.equal(response.ok, true, JSON.stringify(response));
114
+ assert.equal(JSON.stringify(response).includes("SECRET"), false);
115
+ assert.equal(JSON.stringify(response).includes("__jskitJsonApiResult"), false);
116
+ if (Object.hasOwn(input, "workspaceSlug")) assert.equal(observed[0][0].workspaceSlug, "acme");
117
+ });
118
+ }
119
+
120
+ test("member operations deny execution after permissions change, even with a previously resolved tool set", async () => {
121
+ for (const id of ["workspace.member.role.update", "workspace.member.remove", "workspace.invite.create", "workspace.invite.revoke"]) {
122
+ const spec = specifications.find((entry) => entry.id === id);
123
+ let calls = 0;
124
+ const catalog = catalogFor(spec, fixtures[id][1], () => calls++);
125
+ const toolSet = catalog.resolveToolSet(context);
126
+ const response = await catalog.executeToolCall({ toolName: toolSet.tools[0].name, toolSet,
127
+ argumentsText: JSON.stringify(fixtures[id][0]), context: { ...context, permissions: [] } });
128
+ assert.equal(response.ok, false);
129
+ assert.equal(calls, 0);
130
+ }
131
+ });
132
+
133
+ test("excluded actions cannot be discovered or executed even when their HTTP output becomes structured", async () => {
134
+ for (const spec of specifications.filter((entry) => entry.extensions?.assistant?.exclude)) {
135
+ const catalog = catalogFor({ ...spec, output: specifications[0].input }, null, () => assert.fail("Excluded action executed"));
136
+ const toolSet = catalog.resolveToolSet(context);
137
+ assert.equal(toolSet.tools.length, 0, spec.id);
138
+ assert.equal((await catalog.executeToolCall({ toolName: spec.id, context, toolSet })).ok, false);
139
+ }
140
+ });
141
+
142
+ test("unexpected nested output fields fail closed without entering the tool result", async () => {
143
+ const spec = specifications.find((entry) => entry.id === "settings.read");
144
+ const catalog = catalogFor(spec, returnJsonApiData({ ...settings, security: { ...settings.security, token: "SECRET" } }));
145
+ const toolSet = catalog.resolveToolSet(context);
146
+ const response = await catalog.executeToolCall({ toolName: toolSet.tools[0].name, context, toolSet });
147
+ assert.equal(response.ok, false);
148
+ assert.equal(response.error.code, "assistant_tool_output_invalid");
149
+ assert.equal(JSON.stringify(response).includes("SECRET"), false);
150
+ });
@@ -593,3 +593,53 @@ test("assistant tools reject unknown tools and return safe action failures", asy
593
593
  error: { code: "DATABASE_FAILED", message: "Tool call failed.", status: 500 }
594
594
  });
595
595
  });
596
+
597
+ test("wildcard and empty surfaces match named surfaces without bypassing permissions", async () => {
598
+ for (const [surfaces, allowed] of [
599
+ [["*"], ["admin", "app", "console", "custom"]],
600
+ [["admin"], ["admin"]],
601
+ [["admin", "console"], ["admin", "console"]]
602
+ ]) {
603
+ const catalog = createServiceToolCatalog(createActions([action({
604
+ surfaces, permission: { require: "all", permissions: ["books.read"] }
605
+ })]));
606
+ for (const surface of ["admin", "app", "console", "custom"]) {
607
+ const context = { actor: { id: "7" }, surface, permissions: ["books.read"] };
608
+ const toolSet = catalog.resolveToolSet(context);
609
+ assert.equal(toolSet.tools.length, allowed.includes(surface) ? 1 : 0);
610
+ assert.equal(catalog.resolveToolSet({ ...context, permissions: [] }).tools.length, 0);
611
+ if (toolSet.tools.length) {
612
+ const denied = await catalog.executeToolCall({ toolName: toolSet.tools[0].name, toolSet,
613
+ context: { ...context, permissions: [] } });
614
+ assert.equal(denied.ok, false);
615
+ }
616
+ }
617
+ }
618
+ });
619
+
620
+ test("explicit assistant exclusion wins over otherwise valid contracts", async () => {
621
+ let executed = false;
622
+ const catalog = createServiceToolCatalog(createActions([action({
623
+ extensions: { assistant: { exclude: "Use the authenticated password form." } },
624
+ execute: async () => { executed = true; return { ok: true }; }
625
+ })]));
626
+ const context = { actor: { id: "7" }, surface: "admin" };
627
+ const toolSet = catalog.resolveToolSet(context);
628
+ assert.equal(toolSet.tools.length, 0);
629
+ const result = await catalog.executeToolCall({ toolName: "demo.books.list", context, toolSet });
630
+ assert.equal(result.ok, false);
631
+ assert.equal(executed, false);
632
+ });
633
+
634
+
635
+ test("catalogue retains unrestricted matching for externally supplied empty surface metadata", () => {
636
+ // The kernel disallows registering an empty list, but catalogue adapters historically accept it.
637
+ const catalog = createServiceToolCatalog({
638
+ listDefinitions: () => [action({ surfaces: [] })],
639
+ execute: async () => ({ ok: true })
640
+ });
641
+ for (const surface of ["admin", "app", "console", "custom"]) {
642
+ assert.equal(catalog.resolveToolSet({ actor: { id: "7" }, surface }).tools.length, 1);
643
+ assert.equal(catalog.resolveToolSet({ surface }).tools.length, 0);
644
+ }
645
+ });