@jskit-ai/assistant-runtime 0.2.19 → 0.2.21

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Changing an assistant's instructions needs `assistant.settings.update` on a settings surface that requires neither a workspace nor the console owner (it needed only a signed-in user), still `workspace.settings.update` on a workspace surface and the console owner on a console-owner surface. `assistantServer.<surface>.settingsUpdatePermission` names another permission, or `{ require: "authenticated" }` to keep the old behaviour. A refusal is `assistant.runtime.settings.updateForbidden` (403).
6
+
7
+ ### New message keys (1)
8
+
9
+ - `jskit.assistant.runtime.settings.updateForbidden`
10
+
11
+ ### Upgrading
12
+
13
+ - Grant `assistant.settings.update` to the people who configure the assistant, or set `settingsUpdatePermission` to a permission they already hold.
14
+
3
15
  ## 0.2.0 - 2026-10-29
4
16
 
5
17
  - Translation ready: every user-visible text is a message key with its English in `locales/en.json`, so an app translates it by supplying translation files only. With no translations, texts and behaviour are unchanged. See the guide "Translating a JSKIT app".
@@ -68,7 +68,8 @@ export default defineConfig({
68
68
  aiClientFactory: { async resolveClient(_surface, { integrationId }) {
69
69
  return { enabled: true, provider: "fixture", defaultModel: integrationId, supportsAttachments: true,
70
70
  model: createScriptedModel(async function* ({ messages }) {
71
- stream.receivedFiles = messages.at(-1).content;
71
+ // The request, before the engine's state note.
72
+ stream.receivedFiles = messages.at(-2).content;
72
73
  await nextChunk();
73
74
  yield { choices: [{ delta: { content: "Partial answer" } }] };
74
75
  await nextChunk();
package/locales/en.json CHANGED
@@ -125,7 +125,8 @@
125
125
  "notConfigured": "Assistant settings are not configured for this surface.",
126
126
  "workspaceUnavailable": "Workspace support is not available for this assistant surface.",
127
127
  "selectWorkspace": "Select a workspace to configure assistant settings.",
128
- "updateFailed": "Unable to update assistant settings."
128
+ "updateFailed": "Unable to update assistant settings.",
129
+ "updateForbidden": "You do not have permission to change the assistant's instructions."
129
130
  }
130
131
  }
131
132
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -14,19 +14,19 @@
14
14
  "./locales/en.json": "./locales/en.json"
15
15
  },
16
16
  "dependencies": {
17
- "@jskit-ai/assistant-core": "0.2.17",
17
+ "@jskit-ai/assistant-core": "0.2.19",
18
18
  "json-rest-schema": "^1.0.17"
19
19
  },
20
20
  "peerDependencies": {
21
- "@jskit-ai/http-runtime": "0.2.7",
22
- "@jskit-ai/http-web": "0.2.13",
23
- "@jskit-ai/kernel": "0.2.7",
24
- "@jskit-ai/realtime": "0.2.11",
25
- "@jskit-ai/shell-web": "0.2.8",
21
+ "@jskit-ai/http-runtime": "0.2.9",
22
+ "@jskit-ai/http-web": "0.2.15",
23
+ "@jskit-ai/kernel": "0.2.9",
24
+ "@jskit-ai/realtime": "0.2.13",
25
+ "@jskit-ai/shell-web": "0.2.10",
26
26
  "@tanstack/vue-query": "^5.90.5",
27
27
  "vue": "^3.5.13",
28
28
  "vuetify": "^4.0.0",
29
- "@jskit-ai/database-runtime": "0.1.256"
29
+ "@jskit-ai/database-runtime": "0.1.258"
30
30
  },
31
31
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
32
32
  "jskit": {
@@ -34,6 +34,11 @@ in source or a questionnaire transcript.
34
34
  `assistantServer`.
35
35
  - The server config selects an AI integration ID, never a credential value.
36
36
  - The application publishes an authorized `integrations.ai` resolver capability.
37
+ - Changing the assistant's instructions needs `workspace.settings.update` on a
38
+ workspace settings surface, the console owner on a console-owner surface and
39
+ `assistant.settings.update` on any other, unless
40
+ `assistantServer.<surface>.settingsUpdatePermission` names the permission the
41
+ application's administrators hold. Reading them needs only a signed-in user.
37
42
  - Runtime and settings pages use the public assistant elements.
38
43
  - Environment values are supplied through the application's normal secret
39
44
  environment boundary.
@@ -1,13 +1,20 @@
1
1
  import { AppError, requireAuth } from "@jskit-ai/kernel/server/runtime";
2
2
  import { normalizeObject, normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { requireAssistantSurface } from "../support/assistantSurface.js";
4
+ import { resolveAssistantServerConfig, resolveAssistantServerConfigMap } from "../support/assistantServerConfig.js";
4
5
 
5
6
  /** @typedef {import("../types.js").AssistantConfigRepository} AssistantConfigRepository */
6
7
  /** @typedef {import("../types.js").WorkspaceScopeSupport} WorkspaceScopeSupport */
7
8
 
9
+ const WORKSPACE_SETTINGS_UPDATE_POLICY = Object.freeze({ require: "all", permissions: Object.freeze(["workspace.settings.update"]) });
10
+ const ASSISTANT_SETTINGS_UPDATE_POLICY = Object.freeze({ require: "all", permissions: Object.freeze(["assistant.settings.update"]) });
11
+
8
12
  /**
9
13
  * An assistant surface's saved settings: who may read and change them, and the
10
- * system prompt they give the surface's conversations.
14
+ * system prompt they give the surface's conversations. Changing them needs the
15
+ * surface's `assistantServer.<surface>.settingsUpdatePermission`, by default
16
+ * `workspace.settings.update` on a workspace settings surface, the console owner
17
+ * on a console-owner surface, and `assistant.settings.update` everywhere else.
11
18
  * @param {object} dependencies
12
19
  * @param {AssistantConfigRepository} dependencies.assistantConfigRepository
13
20
  * @param {import("@jskit-ai/console-core/server/types").ConsoleService | null} dependencies.consoleService The console access service, when the console is installed.
@@ -20,28 +27,44 @@ function createService({
20
27
  appConfig,
21
28
  workspaceScopeSupport
22
29
  }) {
30
+ // Read once here, so a malformed settingsUpdatePermission stops the server from starting.
31
+ resolveAssistantServerConfigMap(appConfig);
32
+
33
+ /**
34
+ * The auth policy that changing a surface's settings needs, or null when its console-owner check is all.
35
+ * @param {ReturnType<typeof requireAssistantSurface>} assistantSurface
36
+ */
37
+ function resolveSettingsUpdatePolicy(assistantSurface) {
38
+ const configured = resolveAssistantServerConfig(appConfig, assistantSurface.targetSurfaceId).settingsUpdatePermission;
39
+ if (configured) {
40
+ return configured;
41
+ }
42
+ if (assistantSurface.settingsSurfaceRequiresWorkspace === true) {
43
+ return WORKSPACE_SETTINGS_UPDATE_POLICY;
44
+ }
45
+ return assistantSurface.settingsSurfaceRequiresConsoleOwner === true ? null : ASSISTANT_SETTINGS_UPDATE_POLICY;
46
+ }
47
+
23
48
  async function requireSettingsAccess(assistantSurface, context = {}, { mode = "read" } = {}) {
24
49
  requireAuth({ context }, {
25
50
  require: "authenticated"
26
51
  });
27
52
 
28
- if (assistantSurface?.settingsSurfaceRequiresWorkspace === true) {
29
- requireAuth(
30
- { context },
31
- mode === "write"
32
- ? {
33
- require: "all",
34
- permissions: ["workspace.settings.update"]
35
- }
36
- : {
37
- require: "any",
38
- permissions: ["workspace.settings.view", "workspace.settings.update"]
39
- }
40
- );
41
- return;
53
+ const updatePolicy = mode === "write" ? resolveSettingsUpdatePolicy(assistantSurface) : null;
54
+ if (updatePolicy) {
55
+ requireAuth({ context }, {
56
+ ...updatePolicy,
57
+ message: "You do not have permission to change the assistant's instructions.",
58
+ code: "assistant.runtime.settings.updateForbidden"
59
+ });
60
+ } else if (mode !== "write" && assistantSurface.settingsSurfaceRequiresWorkspace === true) {
61
+ requireAuth({ context }, {
62
+ require: "any",
63
+ permissions: ["workspace.settings.view", "workspace.settings.update"]
64
+ });
42
65
  }
43
66
 
44
- if (assistantSurface?.settingsSurfaceRequiresConsoleOwner !== true) {
67
+ if (assistantSurface.settingsSurfaceRequiresWorkspace === true || assistantSurface.settingsSurfaceRequiresConsoleOwner !== true) {
45
68
  return;
46
69
  }
47
70
 
@@ -1,11 +1,41 @@
1
1
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
2
- import { normalizeObject, normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { isRecord, normalizeObject, normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import {
4
4
  DEFAULT_AI_TIMEOUT_MS,
5
5
  normalizeOptionalHttpUrl,
6
6
  normalizeTimeoutMs
7
7
  } from "@jskit-ai/assistant-core/server";
8
8
 
9
+ const SETTINGS_UPDATE_REQUIRE_MODES = Object.freeze(["authenticated", "all", "any"]);
10
+
11
+ /**
12
+ * Who may change a surface's assistant settings, as configured: a permission, an auth policy
13
+ * (`{ require, permissions }`), or null for the surface's default.
14
+ * @param {unknown} value
15
+ * @param {string} targetSurfaceId
16
+ */
17
+ function normalizeSettingsUpdatePermission(value, targetSurfaceId) {
18
+ if (value === undefined) {
19
+ return null;
20
+ }
21
+ if (typeof value === "string" && normalizeText(value)) {
22
+ return Object.freeze({
23
+ require: "all",
24
+ permissions: Object.freeze([normalizeText(value)])
25
+ });
26
+ }
27
+ const permissions = isRecord(value) ? normalizeUniqueTextList(value.permissions) : [];
28
+ if (isRecord(value) && SETTINGS_UPDATE_REQUIRE_MODES.includes(value.require) && (value.require === "authenticated" || permissions.length > 0)) {
29
+ return Object.freeze({
30
+ require: value.require,
31
+ permissions: Object.freeze(permissions)
32
+ });
33
+ }
34
+ throw new TypeError(
35
+ `assistantServer.${targetSurfaceId}.settingsUpdatePermission must be a permission or { require: "authenticated" | "all" | "any", permissions }.`
36
+ );
37
+ }
38
+
9
39
  function resolveAssistantServerConfigMap(appConfig) {
10
40
  const source = normalizeObject(appConfig?.assistantServer);
11
41
  const resolved = {};
@@ -27,7 +57,8 @@ function resolveAssistantServerConfigMap(appConfig) {
27
57
  model: normalizeText(entry.model),
28
58
  timeoutMs: entry.timeoutMs,
29
59
  barredActionIds: Object.freeze(normalizeUniqueTextList(entry.barredActionIds, { acceptSingle: true })),
30
- toolSkipActionPrefixes: Object.freeze(normalizeUniqueTextList(entry.toolSkipActionPrefixes, { acceptSingle: true }))
60
+ toolSkipActionPrefixes: Object.freeze(normalizeUniqueTextList(entry.toolSkipActionPrefixes, { acceptSingle: true })),
61
+ settingsUpdatePermission: normalizeSettingsUpdatePermission(entry.settingsUpdatePermission, targetSurfaceId)
31
62
  });
32
63
  }
33
64
 
@@ -336,8 +336,9 @@ test("conversation routes keep authenticated ownership and reject unknown identi
336
336
  assert.equal(accepted.payload.status, "accepted");
337
337
  await f.dispatched[0].promise;
338
338
  const body = JSON.parse(f.requests[0].body);
339
- assert.match(body.messages[0].content, /^Answer briefly\.\n\nCurrent state, from the system\. /u, "The system prompt, then the engine's state note");
340
- assert.equal(body.messages.at(-1).content, "Use the host configuration.");
339
+ assert.equal(body.messages[0].content, "Answer briefly.", "The host's system prompt");
340
+ assert.equal(body.messages.at(-2).content, "Use the host configuration.");
341
+ assert.match(body.messages.at(-1).content, /^Current state, from the system\. /u, "The engine's state note follows the conversation");
341
342
  assert.equal((await f.invoke("GET")).payload.engine, "api");
342
343
  });
343
344
 
@@ -390,7 +391,7 @@ test("declared conversation data keeps the authored target without becoming auth
390
391
  const state = (await f.invoke("GET")).payload;
391
392
  assert.deepEqual(state.conversationLog[0].user.data, data);
392
393
  assert.equal(state.engine, "api");
393
- assert.match(JSON.parse(f.requests[0].body).messages[0].content, /^Answer briefly\.\n\nCurrent state, from the system\. /u);
394
+ assert.equal(JSON.parse(f.requests[0].body).messages[0].content, "Answer briefly.");
394
395
  const repeated = await f.invoke("POST", "/messages", { body: input });
395
396
  assert.equal(repeated.payload.duplicate, true);
396
397
  assert.equal(repeated.payload.turnId, accepted.payload.turnId);
@@ -51,7 +51,7 @@ test("assistant services compose with supplied repositories without database imp
51
51
  for (const [name, repository] of Object.entries(repositories)) {
52
52
  assert.equal(assistant.repositories[name], repository);
53
53
  }
54
- const options = { context: { actor: { id: 23 } } };
54
+ const options = { context: { actor: { id: 23 }, permissions: ["assistant.settings.update"] } };
55
55
  assert.equal(await assistant.services.config.getSettings({ targetSurfaceId: "home" }, options), record);
56
56
  const updated = await assistant.services.config.updateSettings(
57
57
  { targetSurfaceId: "home" }, { systemPrompt: "Updated prompt" }, options
@@ -41,7 +41,8 @@ test("assistant server config resolves per-surface AI config without app-level g
41
41
  model: "claude-config",
42
42
  timeoutMs: 45_000,
43
43
  barredActionIds: ["demo.admin.secret"],
44
- toolSkipActionPrefixes: ["demo.hidden."]
44
+ toolSkipActionPrefixes: ["demo.hidden."],
45
+ settingsUpdatePermission: null
45
46
  });
46
47
  assert.deepEqual(resolveAssistantAiConfig({ appConfig, env }, "admin"), {
47
48
  aiConfigPrefix: "ADMIN_ASSISTANT",
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createService } from "../src/server/services/assistantConfigService.js";
4
+
5
+ function createSettingsRows() {
6
+ const rows = new Map();
7
+ const key = ({ targetSurfaceId, workspaceId }) => `${targetSurfaceId}:${workspaceId ?? ""}`;
8
+ return Object.freeze({
9
+ async findByScope(scope) {
10
+ return rows.get(key(scope)) || null;
11
+ },
12
+ createDefaultRecord(scope) {
13
+ return { ...scope, scopeKey: key(scope), settings: { systemPrompt: "" } };
14
+ },
15
+ async upsertByScope({ patch, ...scope }) {
16
+ const record = { ...scope, scopeKey: key(scope), settings: { systemPrompt: patch.systemPrompt } };
17
+ rows.set(key(scope), record);
18
+ return record;
19
+ }
20
+ });
21
+ }
22
+
23
+ function createAppConfig(assistantServer) {
24
+ return {
25
+ surfaceDefinitions: {
26
+ home: { enabled: true, requiresWorkspace: false, accessPolicyId: "authenticated" },
27
+ ops: { enabled: true, requiresWorkspace: false, accessPolicyId: "authenticated" },
28
+ admin: { enabled: true, requiresWorkspace: true, accessPolicyId: "workspace_member" },
29
+ console: { enabled: true, requiresWorkspace: false, accessPolicyId: "console_owner" }
30
+ },
31
+ assistantSurfaces: {
32
+ home: { settingsSurfaceId: "home", configScope: "global" },
33
+ ops: { settingsSurfaceId: "console", configScope: "global" },
34
+ admin: { settingsSurfaceId: "admin", configScope: "workspace" }
35
+ },
36
+ assistantServer
37
+ };
38
+ }
39
+
40
+ function createSettings(assistantServer, { consoleOwnerIds = [] } = {}) {
41
+ return createService({
42
+ assistantConfigRepository: createSettingsRows(),
43
+ consoleService: {
44
+ async requireConsoleOwner(context) {
45
+ if (!consoleOwnerIds.includes(context.actor.id)) {
46
+ throw Object.assign(new Error("Console owner required."), { code: "console.ownerRequired" });
47
+ }
48
+ }
49
+ },
50
+ appConfig: createAppConfig(assistantServer),
51
+ workspaceScopeSupport: { resolveWorkspace: () => ({ id: "3" }) }
52
+ });
53
+ }
54
+
55
+ const employee = { actor: { id: "7" }, permissions: [] };
56
+
57
+ async function update(settings, targetSurfaceId, context) {
58
+ return settings.updateSettings({ targetSurfaceId }, { systemPrompt: `Set by ${context.actor.id}` }, { context });
59
+ }
60
+
61
+ test("an employee can read but not change the instructions of a global assistant", async () => {
62
+ const settings = createSettings({});
63
+ assert.equal((await settings.getSettings({ targetSurfaceId: "home" }, { context: employee })).settings.systemPrompt, "");
64
+ await assert.rejects(update(settings, "home", employee), {
65
+ statusCode: 403,
66
+ code: "assistant.runtime.settings.updateForbidden",
67
+ message: "You do not have permission to change the assistant's instructions."
68
+ });
69
+ const admin = { actor: { id: "1" }, permissions: ["assistant.settings.update"] };
70
+ assert.equal((await update(settings, "home", admin)).settings.systemPrompt, "Set by 1");
71
+ });
72
+
73
+ test("assistantServer.<surface>.settingsUpdatePermission names the permission that changes the instructions", async () => {
74
+ const settings = createSettings({ home: { settingsUpdatePermission: "app.admin" } });
75
+ await assert.rejects(update(settings, "home", { actor: { id: "7" }, permissions: ["assistant.settings.update"] }), {
76
+ code: "assistant.runtime.settings.updateForbidden"
77
+ });
78
+ assert.equal((await update(settings, "home", { actor: { id: "1" }, permissions: ["app.admin"] })).settings.systemPrompt, "Set by 1");
79
+
80
+ const open = createSettings({ home: { settingsUpdatePermission: { require: "authenticated" } } });
81
+ assert.equal((await update(open, "home", employee)).settings.systemPrompt, "Set by 7");
82
+ });
83
+
84
+ test("a workspace assistant still needs workspace.settings.update, and a console assistant its console owner", async () => {
85
+ const settings = createSettings({}, { consoleOwnerIds: ["1"] });
86
+ await assert.rejects(update(settings, "admin", { actor: { id: "7" }, permissions: ["workspace.settings.view"] }), {
87
+ code: "assistant.runtime.settings.updateForbidden"
88
+ });
89
+ assert.equal((await settings.getSettings({ targetSurfaceId: "admin" }, {
90
+ context: { actor: { id: "7" }, permissions: ["workspace.settings.view"] }
91
+ })).settings.systemPrompt, "");
92
+ assert.equal((await update(settings, "admin", { actor: { id: "1" }, permissions: ["workspace.settings.update"] })).workspaceId, "3");
93
+
94
+ await assert.rejects(update(settings, "ops", employee), { code: "console.ownerRequired" });
95
+ assert.equal((await update(settings, "ops", { actor: { id: "1" }, permissions: [] })).settings.systemPrompt, "Set by 1");
96
+ });
97
+
98
+ test("a malformed settingsUpdatePermission stops the settings service from starting", () => {
99
+ for (const settingsUpdatePermission of [false, "", { require: "all" }, { require: "none" }, ["app.admin"]]) {
100
+ assert.throws(() => createSettings({ home: { settingsUpdatePermission } }), {
101
+ message: /assistantServer\.home\.settingsUpdatePermission must be a permission/u
102
+ });
103
+ }
104
+ });
@@ -302,9 +302,12 @@ test("a reply that only announces work carries on once, and the state note gives
302
302
  const system = harness.completionRequests[0].messages[0];
303
303
  assert.equal(system.role, "system");
304
304
  assert.match(system.content, /use the current time in the system's state note/u);
305
- assert.match(system.content, /\n\nCurrent state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\./u,
306
- "The state note at the end of the system prompt tells the model the current time");
307
- assert.match(messageText(harness.completionRequests[1].messages.at(-1)), /^\(Check from the app: your reply ended by saying what you'd do next/u,
305
+ assert.doesNotMatch(system.content, /Current state, from the system/u, "The state note is not in the system prompt");
306
+ const note = harness.completionRequests[0].messages.at(-1);
307
+ assert.equal(note.role, "user");
308
+ assert.match(messageText(note), /^Current state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\./u,
309
+ "The state note after the conversation tells the model the current time");
310
+ assert.match(messageText(harness.completionRequests[1].messages.at(-2)), /^\(Check from the app: your reply ended by saying what you'd do next/u,
308
311
  "The reply that only announced work carries on once with the engine's check");
309
312
  assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
310
313
  assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
@@ -416,7 +419,7 @@ test("tool calls written as text never run, never become answer deltas, and the
416
419
  const harness = createHarness([response(), textCompletion("The search is complete.")]);
417
420
  await harness.run();
418
421
  assert.deepEqual(harness.executedTools, [], "A tool call written as text is not run");
419
- assert.match(messageText(harness.completionRequests[1].messages.at(-1)), /^\(Check from the app: your last message contained raw markup/u);
422
+ assert.match(messageText(harness.completionRequests[1].messages.at(-2)), /^\(Check from the app: your last message contained raw markup/u);
420
423
  assert.doesNotMatch(JSON.stringify(harness.streamEvents), /DSML|private reasoning|private arguments/u);
421
424
  assert.deepEqual(assistantMessages(harness.streamEvents), ["The search is complete."]);
422
425
  });
@@ -435,7 +438,7 @@ test("application-authorized attachments reach the model and are authorized agai
435
438
  await harness.run("Read this", { attachmentIds: ["file-one"] });
436
439
  assert.equal(calls.length, 1, "The new message's file is authorized before admission");
437
440
  assert.deepEqual(harness.transcriptMessages[0].metadata.attachments, [receipt]);
438
- assert.deepEqual(harness.completionRequests[0].messages.at(-1).content, [
441
+ assert.deepEqual(harness.completionRequests[0].messages.at(-2).content, [
439
442
  { type: "text", text: "Read this" }, { type: "text", text: "Authorized file bytes" }
440
443
  ]);
441
444
  await harness.run("What do they say?", { messageId: "message_2" });