@things-factory/ai-assistant 10.1.25 → 10.1.26

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 (38) hide show
  1. package/client/components/assistant-chat.ts +3 -2
  2. package/client/components/assistant-mode.test.ts +18 -3
  3. package/client/components/assistant-mode.ts +12 -6
  4. package/dist-client/components/assistant-chat.js +3 -2
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/components/assistant-mode.d.ts +11 -5
  7. package/dist-client/components/assistant-mode.js +12 -6
  8. package/dist-client/components/assistant-mode.js.map +1 -1
  9. package/dist-client/components/assistant-mode.test.js +16 -3
  10. package/dist-client/components/assistant-mode.test.js.map +1 -1
  11. package/dist-client/tsconfig.tsbuildinfo +1 -0
  12. package/dist-server/index.d.ts +1 -0
  13. package/dist-server/index.js +1 -0
  14. package/dist-server/index.js.map +1 -1
  15. package/dist-server/service/assistant-chat-resolver.js +3 -1
  16. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  17. package/dist-server/service/index.d.ts +1 -0
  18. package/dist-server/service/index.js +1 -0
  19. package/dist-server/service/index.js.map +1 -1
  20. package/dist-server/service/tool-access.d.ts +4 -0
  21. package/dist-server/service/tool-access.js +23 -0
  22. package/dist-server/service/tool-access.js.map +1 -0
  23. package/dist-server/tool-registry-boot-report.d.ts +3 -0
  24. package/dist-server/tool-registry-boot-report.js +29 -0
  25. package/dist-server/tool-registry-boot-report.js.map +1 -0
  26. package/dist-server/tsconfig.tsbuildinfo +1 -0
  27. package/docs/session-controller.md +1 -1
  28. package/package.json +6 -5
  29. package/server/index.ts +1 -0
  30. package/server/service/assistant-chat-resolver.ts +3 -1
  31. package/server/service/index.ts +1 -0
  32. package/server/service/tool-access.test.ts +48 -0
  33. package/server/service/tool-access.ts +27 -0
  34. package/server/tool-registry-boot-report.test.ts +18 -0
  35. package/server/tool-registry-boot-report.ts +26 -0
  36. package/test/assistant-request-context.test.ts +2 -2
  37. package/test/assistant-session-controller.test.ts +0 -1
  38. package/test/assistant-session-toolbar.test.ts +2 -2
@@ -41,7 +41,7 @@ Shared chat accepts synchronous `contextProvider(): {systemPrompt?, hostContext?
41
41
 
42
42
  `autoAskRequestId` distinguishes repeated identical user requests; generate it per user submission, not per render. Without it, legacy text-based deduplication remains.
43
43
 
44
- `board-edit-patch` and `board-action-execute` include local `detail.requestBase = {version: 1, sessionId, hostContext, modelFingerprint}`. `assistantModelFingerprint` returns a deterministic JSON signature, not a security hash. Undefined models have no signature. Hosts must validate target identity and compare the current model signature before applying a proposal. This metadata is not persisted into restored history; missing metadata is not proof that a historical proposal is safe. The framework captures the evidence but does not implement product-specific application guards.
44
+ `scene-edit-patch` and `scene-action-execute` include local `detail.requestBase = {version: 1, sessionId, hostContext, modelFingerprint}`. `assistantModelFingerprint` returns a deterministic JSON signature, not a security hash. Undefined models have no signature. Hosts must validate target identity and compare the current model signature before applying a proposal. This metadata is not persisted into restored history; missing metadata is not proof that a historical proposal is safe. The framework captures the evidence but does not implement product-specific application guards.
45
45
 
46
46
  Figure browser verification after toolbar migration: selected old session, reset to session 3, observed sessions 1 and 2 retained; screenshot confirmed aligned controls. Models were not edited. Plant runtime validation is owned by its development task. Legacy Board forwarding has source-contract coverage, not full Board browser coverage.
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.25",
3
+ "version": "10.1.26",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -29,10 +29,10 @@
29
29
  "@operato/graphql": "^10.0.0",
30
30
  "@operato/i18n": "^10.0.0",
31
31
  "@operato/styles": "^10.0.0",
32
- "@things-factory/ai-client-base": "^10.1.25",
33
- "@things-factory/auth-base": "^10.1.24",
32
+ "@things-factory/ai-client-base": "^10.1.26",
33
+ "@things-factory/auth-base": "^10.1.26",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/shell": "^10.1.20",
35
+ "@things-factory/shell": "^10.1.26",
36
36
  "dompurify": "^3.0.0",
37
37
  "graphql-scalars": "^1.22.0",
38
38
  "graphql-tag": "^2.12.6",
@@ -43,5 +43,6 @@
43
43
  "devDependencies": {
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
- }
46
+ },
47
+ "gitHead": "151bda14d899442c6846532ddded1e2bfe23a2f5"
47
48
  }
package/server/index.ts CHANGED
@@ -26,3 +26,4 @@
26
26
  * 설계 정본은 `things-factory/docs/design/ai-modules.md`.
27
27
  */
28
28
  export * from './service/index.js'
29
+ import './tool-registry-boot-report.js'
@@ -37,6 +37,7 @@ import { buildLlmHistory } from './chat-message/llm-history.js'
37
37
  import { publishChatMessage } from './chat-message/chat-message-publish.js'
38
38
  import { publishSessionActivity } from './chat-session/session-activity-publish.js'
39
39
  import { activityPatch, autoSessionName } from './chat-session/session-inbox.js'
40
+ import { toolAccessFor } from './tool-access.js'
40
41
 
41
42
  /**
42
43
  * How many stored messages reach the model.
@@ -79,7 +80,7 @@ class AssistantChatInput {
79
80
  @Field(() => [String], {
80
81
  nullable: true,
81
82
  description:
82
- 'Registered tool categories this conversation may use (registerToolCategory names, e.g. ["figure-authoring"]). Omit to allow every registered category. An empty list allows none a plain conversation.'
83
+ 'Registered tool categories this conversation may use (registerToolCategory names, e.g. ["figure-authoring"]). Omitted or empty allows none — a plain conversation. A tool that declares a privilege is offered only to a caller who holds it.'
83
84
  })
84
85
  toolCategories?: string[]
85
86
 
@@ -292,6 +293,7 @@ export class AssistantChatResolver {
292
293
  messages: conversation as any,
293
294
  systemPrompt: input.systemPrompt,
294
295
  toolCategories: input.toolCategories,
296
+ toolAccess: toolAccessFor(context),
295
297
  hostContext: input.hostContext,
296
298
  state: context.state,
297
299
  model: input.model,
@@ -9,6 +9,7 @@ import { entities as ChatSessionParticipantEntities } from './chat-session-parti
9
9
  export * from './types.js'
10
10
  export * from './chat-session-resolver.js'
11
11
  export * from './assistant-chat-resolver.js'
12
+ export * from './tool-access.js'
12
13
  export * from './subscribe-auth.js'
13
14
  /* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
14
15
  export * from './chat-session/session-anchor.js'
@@ -0,0 +1,48 @@
1
+ /*
2
+ * What this guards: a registered tool is asked the same question its GraphQL door asks.
3
+ *
4
+ * The tool declares `{ category, privilege }`; `@privilege` on the door runs `checkPermission`
5
+ * with the request's user, domain, address safety and prohibited list. If this passed fewer of
6
+ * those — say, dropped `unsafeIP` — the tool would be a softer way into the same door.
7
+ */
8
+ const mockCheckPermission = jest.fn()
9
+ jest.mock('@things-factory/auth-base', () => ({ checkPermission: (...a: any[]) => mockCheckPermission(...a) }))
10
+
11
+ import { toolAccessFor } from './tool-access'
12
+
13
+ const state = {
14
+ domain: { id: 'dom-1' },
15
+ user: { id: 'u-1' },
16
+ unsafeIP: true,
17
+ prohibitedPrivileges: [{ category: 'board-import', privilege: 'mutation' }]
18
+ }
19
+
20
+ beforeEach(() => mockCheckPermission.mockReset())
21
+
22
+ it('★ asks checkPermission with the declared privilege and everything the door would pass', async () => {
23
+ mockCheckPermission.mockResolvedValue(true)
24
+
25
+ const allowed = await toolAccessFor({ state })({ category: 'board-import', privilege: 'mutation', owner: true })
26
+
27
+ expect(allowed).toBe(true)
28
+ expect(mockCheckPermission).toHaveBeenCalledWith(
29
+ { category: 'board-import', privilege: 'mutation', owner: true, super: undefined },
30
+ state.user,
31
+ state.domain,
32
+ true,
33
+ state.prohibitedPrivileges
34
+ )
35
+ })
36
+
37
+ it('a refusal is a refusal', async () => {
38
+ mockCheckPermission.mockResolvedValue(false)
39
+ expect(await toolAccessFor({ state })({ category: 'board-import', privilege: 'query' })).toBe(false)
40
+ })
41
+
42
+ it('★ a request with no user or no domain holds no privilege, and auth is not asked', async () => {
43
+ const privilege = { category: 'board-import', privilege: 'query' }
44
+ expect(await toolAccessFor({ state: { domain: state.domain } })(privilege)).toBe(false)
45
+ expect(await toolAccessFor({ state: { user: state.user } })(privilege)).toBe(false)
46
+ expect(await toolAccessFor(undefined)(privilege)).toBe(false)
47
+ expect(mockCheckPermission).not.toHaveBeenCalled()
48
+ })
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Who may use a registered tool — the request's answer to `ToolAccessCheck`.
3
+ *
4
+ * `ai-client-base` decides *which* tools a conversation may use but does not know auth, so the
5
+ * conversation doors hand it this. A tool declares the same `{ category, privilege }` its GraphQL
6
+ * door uses (A1, 2026-09-17), and this asks exactly what `@privilege` on that door would ask: the
7
+ * same `checkPermission`, with the request's user, domain, address safety and prohibited list.
8
+ * Asking anything looser here would make the tool a second, softer way into that door.
9
+ */
10
+ import { checkPermission } from '@things-factory/auth-base'
11
+ import type { ToolAccessCheck, ToolPrivilege } from '@things-factory/ai-client-base'
12
+
13
+ export function toolAccessFor(context: { state?: any } | undefined): ToolAccessCheck {
14
+ const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
15
+
16
+ return async (privilege: ToolPrivilege) => {
17
+ /* No caller, no answer — a request without a user or domain holds no privilege. */
18
+ if (!user || !domain) return false
19
+ return !!(await checkPermission(
20
+ { category: privilege.category, privilege: privilege.privilege, owner: privilege.owner, super: privilege.super },
21
+ user,
22
+ domain,
23
+ unsafeIP,
24
+ prohibitedPrivileges
25
+ ))
26
+ }
27
+ }
@@ -0,0 +1,18 @@
1
+ jest.mock('@things-factory/env', () => ({ logger: { warn: jest.fn() } }))
2
+
3
+ import { formatToolRegistryWarnings } from './tool-registry-boot-report'
4
+
5
+ it('★ groups the recorded omissions by code and names every tool', () => {
6
+ const lines = formatToolRegistryWarnings([
7
+ { code: 'tool-privilege-undeclared', params: { category: 'twin-authoring', tool: 'bindTwinEntity', kind: 'write' }, message: '' },
8
+ { code: 'tool-privilege-undeclared', params: { category: 'x-ops', tool: 'runThing', kind: 'external' }, message: '' }
9
+ ])
10
+
11
+ expect(lines).toEqual([
12
+ '[ai-assistant] tool-privilege-undeclared ×2: twin-authoring/bindTwinEntity(write), x-ops/runThing(external)'
13
+ ])
14
+ })
15
+
16
+ it('says nothing when every tool is declared', () => {
17
+ expect(formatToolRegistryWarnings([])).toEqual([])
18
+ })
@@ -0,0 +1,26 @@
1
+ /*
2
+ * At boot, say which registered tools are not yet what the tool contract requires (A1 stage one).
3
+ *
4
+ * Registration no longer refuses a tool that changes something without declaring its privilege —
5
+ * refusing would stop apps whose tools predate the field. The registry records each omission as a
6
+ * code instead. This prints that list once, after every module has registered, grouped by code,
7
+ * so the person starting the server sees which tools still need a declaration. When the list is
8
+ * empty across the apps, registration becomes a refusal and this report goes away.
9
+ */
10
+ import { logger } from '@things-factory/env'
11
+ import { getToolRegistryWarnings, type ToolRegistryWarning } from '@things-factory/ai-client-base'
12
+
13
+ /** One line per code: the code, how many, and which tools. Empty when there is nothing to report. */
14
+ export function formatToolRegistryWarnings(warnings: ReadonlyArray<ToolRegistryWarning>): string[] {
15
+ const byCode = new Map<string, string[]>()
16
+ for (const warning of warnings) {
17
+ const tools = byCode.get(warning.code) ?? []
18
+ tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})`)
19
+ byCode.set(warning.code, tools)
20
+ }
21
+ return [...byCode].map(([code, tools]) => `[ai-assistant] ${code} ×${tools.length}: ${tools.join(', ')}`)
22
+ }
23
+
24
+ process.on('bootstrap-module-start' as any, () => {
25
+ for (const line of formatToolRegistryWarnings(getToolRegistryWarnings())) logger.warn(line)
26
+ })
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
- import { test } from 'node:test'
3
2
  import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
4
  import { assistantAutoAskKey, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
5
5
 
6
6
  test('model signature ignores object key order but detects nested edits and array order', () => {
@@ -21,7 +21,7 @@ test('recent tabs retain the selected older conversation without mutating the in
21
21
  assert.deepEqual(assistantRecentSessions([], '', 5), [])
22
22
  })
23
23
  test('send captures current context before optimistic input and attaches request base to edit events', () => {
24
- const chat = readFileSync(new URL('../client/components/assistant-chat.ts', import.meta.url), 'utf8')
24
+ const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
25
25
  const send = chat.slice(chat.indexOf('private async send()'))
26
26
  assert.ok(send.indexOf('this.contextProvider?.()') < send.indexOf('// optimistic'))
27
27
  assert.match(send, /modelFingerprint: assistantModelFingerprint\(liveBoard\)/)
@@ -1,5 +1,4 @@
1
1
  import assert from 'node:assert/strict'
2
- import { test } from 'node:test'
3
2
  import { AssistantSessionController, type AssistantSession, type AssistantSessionTarget } from '../client/utils/assistant-session-controller.ts'
4
3
 
5
4
  const target = (id: string, scope = 'user:domain'): AssistantSessionTarget => ({ scope, anchorType: 'figure', anchorId: id })
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { readFileSync } from 'node:fs'
3
- import { test } from 'node:test'
4
- const read = (file: string) => readFileSync(new URL(file, import.meta.url), 'utf8')
3
+ import { join } from 'node:path'
4
+ const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
5
5
  const toolbar = read('../client/components/assistant-session-toolbar.ts')
6
6
  test('toolbar emits domain-neutral intent and never calls persistence APIs', () => {
7
7
  for (const event of ['assistant-session-select', 'assistant-session-reset', 'assistant-session-retry']) assert.ok(toolbar.includes(event))