@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.
- package/client/components/assistant-chat.ts +3 -2
- package/client/components/assistant-mode.test.ts +18 -3
- package/client/components/assistant-mode.ts +12 -6
- package/dist-client/components/assistant-chat.js +3 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +11 -5
- package/dist-client/components/assistant-mode.js +12 -6
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +16 -3
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +1 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +3 -1
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +1 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/tool-access.d.ts +4 -0
- package/dist-server/service/tool-access.js +23 -0
- package/dist-server/service/tool-access.js.map +1 -0
- package/dist-server/tool-registry-boot-report.d.ts +3 -0
- package/dist-server/tool-registry-boot-report.js +29 -0
- package/dist-server/tool-registry-boot-report.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/docs/session-controller.md +1 -1
- package/package.json +6 -5
- package/server/index.ts +1 -0
- package/server/service/assistant-chat-resolver.ts +3 -1
- package/server/service/index.ts +1 -0
- package/server/service/tool-access.test.ts +48 -0
- package/server/service/tool-access.ts +27 -0
- package/server/tool-registry-boot-report.test.ts +18 -0
- package/server/tool-registry-boot-report.ts +26 -0
- package/test/assistant-request-context.test.ts +2 -2
- package/test/assistant-session-controller.test.ts +0 -1
- 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
|
-
`
|
|
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.
|
|
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.
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
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.
|
|
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
|
@@ -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"]).
|
|
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,
|
package/server/service/index.ts
CHANGED
|
@@ -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(
|
|
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 {
|
|
4
|
-
const read = (file: string) => readFileSync(
|
|
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))
|