@jskit-ai/assistant-core 0.1.160 → 0.1.162
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.
|
|
3
|
+
"version": "0.1.162",
|
|
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.
|
|
15
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
14
|
+
"@jskit-ai/resource-core": "0.1.128",
|
|
15
|
+
"@jskit-ai/resource-crud-core": "0.1.128",
|
|
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.
|
|
23
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
+
"@jskit-ai/http-runtime": "0.1.184",
|
|
23
|
+
"@jskit-ai/kernel": "0.1.186",
|
|
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
|
+
});
|