@things-factory/ai-assistant 10.1.29 → 10.1.32

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": "@things-factory/ai-assistant",
3
- "version": "10.1.29",
3
+ "version": "10.1.32",
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.29",
33
- "@things-factory/auth-base": "^10.1.29",
32
+ "@things-factory/ai-client-base": "^10.1.31",
33
+ "@things-factory/auth-base": "^10.1.31",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/shell": "^10.1.29",
35
+ "@things-factory/shell": "^10.1.31",
36
36
  "dompurify": "^3.0.0",
37
37
  "graphql-scalars": "^1.22.0",
38
38
  "graphql-tag": "^2.12.6",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
47
+ "gitHead": "67b211e001da3a923e562cb048e4691bd961315c"
48
48
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Modes — what each conversation is, declared by the app server that owns the screen.
3
3
  *
4
- * Design: ADR-0061 decision 7 (`operato-twin/design/04-decisions.md`) and the AI standard's A2.
4
+ * Design: ADR-0061 decision 7 (`operato-application/adr/ADR-0061.md`) and the AI standard's A2.
5
5
  */
6
6
  export {
7
7
  registerAssistantMode,
@@ -22,10 +22,11 @@ beforeEach(() => {
22
22
  name: 'twin-ops',
23
23
  guidance: 'call summarizeStatus before describing the line',
24
24
  specs: [
25
- { kind: 'read', name: 'summarizeStatus', description: 'read the line', schema: {}, builder: async () => ({ line: 3, stopped: true }) },
25
+ { kind: 'read', name: 'summarizeStatus', doors: ['Query.twinReplay'], description: 'read the line', schema: {}, builder: async () => ({ line: 3, stopped: true }) },
26
26
  {
27
27
  kind: 'read',
28
28
  name: 'proposeAction',
29
+ doors: ['Query.twinReplay'],
29
30
  description: 'propose stopping the line',
30
31
  schema: {},
31
32
  builder: async () => ({ proposed: true, command: 'stop', instanceId: 'LINE-3', label: '3 라인 정지' })
@@ -16,8 +16,8 @@ beforeEach(() => {
16
16
  name: 'twin-ops',
17
17
  guidance: 'call summarizeStatus before describing the line',
18
18
  specs: [
19
- { kind: 'read', name: 'summarizeStatus', description: 'read the line', schema: {}, builder: async () => ({ stopped: true }) },
20
- { kind: 'write', name: 'stopLine', description: 'stop the line', schema: {}, builder: async () => ({ ok: true }) }
19
+ { kind: 'read', name: 'summarizeStatus', doors: ['Query.twinReplay'], description: 'read the line', schema: {}, builder: async () => ({ stopped: true }) },
20
+ { kind: 'write', name: 'stopLine', doors: ['Mutation.twinCommand'], description: 'stop the line', schema: {}, builder: async () => ({ ok: true }) }
21
21
  ]
22
22
  } as any)
23
23
  })
@@ -101,6 +101,8 @@ it('★ a proposal belongs to the turn that asked for it', async () => {
101
101
  {
102
102
  kind: 'read',
103
103
  name: 'proposeSceneEdit',
104
+ /* 저작 도구는 열린 씬만 본다 — 저장소에 닿지 않으므로 문이 없다. */
105
+ doors: [],
104
106
  description: 'propose placing a component',
105
107
  schema: {},
106
108
  builder: async () => ({ proposed: true, kind: 'scene-edit', label: 'place EQ-1', ops: [] })
@@ -131,6 +131,13 @@ export interface ConversationOptions {
131
131
  systemPrompt?: string
132
132
  /** Which registered categories this conversation may use. Omitted allows none. */
133
133
  toolCategories?: string[]
134
+ /**
135
+ * Which doors this script's speaker may pass (ADR-0071 decision 2). A tool is offered when the
136
+ * caller passes every door it names, so a script that says nothing here is written as someone who
137
+ * passes them all — the permission axis belongs to `tool-access`, not to a conversation script.
138
+ * Name the doors to write the other case: a person the tool is not offered to.
139
+ */
140
+ toolAccess?: (door: string) => Promise<boolean>
134
141
  /** Request state the tools receive (domain, user). */
135
142
  state?: any
136
143
  /** What the surface is looking at — reaches tools as `ctx.host`, never the model. */
@@ -263,6 +270,7 @@ export function conversation(options: ConversationOptions = {}) {
263
270
  messages: buildLlmHistory(rows, {}) as any,
264
271
  systemPrompt: options.systemPrompt ?? 'you are a test assistant',
265
272
  toolCategories: options.toolCategories,
273
+ toolAccess: options.toolAccess ?? (async () => true),
266
274
  hostContext: options.hostContext,
267
275
  state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
268
276
  requireToolOnFirstTurn: options.requireToolOnFirstTurn
@@ -0,0 +1,48 @@
1
+ /*
2
+ * What this guards: the harness an app uses to check its own modes asks about **modes**, not about
3
+ * permission.
4
+ *
5
+ * `runRegisteredMode` answers "which tools does this mode open". Since the gate moved onto `doors`
6
+ * (ADR-0071 decision 2), a tool that names a door is closed to a caller with no access check —
7
+ * which is right in a request and wrong in a conformance run: an app checking that its mode opens
8
+ * four tools would see three, and the one that vanishes is the one that reads real data.
9
+ *
10
+ * So the harness runs as someone who passes every door, and an app that wants the other case names
11
+ * it. The permission axis has its own tests (`tool-access.test.ts` · `tool-scope.test.ts`).
12
+ */
13
+ import { clearAssistantModes, registerAssistantMode } from '../assistant-mode/registry'
14
+ import { clearToolRegistry, registerToolCategory } from '@things-factory/ai-client-base'
15
+
16
+ import { runRegisteredMode } from './mode-conformance'
17
+
18
+ const client = {
19
+ chatWithTools: async () => ({ text: '한 턴', toolCalls: [], stopReason: 'end_turn' })
20
+ }
21
+
22
+ beforeEach(() => {
23
+ clearAssistantModes()
24
+ clearToolRegistry()
25
+ registerToolCategory({
26
+ name: 'twin-authoring',
27
+ guidance: 'read the scene before proposing an edit',
28
+ specs: [
29
+ /* 화면이 보낸 것을 되돌려 준다 — 저장소에 안 닿는다. */
30
+ { kind: 'read', name: 'getOpenScene', description: 'the open scene', schema: {}, doors: [], builder: () => ({}) },
31
+ /* 저장소를 읽는다 — 문을 적는다. 관문이 이 자리를 가른다. */
32
+ { kind: 'read', name: 'getTwinStructure', description: 'the twin structure', schema: {}, doors: ['Query.twinInstances'], builder: () => ({}) }
33
+ ]
34
+ } as any)
35
+ registerAssistantMode({ kind: 'twin.space', systemPrompt: 'you answer about one space', toolCategories: ['twin-authoring'] } as any)
36
+ })
37
+
38
+ it('★ a mode opens the tools that read real data too — a conformance run is not a caller without permission', async () => {
39
+ const result = await runRegisteredMode('twin.space', { client })
40
+
41
+ expect(result.offeredTools.sort()).toEqual(['getOpenScene', 'getTwinStructure'])
42
+ })
43
+
44
+ it('an app that wants to see the other case names it — the door the caller cannot pass', async () => {
45
+ const result = await runRegisteredMode('twin.space', { client, toolAccess: async () => false })
46
+
47
+ expect(result.offeredTools).toEqual(['getOpenScene'])
48
+ })
@@ -52,7 +52,14 @@ export async function runRegisteredMode(
52
52
  temperature: settings.temperature,
53
53
  maxTokens: settings.maxTokens,
54
54
  hostContext: options.hostContext,
55
- toolAccess: options.toolAccess,
55
+ /*
56
+ * 이 하네스가 묻는 것은 **모드**다 — 「이 모드가 무엇을 여는가」이지 「이 사람이 문을 지나는가」가
57
+ * 아니다. 관문이 `doors` 로 옮겨간 뒤(ADR-0071 결정 2) 검사를 안 넘기면 문을 적은 도구가 전부
58
+ * 닫히고, 그러면 앱의 적합성 시험에서 **실제 자료를 읽는 도구만** 조용히 사라진다. 그래서 기본은
59
+ * 모든 문을 지나는 사람이고, 다른 쪽을 보고 싶은 앱은 `toolAccess` 를 적어 그 경우를 쓴다.
60
+ * 권한 축은 자기 시험이 따로 본다(`tool-access.test.ts` · `tool-scope.test.ts`).
61
+ */
62
+ toolAccess: options.toolAccess ?? (async () => true),
56
63
  state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
57
64
  })
58
65
 
@@ -1,48 +1,96 @@
1
1
  /*
2
2
  * What this guards: a registered tool is asked the same question its GraphQL door asks.
3
3
  *
4
- * The tool declares `{ category, privilege }`; `@privilege` on the door runs `checkPermission`
4
+ * The tool names the door; AUTH's field table says what that door declares; `checkPermission` runs
5
5
  * with the request's user, domain, address safety and prohibited list. If this passed fewer of
6
6
  * those — say, dropped `unsafeIP` — the tool would be a softer way into the same door.
7
+ *
8
+ * `checkPermission` itself is fixed by auth-base's own tests (`permission-gate.test.ts`), including
9
+ * the two cases below: a domain owner passes a role-less door from a safe address, and from an
10
+ * unsafe one ownership counts for nothing and the role decides. What this file fixes is that the
11
+ * tool gate hands that function everything it needs to answer either way.
7
12
  */
8
13
  const mockCheckPermission = jest.fn()
9
- jest.mock('@things-factory/auth-base', () => ({ checkPermission: (...a: any[]) => mockCheckPermission(...a) }))
14
+ const mockPrivilegeOfField = jest.fn()
15
+ jest.mock('@things-factory/auth-base', () => ({
16
+ checkPermission: (...a: any[]) => mockCheckPermission(...a),
17
+ privilegeOfField: (...a: any[]) => mockPrivilegeOfField(...a)
18
+ }))
10
19
 
11
20
  import { toolAccessFor } from './tool-access'
12
21
 
13
22
  const state = {
14
23
  domain: { id: 'dom-1' },
15
24
  user: { id: 'u-1' },
16
- unsafeIP: true,
25
+ unsafeIP: false,
17
26
  prohibitedPrivileges: [{ category: 'board-import', privilege: 'mutation' }]
18
27
  }
19
28
 
20
- beforeEach(() => mockCheckPermission.mockReset())
29
+ /** What plant's five doors declare — `@privilege(category, privilege, domainOwnerGranted: true)`. */
30
+ const OWNER_GRANTED = { category: 'job-order', privilege: 'query', owner: true }
31
+
32
+ beforeEach(() => {
33
+ mockCheckPermission.mockReset()
34
+ mockPrivilegeOfField.mockReset()
35
+ mockPrivilegeOfField.mockReturnValue({ known: true, gate: OWNER_GRANTED })
36
+ })
21
37
 
22
- it('★ asks checkPermission with the declared privilege and everything the door would pass', async () => {
38
+ it('★ asks checkPermission with the door’s own declaration and everything the door would pass', async () => {
23
39
  mockCheckPermission.mockResolvedValue(true)
24
40
 
25
- const allowed = await toolAccessFor({ state })({ category: 'board-import', privilege: 'mutation', owner: true })
41
+ const allowed = await toolAccessFor({ state })('Query.jobOrders')
26
42
 
27
43
  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
- )
44
+ expect(mockPrivilegeOfField).toHaveBeenCalledWith('Query', 'jobOrders')
45
+ expect(mockCheckPermission).toHaveBeenCalledWith(OWNER_GRANTED, state.user, state.domain, false, state.prohibitedPrivileges)
46
+ })
47
+
48
+ /*
49
+ * Plant's five doors are all `domainOwnerGranted: true`, so moving the gate onto the doors opens
50
+ * those five tools to a domain owner holding no privilege row. That is the right answer — the owner
51
+ * already sees the same numbers on the screen — and it is right only because the address travels
52
+ * with it. From an unsafe address ownership counts for nothing and the role decides.
53
+ */
54
+ it('★ the owner-granted door gets the address too, so the answer can differ by where the caller sits', async () => {
55
+ mockCheckPermission.mockResolvedValue(true)
56
+ await toolAccessFor({ state })('Query.jobOrders')
57
+ expect(mockCheckPermission).toHaveBeenLastCalledWith(OWNER_GRANTED, state.user, state.domain, false, state.prohibitedPrivileges)
58
+
59
+ /* The same owner, the same door, an unsafe address: auth is asked again with that fact. */
60
+ mockCheckPermission.mockResolvedValue(false)
61
+ const fromUnsafe = await toolAccessFor({ state: { ...state, unsafeIP: true } })('Query.jobOrders')
62
+
63
+ expect(fromUnsafe).toBe(false)
64
+ expect(mockCheckPermission).toHaveBeenLastCalledWith(OWNER_GRANTED, state.user, state.domain, true, state.prohibitedPrivileges)
35
65
  })
36
66
 
37
67
  it('a refusal is a refusal', async () => {
38
68
  mockCheckPermission.mockResolvedValue(false)
39
- expect(await toolAccessFor({ state })({ category: 'board-import', privilege: 'query' })).toBe(false)
69
+ expect(await toolAccessFor({ state })('Query.jobOrders')).toBe(false)
70
+ })
71
+
72
+ it('★ a door with no declaration of its own opens as far as that door opens — auth is not asked', async () => {
73
+ mockPrivilegeOfField.mockReturnValue({ known: true, gate: null })
74
+
75
+ expect(await toolAccessFor({ state })('Query.twinReplay')).toBe(true)
76
+ expect(mockCheckPermission).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('★ a door the schema does not have is closed, not open — a typo hides a tool, it does not free it', async () => {
80
+ mockPrivilegeOfField.mockReturnValue({ known: false })
81
+
82
+ expect(await toolAccessFor({ state })('Query.twinRepaly')).toBe(false)
83
+ expect(mockCheckPermission).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('a door name that is not a root field passes nobody', async () => {
87
+ expect(await toolAccessFor({ state })('jobOrders')).toBe(false)
88
+ expect(mockPrivilegeOfField).not.toHaveBeenCalled()
40
89
  })
41
90
 
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)
91
+ it('★ a request with no user or no domain passes no door, and auth is not asked', async () => {
92
+ expect(await toolAccessFor({ state: { domain: state.domain } })('Query.jobOrders')).toBe(false)
93
+ expect(await toolAccessFor({ state: { user: state.user } })('Query.jobOrders')).toBe(false)
94
+ expect(await toolAccessFor(undefined)('Query.jobOrders')).toBe(false)
47
95
  expect(mockCheckPermission).not.toHaveBeenCalled()
48
96
  })
@@ -2,26 +2,35 @@
2
2
  * Who may use a registered tool — the request's answer to `ToolAccessCheck`.
3
3
  *
4
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.
5
+ * conversation doors hand it this. A tool names the GraphQL doors it reaches (ADR-0071 decision 2);
6
+ * this resolves each one against AUTH's field table (`privilegeOfField`, ADR-0072 decision 5) and
7
+ * asks `checkPermission` exactly what `@privilege` on that field asks — the same user, domain,
8
+ * address safety and prohibited list. Asking anything looser here would make the tool a second,
9
+ * softer way into that door.
10
+ *
11
+ * **The declaration is the door's, and it is read, not copied.** Until 2026-09-18 a tool carried its
12
+ * own `{ category, privilege }` and this asked that copy. A copy drifts from the door it mirrors and
13
+ * the looser of the two wins; the table removed the need for one.
14
+ *
15
+ * The three answers of the table are read exactly as the reader's gate reads them (`door-access.ts`)
16
+ * — an unknown field closes, a field with no declaration opens as far as that field opens.
9
17
  */
10
- import { checkPermission } from '@things-factory/auth-base'
11
- import type { ToolAccessCheck, ToolPrivilege } from '@things-factory/ai-client-base'
18
+ import { checkPermission, privilegeOfField } from '@things-factory/auth-base'
19
+ import type { ToolAccessCheck } from '@things-factory/ai-client-base'
20
+
21
+ import { doorCheck } from './chat-message/door-access.js'
12
22
 
13
23
  export function toolAccessFor(context: { state?: any } | undefined): ToolAccessCheck {
14
24
  const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
15
25
 
16
- return async (privilege: ToolPrivilege) => {
17
- /* No caller, no answer — a request without a user or domain holds no privilege. */
26
+ const passes = doorCheck({
27
+ lookup: privilegeOfField,
28
+ permits: gate => checkPermission(gate as any, user, domain, unsafeIP, prohibitedPrivileges)
29
+ })
30
+
31
+ return async (door: string) => {
32
+ /* No caller, no answer — a request without a user or domain passes no door. */
18
33
  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
- ))
34
+ return await passes(door)
26
35
  }
27
36
  }
@@ -1,14 +1,14 @@
1
1
  /*
2
- * At boot, say what registered but is not yet what the contracts require — tools with no privilege
3
- * (A1 stage one) and modes offering `/` sentences their tools cannot answer (ADR-0058 decision 3).
2
+ * At boot, say what registered but is not yet what the contracts require — tools that named no doors
3
+ * (ADR-0071 decision 2) and modes offering `/` sentences their tools cannot answer (ADR-0058
4
+ * decision 3).
4
5
  *
5
6
  * One window, because it is one question: what state is this deployment's AI in (standard A7-a).
6
7
  *
7
- * Registration no longer refuses a tool that changes something without declaring its privilege —
8
- * refusing would stop apps whose tools predate the field. The registry records each omission as a
9
- * code instead. This prints that list once, after every module has registered, grouped by code,
10
- * so the person starting the server sees which tools still need a declaration. When the list is
11
- * empty across the apps, registration becomes a refusal and this report goes away.
8
+ * A tool that names no doors still registers; refusing at registration would stop a whole app from
9
+ * booting over one omission. It is the **gate** that refuses it, tool by tool — such a tool is
10
+ * offered to no conversation (`resolveToolScope`). So this list is not a soft warning about
11
+ * something that still works: it names the tools that are not working, and why.
12
12
  */
13
13
  import { logger } from '@things-factory/env'
14
14
  import { getToolRegistryWarnings, type ToolRegistryWarning } from '@things-factory/ai-client-base'
@@ -22,8 +22,7 @@ export function formatToolRegistryWarnings(warnings: ReadonlyArray<ToolRegistryW
22
22
  for (const warning of warnings) {
23
23
  const tools = byCode.get(warning.code) ?? []
24
24
  /* 문 이름이 있으면 함께 적는다 — 「어느 문이 아직 선언이 없나」가 다음에 할 일이다. */
25
- const doors = warning.params.doors?.length ? ` → ${warning.params.doors.join(' + ')}` : ''
26
- tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})${doors}`)
25
+ tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})`)
27
26
  byCode.set(warning.code, tools)
28
27
  }
29
28
  return [...byCode].map(([code, tools]) => `[ai-assistant] ${code} ×${tools.length}: ${tools.join(', ')}`)