mtech-ai-local-runner 0.1.81 → 0.1.84
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/README.md +6 -0
- package/claude-mod/.claude-plugin/plugin.json +1 -1
- package/claude-mod/hooks/model.ts +54 -1
- package/claude-mod/hooks/register.tsx +125 -20
- package/claude-mod/types/index.d.ts +5 -2
- package/dist/src/asks/messenger.js +2 -3
- package/dist/src/cli.js +12 -4
- package/dist/src/daemon/runtime.js +20 -2
- package/dist/src/daemon/self-check-monitor.js +43 -0
- package/dist/src/gateway-client.js +9 -0
- package/dist/src/publication/git-snapshot.js +17 -10
- package/dist/src/registration/refusal-report.js +27 -0
- package/dist/src/safety/self-checks.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -293,6 +293,12 @@ The Claude integration also installs the Work Room pane, the mod `ai-gateway-wor
|
|
|
293
293
|
98da2635). A band above the prompt says how many Room shares and requests are waiting. Its button, or `/work-room`,
|
|
294
294
|
opens a pane beside the conversation. The pane lists what waits for you and the Rooms you are in, and it opens Room
|
|
295
295
|
threads. From the pane you can answer a request, reply in a thread, share an answer to a Room or mark messages read.
|
|
296
|
+
Opening a Room shows its threads with unread messages first and every unread message in them, and Mark all read marks
|
|
297
|
+
up to 100 of them at once. Clear hides a request, decision or review on this Mac only, with no Gateway call; Show
|
|
298
|
+
under "Cleared" lists them again with Restore.
|
|
299
|
+
After a Gateway change to a tool's arguments, refresh the AI Gateway tools list in Claude (Settings, Connectors, AI
|
|
300
|
+
Gateway) and start a new session: a session drops arguments its tools list does not declare. The pane says so when
|
|
301
|
+
its Room reads lose their arguments.
|
|
296
302
|
Each of these is sent only after a confirm row that names who will see it. The pane calls the AI Gateway only through
|
|
297
303
|
your own AI Gateway connector in Claude, and it never starts a model turn: its Open in chat button puts only ids in the
|
|
298
304
|
conversation. Without the connector the pane says so. The Claude mobile app draws no input field, so there the pane
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "name": "ai-gateway-work-room", "version": "1.
|
|
1
|
+
{ "name": "ai-gateway-work-room", "version": "1.3.0", "description": "Work Room shares and requests from colleagues beside the conversation, installed by mtech-ai-local-runner (Work Item 98da2635).", "types": "./types/index.d.ts" }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Counts, InboxItem, RoomActivity, RoomInfo, RoomMessage } from '../types'
|
|
1
|
+
import type { Counts, InboxItem, RoomActivity, RoomInfo, RoomMessage, RoomThread, ThreadRow } from '../types'
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
4
|
* Pure helpers for the Work Room pane (Work Item 98da2635): they read Gateway tool results and build the text the
|
|
@@ -82,6 +82,20 @@ export function parseInbox(payload: Record<string, unknown> | undefined): InboxI
|
|
|
82
82
|
.slice(0, 50)
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** `$.store` key prefix for a request, decision or review the person cleared on this Mac. */
|
|
86
|
+
export const CLEARED = 'cleared:'
|
|
87
|
+
|
|
88
|
+
/** The inbox's own page size: a shorter list is the whole inbox. */
|
|
89
|
+
export const INBOX_LIMIT = 50
|
|
90
|
+
|
|
91
|
+
export const clearedIds = (keys: string[]): string[] => keys.filter(key => key.startsWith(CLEARED)).map(key => key.slice(CLEARED.length))
|
|
92
|
+
|
|
93
|
+
export const visibleInbox = (items: InboxItem[], cleared: string[]): InboxItem[] => {
|
|
94
|
+
const hidden = new Set(cleared)
|
|
95
|
+
|
|
96
|
+
return items.filter(item => !hidden.has(item.id))
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
export function parseMessages(payload: Record<string, unknown> | undefined): RoomMessage[] | undefined {
|
|
86
100
|
if (!payload || !Array.isArray(payload.messages)) return undefined
|
|
87
101
|
|
|
@@ -107,6 +121,45 @@ export function parseMessages(payload: Record<string, unknown> | undefined): Roo
|
|
|
107
121
|
})
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
/** One page of `threads`; `offset` numbers the rows so later pages sort after earlier ones. */
|
|
125
|
+
export function parseThreads(payload: Record<string, unknown> | undefined, offset: number): { rows: ThreadRow[]; next: number | null } | undefined {
|
|
126
|
+
if (!payload || !Array.isArray(payload.threads)) return undefined
|
|
127
|
+
const rows = payload.threads
|
|
128
|
+
.map(record)
|
|
129
|
+
.filter((row): row is Record<string, unknown> => row !== undefined && typeof row.id === 'string')
|
|
130
|
+
.map((row, index) => ({ id: clean(row.id, 64), title: clean(row.title, 120), unread: num(row.unread_count), latest: num(row.latest_message_sequence), order: offset + index }))
|
|
131
|
+
|
|
132
|
+
return { rows, next: typeof payload.next_cursor === 'number' ? payload.next_cursor : null }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const newestFirst = (a: ThreadRow, b: ThreadRow): number => b.latest - a.latest || b.order - a.order
|
|
136
|
+
|
|
137
|
+
/** Every thread with unread (at most 20), newest first, then the newest others until 10 threads are shown. */
|
|
138
|
+
export function pickThreads(rows: ThreadRow[]): ThreadRow[] {
|
|
139
|
+
const unread = rows.filter(row => row.unread > 0).sort(newestFirst).slice(0, 20)
|
|
140
|
+
const rest = rows.filter(row => row.unread === 0).sort(newestFirst).slice(0, Math.max(0, 10 - unread.length))
|
|
141
|
+
|
|
142
|
+
return [...unread, ...rest]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function mergeMessages(a: RoomMessage[], b: RoomMessage[]): RoomMessage[] {
|
|
146
|
+
const byId = new Map<string, RoomMessage>()
|
|
147
|
+
for (const message of [...a, ...b]) byId.set(message.id, message)
|
|
148
|
+
|
|
149
|
+
return [...byId.values()].sort((x, y) => x.sequence - y.sequence)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Unread ids the Gateway already records as presented, in display order, at most 100 (one receipt call). */
|
|
153
|
+
export function markable(threads: RoomThread[]): string[] {
|
|
154
|
+
return threads
|
|
155
|
+
.flatMap(thread => {
|
|
156
|
+
const unread = new Set(thread.unreadIds)
|
|
157
|
+
|
|
158
|
+
return thread.messages.filter(m => unread.has(m.id) && m.presented && !m.acknowledged).map(m => m.id)
|
|
159
|
+
})
|
|
160
|
+
.slice(0, 100)
|
|
161
|
+
}
|
|
162
|
+
|
|
110
163
|
export function parseRoom(payload: Record<string, unknown> | undefined): RoomInfo | undefined {
|
|
111
164
|
const room = record(payload?.room)
|
|
112
165
|
if (!room || typeof room.id !== 'string') return undefined
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { atom, read, update } from 'claude-code'
|
|
2
2
|
import type { EngineInterface, Register } from 'claude-code'
|
|
3
3
|
|
|
4
|
-
import type { Counts, InboxItem, OpenRoom, Pending, RoomActivity, Status } from '../types'
|
|
4
|
+
import type { Counts, InboxItem, OpenRoom, Pending, RoomActivity, RoomMessage, Status, ThreadRow } from '../types'
|
|
5
5
|
import { callGateway } from './gateway'
|
|
6
|
-
import { bandText, chatPrompt, findPayload, parseActivity, parseInbox, parseMessages, parseRoom, writeKey } from './model'
|
|
6
|
+
import { CLEARED, INBOX_LIMIT, bandText, chatPrompt, clearedIds, findPayload, markable, mergeMessages, parseActivity, parseInbox, parseMessages, parseRoom, parseThreads, pickThreads, visibleInbox, writeKey } from './model'
|
|
7
7
|
|
|
8
8
|
/*
|
|
9
9
|
* The Work Room pane (Work Item 98da2635, spec 2026-10-30): Room shares and requests from colleagues beside the
|
|
@@ -21,6 +21,10 @@ export const PANE = 'work-room'
|
|
|
21
21
|
const TITLE = 'Work Room'
|
|
22
22
|
const REFRESH_MS = 60_000
|
|
23
23
|
const NO_CONNECTOR = 'This session has no AI Gateway Work Room tools. Connect AI Gateway in Claude, reconnect it with /mcp, or start a new session.'
|
|
24
|
+
// A session keeps the tools list its connector had when it started, and a call drops arguments that list does not
|
|
25
|
+
// declare. With a list from before unread_only and latest (Gateway a8df571a), a Room opens with its oldest messages.
|
|
26
|
+
const STALE = 'Refresh the AI Gateway tools list in Claude (Settings, Connectors, AI Gateway), then start a new session, so Rooms show their newest and unread messages.'
|
|
27
|
+
const NIL = '00000000-0000-4000-8000-000000000000'
|
|
24
28
|
|
|
25
29
|
const counts = atom({ plugin: 'ai-gateway-work-room', key: 'counts' } as const, { shares: 0, requests: 0 } as Counts)
|
|
26
30
|
const activity = atom({ plugin: 'ai-gateway-work-room', key: 'activity' } as const, [] as RoomActivity[])
|
|
@@ -28,6 +32,24 @@ const inbox = atom({ plugin: 'ai-gateway-work-room', key: 'inbox' } as const, []
|
|
|
28
32
|
const rooms = atom({ plugin: 'ai-gateway-work-room', key: 'rooms' } as const, {} as Record<string, OpenRoom>)
|
|
29
33
|
const pending = atom({ plugin: 'ai-gateway-work-room', key: 'pending' } as const, null as Pending)
|
|
30
34
|
const status = atom({ plugin: 'ai-gateway-work-room', key: 'status' } as const, { state: 'ok', detail: '' } as Status)
|
|
35
|
+
const cleared = atom({ plugin: 'ai-gateway-work-room', key: 'cleared' } as const, [] as string[])
|
|
36
|
+
const showCleared = atom({ plugin: 'ai-gateway-work-room', key: 'showCleared' } as const, false as boolean)
|
|
37
|
+
|
|
38
|
+
/** Whether this session's calls carry unread_only and latest to the Gateway; asked once per run. */
|
|
39
|
+
let readOptions: boolean | undefined
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The Gateway refuses unread_only with latest before it looks up the Room. A call that loses both arguments on the
|
|
43
|
+
* way gets "Work Room is unavailable." instead. Undefined when the answer says neither (a refusal, no connector).
|
|
44
|
+
*/
|
|
45
|
+
async function readOptionsReach($: EngineInterface): Promise<boolean | undefined> {
|
|
46
|
+
if (readOptions !== undefined) return readOptions
|
|
47
|
+
const probe = await callGateway($, 'work_room_read', { operation: 'messages', room_id: NIL, thread_id: NIL, unread_only: true, latest: true, limit: 1 })
|
|
48
|
+
if (probe.ok || probe.state !== 'error') return undefined
|
|
49
|
+
readOptions = probe.detail.includes('not both')
|
|
50
|
+
|
|
51
|
+
return readOptions
|
|
52
|
+
}
|
|
31
53
|
|
|
32
54
|
/** Messages already sent for presented in this run, so a redraw before the update lands does not report them twice. */
|
|
33
55
|
const presentedSent = new Set<string>()
|
|
@@ -41,6 +63,28 @@ async function fail($: EngineInterface, answer: { state: Status['state']; detail
|
|
|
41
63
|
await update($, status, () => ({ state: answer.state, detail: answer.state === 'no-connector' ? NO_CONNECTOR : answer.detail }))
|
|
42
64
|
}
|
|
43
65
|
|
|
66
|
+
/** The band's counts from what this session holds: Room unread, and requests not cleared on this Mac. */
|
|
67
|
+
async function recount($: EngineInterface): Promise<void> {
|
|
68
|
+
const rows = await read($, activity)
|
|
69
|
+
const items = visibleInbox(await read($, inbox), await read($, cleared))
|
|
70
|
+
await update($, counts, () => ({ shares: rows.reduce((sum, row) => sum + row.unread, 0), requests: items.length }))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads the cleared ids from the store. A complete inbox read drops ids it no longer lists (answered, decided or expired). */
|
|
74
|
+
async function syncCleared($: EngineInterface, items: InboxItem[] | undefined): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
let ids = clearedIds(await $.store.keys())
|
|
77
|
+
if (items !== undefined && items.length < INBOX_LIMIT) {
|
|
78
|
+
const open = new Set(items.map(item => item.id))
|
|
79
|
+
for (const id of ids.filter(id => !open.has(id))) await $.store.delete(`${CLEARED}${id}`)
|
|
80
|
+
ids = ids.filter(id => open.has(id))
|
|
81
|
+
}
|
|
82
|
+
await update($, cleared, () => ids)
|
|
83
|
+
} catch {
|
|
84
|
+
// An unreadable store keeps the ids this session already holds; the counts still show.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
44
88
|
/** Counts only: Room activity and open inbox items, never message bodies. Each half shows even when the other fails. */
|
|
45
89
|
export async function refreshCounts($: EngineInterface): Promise<void> {
|
|
46
90
|
const [roomAnswer, inboxAnswer] = await Promise.all([
|
|
@@ -48,31 +92,65 @@ export async function refreshCounts($: EngineInterface): Promise<void> {
|
|
|
48
92
|
callGateway($, 'work_inbox', { direction: 'to_me' }),
|
|
49
93
|
])
|
|
50
94
|
if (roomAnswer.ok) await update($, activity, () => parseActivity(findPayload(roomAnswer.payload, v => Array.isArray(v.rooms))) ?? [])
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await
|
|
95
|
+
const listed = inboxAnswer.ok ? parseInbox(findPayload(inboxAnswer.payload, v => Array.isArray(v.items))) : undefined
|
|
96
|
+
if (inboxAnswer.ok) await update($, inbox, () => listed ?? [])
|
|
97
|
+
await syncCleared($, listed)
|
|
98
|
+
await recount($)
|
|
55
99
|
if (!roomAnswer.ok) return fail($, roomAnswer)
|
|
56
100
|
if (!inboxAnswer.ok) return fail($, inboxAnswer)
|
|
57
|
-
|
|
101
|
+
const current = await readOptionsReach($)
|
|
102
|
+
await update($, status, () => (current === false ? { state: 'stale', detail: STALE } : { state: 'ok', detail: '' }))
|
|
58
103
|
}
|
|
59
104
|
|
|
60
|
-
/**
|
|
105
|
+
/** Clear: hides the item on this Mac only. No Gateway call; the item stays open there and on other Macs. */
|
|
106
|
+
export async function clearItem($: EngineInterface, item: InboxItem): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
await $.store.set(`${CLEARED}${item.id}`, item.kind)
|
|
109
|
+
} catch {
|
|
110
|
+
return fail($, { state: 'error', detail: 'Clear could not be saved on this Mac.' })
|
|
111
|
+
}
|
|
112
|
+
await update($, cleared, ids => (ids.includes(item.id) ? ids : [...ids, item.id]))
|
|
113
|
+
await recount($)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function restoreItem($: EngineInterface, id: string): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
await $.store.delete(`${CLEARED}${id}`)
|
|
119
|
+
} catch {
|
|
120
|
+
return fail($, { state: 'error', detail: 'Restore could not be saved on this Mac.' })
|
|
121
|
+
}
|
|
122
|
+
await update($, cleared, ids => ids.filter(other => other !== id))
|
|
123
|
+
await recount($)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Loads a Room the person opened: its threads with unread first, then the newest; per thread the newest 20 messages and every unread one (up to 100). */
|
|
61
127
|
export async function openRoom($: EngineInterface, roomId: string, consent: string): Promise<void> {
|
|
62
128
|
const shown = await callGateway($, 'work_room_read', { operation: 'show', room_id: roomId }, consent)
|
|
63
129
|
if (!shown.ok) return fail($, shown)
|
|
64
130
|
const info = parseRoom(findPayload(shown.payload, v => v.room !== undefined))
|
|
65
131
|
if (!info) return fail($, { state: 'error', detail: 'The Room is unavailable.' })
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
132
|
+
const rows: ThreadRow[] = []
|
|
133
|
+
let cursor: number | null = 0
|
|
134
|
+
for (let page = 0; page < 5 && cursor !== null; page++) {
|
|
135
|
+
const listed = await callGateway($, 'work_room_read', { operation: 'threads', room_id: roomId, after_sequence: cursor, limit: 100 }, consent)
|
|
136
|
+
if (!listed.ok) return fail($, listed)
|
|
137
|
+
const parsed = parseThreads(findPayload(listed.payload, v => Array.isArray(v.threads)), rows.length)
|
|
138
|
+
if (!parsed) break
|
|
139
|
+
rows.push(...parsed.rows)
|
|
140
|
+
cursor = parsed.next
|
|
141
|
+
}
|
|
69
142
|
const threads: OpenRoom['threads'] = []
|
|
70
|
-
for (const row of
|
|
71
|
-
const
|
|
72
|
-
const latest = await callGateway($, 'work_room_read', { operation: 'messages', room_id: roomId, thread_id: id, limit: 20 }, consent)
|
|
143
|
+
for (const row of pickThreads(rows)) {
|
|
144
|
+
const latest = await callGateway($, 'work_room_read', { operation: 'messages', room_id: roomId, thread_id: row.id, latest: true, limit: 20 }, consent)
|
|
73
145
|
if (!latest.ok) return fail($, latest)
|
|
74
|
-
|
|
75
|
-
|
|
146
|
+
let unread: RoomMessage[] = []
|
|
147
|
+
if (row.unread > 0) {
|
|
148
|
+
const page = await callGateway($, 'work_room_read', { operation: 'messages', room_id: roomId, thread_id: row.id, unread_only: true, limit: 100 }, consent)
|
|
149
|
+
if (!page.ok) return fail($, page)
|
|
150
|
+
unread = parseMessages(findPayload(page.payload, v => Array.isArray(v.messages))) ?? []
|
|
151
|
+
}
|
|
152
|
+
const newest = parseMessages(findPayload(latest.payload, v => Array.isArray(v.messages))) ?? []
|
|
153
|
+
threads.push({ id: row.id, title: row.title, messages: mergeMessages(newest, unread), unreadIds: unread.map(m => m.id) })
|
|
76
154
|
}
|
|
77
155
|
await update($, rooms, all => ({ ...all, [roomId]: { info, threads } }))
|
|
78
156
|
}
|
|
@@ -183,6 +261,10 @@ export const register: Register = on => {
|
|
|
183
261
|
const state = await read($, status)
|
|
184
262
|
const job = await read($, pending)
|
|
185
263
|
const asks = await read($, inbox)
|
|
264
|
+
const hidden = await read($, cleared)
|
|
265
|
+
const showHidden = await read($, showCleared)
|
|
266
|
+
const visible = visibleInbox(asks, hidden)
|
|
267
|
+
const clearedItems = asks.filter(item => hidden.includes(item.id))
|
|
186
268
|
const rows = await read($, activity)
|
|
187
269
|
const open = await read($, rooms)
|
|
188
270
|
const toReport: Array<[string, string[]]> = []
|
|
@@ -198,8 +280,8 @@ export const register: Register = on => {
|
|
|
198
280
|
</Box>
|
|
199
281
|
)}
|
|
200
282
|
<Text bold>Requests for me</Text>
|
|
201
|
-
{
|
|
202
|
-
{
|
|
283
|
+
{visible.length === 0 && <Text dimColor>None.</Text>}
|
|
284
|
+
{visible.map(item => (
|
|
203
285
|
<Box flexDirection="column">
|
|
204
286
|
<Text>[{item.kind}] {item.title} — {item.askedBy}</Text>
|
|
205
287
|
{item.kind === 'request' && Input && (
|
|
@@ -212,6 +294,19 @@ export const register: Register = on => {
|
|
|
212
294
|
/>
|
|
213
295
|
)}
|
|
214
296
|
<Button key={`chat-${item.id}`} label="Open in chat" onPress={() => quietly($.prompt.submit({ text: chatPrompt(item) }))} />
|
|
297
|
+
<Button key={`clear-${item.id}`} label="Clear" onPress={() => quietly(clearItem($, item))} />
|
|
298
|
+
</Box>
|
|
299
|
+
))}
|
|
300
|
+
{clearedItems.length > 0 && (
|
|
301
|
+
<Box>
|
|
302
|
+
<Text dimColor>Cleared ({clearedItems.length}) </Text>
|
|
303
|
+
<Button key="show-cleared" label={showHidden ? 'Hide' : 'Show'} onPress={() => quietly(update($, showCleared, shown => !shown))} />
|
|
304
|
+
</Box>
|
|
305
|
+
)}
|
|
306
|
+
{showHidden && clearedItems.map(item => (
|
|
307
|
+
<Box>
|
|
308
|
+
<Text dimColor>[{item.kind}] {item.title} — {item.askedBy} </Text>
|
|
309
|
+
<Button key={`restore-${item.id}`} label="Restore" onPress={() => quietly(restoreItem($, item.id))} />
|
|
215
310
|
</Box>
|
|
216
311
|
))}
|
|
217
312
|
<Text bold>Room shares</Text>
|
|
@@ -230,6 +325,16 @@ export const register: Register = on => {
|
|
|
230
325
|
onPress={() => quietly(openRoom($, row.roomId, `The user pressed Open on Room ${row.name} in the Work Room pane.`))}
|
|
231
326
|
/>
|
|
232
327
|
</Box>
|
|
328
|
+
{room && markable(room.threads).length > 0 && (
|
|
329
|
+
<Button
|
|
330
|
+
key={`ack-room-${row.roomId}`}
|
|
331
|
+
label="Mark all read"
|
|
332
|
+
onPress={() => {
|
|
333
|
+
const ids = markable(room.threads)
|
|
334
|
+
quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
)}
|
|
233
338
|
{room?.threads.map(thread => (
|
|
234
339
|
<Box flexDirection="column">
|
|
235
340
|
<Text dimColor># {thread.title}</Text>
|
|
@@ -245,12 +350,12 @@ export const register: Register = on => {
|
|
|
245
350
|
)}
|
|
246
351
|
</Box>
|
|
247
352
|
))}
|
|
248
|
-
{thread.
|
|
353
|
+
{markable([thread]).length > 0 && (
|
|
249
354
|
<Button
|
|
250
355
|
key={`ack-${thread.id}`}
|
|
251
356
|
label="Mark read"
|
|
252
357
|
onPress={() => {
|
|
253
|
-
const ids = thread
|
|
358
|
+
const ids = markable([thread])
|
|
254
359
|
quietly(stage($, { kind: 'acknowledge', target: `${row.roomId}|${thread.id}`, text: ids.join(','), summary: `Mark ${ids.length} message${ids.length === 1 ? '' : 's'} read?` }))
|
|
255
360
|
}}
|
|
256
361
|
/>
|
|
@@ -2,10 +2,11 @@ export type RoomActivity = { roomId: string; name: string; unread: number; lates
|
|
|
2
2
|
export type InboxItem = { kind: 'request' | 'decision' | 'review'; id: string; workItemId: string; askedBy: string; title: string; status: string; changedAt: number }
|
|
3
3
|
export type RoomMessage = { id: string; threadId: string; sequence: number; author: string; kind: string; body: string; createdAt: number; presented: boolean; acknowledged: boolean; requestId?: string }
|
|
4
4
|
export type RoomInfo = { id: string; name: string; membershipRevision: number; memberCount: number }
|
|
5
|
-
export type
|
|
5
|
+
export type ThreadRow = { id: string; title: string; unread: number; latest: number; order: number }
|
|
6
|
+
export type RoomThread = { id: string; title: string; messages: RoomMessage[]; unreadIds: string[] }
|
|
6
7
|
export type OpenRoom = { info: RoomInfo; threads: RoomThread[] }
|
|
7
8
|
export type Pending = { kind: 'answer' | 'post' | 'answer_share' | 'acknowledge'; target: string; text: string; summary: string } | null
|
|
8
|
-
export type Status = { state: 'ok' | 'no-connector' | 'refused' | 'error' | 'loading'; detail: string }
|
|
9
|
+
export type Status = { state: 'ok' | 'no-connector' | 'refused' | 'error' | 'loading' | 'stale'; detail: string }
|
|
9
10
|
export type Counts = { shares: number; requests: number }
|
|
10
11
|
|
|
11
12
|
declare module 'claude-code' {
|
|
@@ -17,6 +18,8 @@ declare module 'claude-code' {
|
|
|
17
18
|
rooms: Record<string, OpenRoom>
|
|
18
19
|
pending: Pending
|
|
19
20
|
status: Status
|
|
21
|
+
cleared: string[]
|
|
22
|
+
showCleared: boolean
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import { McpGatewayClient } from "../gateway-client.js";
|
|
4
3
|
import { Daemon } from "../daemon/daemon.js";
|
|
5
4
|
import { ProcessLock } from "../daemon/process-lock.js";
|
|
6
|
-
import { runnerDisplayName } from "../daemon/runtime.js";
|
|
5
|
+
import { runnerDeviceFingerprint, runnerDisplayName } from "../daemon/runtime.js";
|
|
7
6
|
import { SessionOpener, sessionOpenAdvertisement } from "../daemon/session-opener.js";
|
|
8
7
|
import { RunnerTransport } from "../transport/runner-transport.js";
|
|
9
8
|
import { ReverbSocket } from "../transport/reverb-socket.js";
|
|
@@ -108,7 +107,7 @@ export async function buildMessengerDaemon(input) {
|
|
|
108
107
|
const askInbox = await asks.advertisement();
|
|
109
108
|
const guidance = await guide.advertisement();
|
|
110
109
|
await gateway.heartbeatLocalRunner({
|
|
111
|
-
deviceFingerprint:
|
|
110
|
+
deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
|
|
112
111
|
displayName: runnerDisplayName(), packageVersion: packageVersion(), capabilities: [], mode: "messenger",
|
|
113
112
|
// Without an integration, session_open still goes out (clients only): the Gateway keeps the last one it stored, and would keep long-polling for asks.
|
|
114
113
|
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
package/dist/src/cli.js
CHANGED
|
@@ -25,8 +25,10 @@ import { createBrowserOpener } from "./auth/browser-opener.js";
|
|
|
25
25
|
import { FileLocalConfigStore, runnerMode } from "./registration/local-config.js";
|
|
26
26
|
import { LaunchdService } from "./platform/macos/launchd.js";
|
|
27
27
|
import { installStableRelease } from "./platform/macos/stable-release.js";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { packageVersion } from "./package-version.js";
|
|
29
|
+
import { reportRefusal } from "./registration/refusal-report.js";
|
|
30
|
+
import { selfChecksReport } from "./safety/self-checks.js";
|
|
31
|
+
import { buildDaemon, runnerDeviceFingerprint, runnerDisplayName } from "./daemon/runtime.js";
|
|
30
32
|
import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
|
|
31
33
|
import { verifyPackagedHook } from "./safety/hook-command.js";
|
|
32
34
|
import { access, mkdir, rm } from "node:fs/promises";
|
|
@@ -259,6 +261,8 @@ export async function main(argv, env, io = {}) {
|
|
|
259
261
|
const hook = await hookSelfTest(config.workspaceRoot);
|
|
260
262
|
if (!hook.enforcing) {
|
|
261
263
|
stdout(JSON.stringify({ ok: false, configured: false, hook: hookReport(hook), action: hookAction(hook) }));
|
|
264
|
+
await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "setup", packageVersion: packageVersion(),
|
|
265
|
+
checkedAt: Math.floor(Date.now() / 1000), tools: null, hook, refusalCode: "hook_not_enforcing" }) });
|
|
262
266
|
throw new Error(`The PreToolUse hook did not deny the probe call (hook_not_enforcing: ${hook.reason})`);
|
|
263
267
|
}
|
|
264
268
|
// A full runner whose job file tools cannot read here would take jobs and stop executor_blocked on every one
|
|
@@ -268,6 +272,8 @@ export async function main(argv, env, io = {}) {
|
|
|
268
272
|
if (tools.status !== "passed") {
|
|
269
273
|
const report = localToolsReport(tools);
|
|
270
274
|
stdout(JSON.stringify({ ok: false, configured: false, local_tools: report, action: report.action }));
|
|
275
|
+
await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "setup", packageVersion: packageVersion(),
|
|
276
|
+
checkedAt: Math.floor(Date.now() / 1000), tools, hook, refusalCode: "local_tools_failed" }) });
|
|
271
277
|
throw new Error(`The job file tools failed on this Mac (local_tools_failed: ${tools.tool ?? "setup"}: ${tools.detail ?? "unknown"})`);
|
|
272
278
|
}
|
|
273
279
|
}
|
|
@@ -285,8 +291,8 @@ export async function main(argv, env, io = {}) {
|
|
|
285
291
|
throw new Error("No usable authenticated runtime adapter is available. Run doctor for adapter diagnostics.");
|
|
286
292
|
}
|
|
287
293
|
const registered = await gateway.registerLocalRunner({
|
|
288
|
-
deviceFingerprint:
|
|
289
|
-
displayName: runnerDisplayName(), packageVersion:
|
|
294
|
+
deviceFingerprint: runnerDeviceFingerprint(config.gatewayMcpUrl),
|
|
295
|
+
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
290
296
|
capabilities: mode === "full" ? adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, ...adapterStatus(probe) })) : [],
|
|
291
297
|
mode,
|
|
292
298
|
});
|
|
@@ -418,6 +424,8 @@ export async function main(argv, env, io = {}) {
|
|
|
418
424
|
if (!hook.enforcing) {
|
|
419
425
|
stderr(JSON.stringify({ event: "hook_not_enforcing", reason: hook.reason,
|
|
420
426
|
...(hook.exitCode === null ? {} : { exit_code: hook.exitCode }), ...(hook.detail ? { detail: hook.detail } : {}) }));
|
|
427
|
+
await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "daemon_start", packageVersion: packageVersion(),
|
|
428
|
+
checkedAt: Math.floor(Date.now() / 1000), tools: null, hook, refusalCode: "hook_not_enforcing" }) });
|
|
421
429
|
return 1;
|
|
422
430
|
}
|
|
423
431
|
}
|
|
@@ -30,7 +30,9 @@ import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-disp
|
|
|
30
30
|
import { abortableDelay } from "../transport/abortable-delay.js";
|
|
31
31
|
import { sideLoops } from "../transport/side-loops.js";
|
|
32
32
|
import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
|
|
33
|
-
import { packagedHookCommandParts } from "../safety/hook-command.js";
|
|
33
|
+
import { packagedHookCommandParts, verifyPackagedHook } from "../safety/hook-command.js";
|
|
34
|
+
import { localToolSelfTest } from "../safety/local-tool-self-test.js";
|
|
35
|
+
import { SelfCheckMonitor } from "./self-check-monitor.js";
|
|
34
36
|
import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
|
|
35
37
|
import { AskRuntime, withAsks } from '../asks/runtime.js';
|
|
36
38
|
import { GuideRuntime, withGuide } from '../guide/runtime.js';
|
|
@@ -196,6 +198,15 @@ export async function buildDaemon(input) {
|
|
|
196
198
|
// Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
|
|
197
199
|
onArmed: () => { void heartbeats.tick(); transport?.requestSync("start"); },
|
|
198
200
|
});
|
|
201
|
+
// Work Item 2549b1b0: the file tools and hook checked with this daemon's node, sent in every heartbeat.
|
|
202
|
+
const selfChecks = new SelfCheckMonitor({
|
|
203
|
+
workspaceRoot: input.workspaceRoot,
|
|
204
|
+
localTools: input.selfChecks?.localTools ?? localToolSelfTest,
|
|
205
|
+
hook: input.selfChecks?.hook ?? verifyPackagedHook,
|
|
206
|
+
packageVersion: () => packageVersion(),
|
|
207
|
+
changed: () => { void heartbeats.tick(); },
|
|
208
|
+
...(input.selfChecks?.intervalMs ? { intervalMs: input.selfChecks.intervalMs } : {}),
|
|
209
|
+
});
|
|
199
210
|
const heartbeats = new HeartbeatScheduler({
|
|
200
211
|
intervalMs: 30_000,
|
|
201
212
|
snapshot: async () => {
|
|
@@ -259,13 +270,14 @@ export async function buildDaemon(input) {
|
|
|
259
270
|
const askInbox = await asks.advertisement();
|
|
260
271
|
const guidance = await guide.advertisement();
|
|
261
272
|
return {
|
|
262
|
-
deviceFingerprint:
|
|
273
|
+
deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
|
|
263
274
|
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
264
275
|
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode), poller.accepting),
|
|
265
276
|
mode: "full",
|
|
266
277
|
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
267
278
|
...(guidance ? { guidance } : {}),
|
|
268
279
|
localFix: localFixReport(),
|
|
280
|
+
...(selfChecks.report() ? { selfChecks: selfChecks.report() } : {}),
|
|
269
281
|
};
|
|
270
282
|
},
|
|
271
283
|
send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
|
|
@@ -340,6 +352,7 @@ export async function buildDaemon(input) {
|
|
|
340
352
|
});
|
|
341
353
|
transport = pushTransport;
|
|
342
354
|
const sessionOpens = sideLoops({ heartbeat: heartbeats, transport: pushTransport, onStop: () => stopping.abort() });
|
|
355
|
+
selfChecks.start();
|
|
343
356
|
const daemon = new Daemon({
|
|
344
357
|
lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
|
|
345
358
|
reconcile: async (stored) => {
|
|
@@ -358,6 +371,7 @@ export async function buildDaemon(input) {
|
|
|
358
371
|
// Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
|
|
359
372
|
// The Claude host always closes; a failing Claude close never masks a Codex close failure.
|
|
360
373
|
return { daemon, close: async () => {
|
|
374
|
+
selfChecks.stop();
|
|
361
375
|
daemon.stop();
|
|
362
376
|
try {
|
|
363
377
|
await production?.close();
|
|
@@ -370,6 +384,10 @@ export async function buildDaemon(input) {
|
|
|
370
384
|
} };
|
|
371
385
|
}
|
|
372
386
|
export function runnerDisplayName() { return `${hostname()} local AI`; }
|
|
387
|
+
/** The fingerprint setup registers and every heartbeat repeats; a refusal report must match it too (Work Item 2549b1b0). */
|
|
388
|
+
export function runnerDeviceFingerprint(gatewayMcpUrl) {
|
|
389
|
+
return createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${gatewayMcpUrl}`).digest("hex");
|
|
390
|
+
}
|
|
373
391
|
/** The execution blocker a full runner reports for every adapter while it takes no jobs. */
|
|
374
392
|
export const RUNNER_NOT_ACCEPTING_JOBS = "RUNNER_NOT_ACCEPTING_JOBS";
|
|
375
393
|
/*
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { selfChecksReport } from "../safety/self-checks.js";
|
|
2
|
+
export const SELF_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Runs the job file tool and hook self-checks with this daemon's own node (Work Item 2549b1b0): the node every job uses,
|
|
5
|
+
* which doctor's terminal node may not be. A check that throws counts as failed; nothing here stops job claims.
|
|
6
|
+
*/
|
|
7
|
+
export class SelfCheckMonitor {
|
|
8
|
+
deps;
|
|
9
|
+
latest;
|
|
10
|
+
timer;
|
|
11
|
+
stopped = false;
|
|
12
|
+
constructor(deps) {
|
|
13
|
+
this.deps = deps;
|
|
14
|
+
}
|
|
15
|
+
report() { return this.latest; }
|
|
16
|
+
start() {
|
|
17
|
+
const loop = () => {
|
|
18
|
+
if (this.stopped)
|
|
19
|
+
return;
|
|
20
|
+
void this.check().finally(() => {
|
|
21
|
+
if (!this.stopped)
|
|
22
|
+
this.timer = setTimeout(loop, this.deps.intervalMs ?? SELF_CHECK_INTERVAL_MS);
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
loop();
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
this.stopped = true;
|
|
29
|
+
if (this.timer)
|
|
30
|
+
clearTimeout(this.timer);
|
|
31
|
+
}
|
|
32
|
+
async check() {
|
|
33
|
+
const [tools, hook] = await Promise.all([
|
|
34
|
+
this.deps.localTools(this.deps.workspaceRoot).catch(() => ({ status: "failed", node: process.execPath })),
|
|
35
|
+
this.deps.hook(this.deps.workspaceRoot).catch(() => ({ enforcing: false, reason: "failed", exitCode: null })),
|
|
36
|
+
]);
|
|
37
|
+
if (this.stopped)
|
|
38
|
+
return;
|
|
39
|
+
this.latest = selfChecksReport({ source: "daemon", packageVersion: this.deps.packageVersion(),
|
|
40
|
+
checkedAt: Math.floor((this.deps.now?.() ?? Date.now()) / 1000), tools, hook });
|
|
41
|
+
this.deps.changed();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -568,9 +568,18 @@ export class McpGatewayClient {
|
|
|
568
568
|
...(input.sessionOpen ? { session_open: input.sessionOpen } : {}),
|
|
569
569
|
...(input.guidance ? { guidance: input.guidance } : {}),
|
|
570
570
|
...(input.localFix !== undefined ? { local_fix: input.localFix } : {}),
|
|
571
|
+
...(input.selfChecks ? { self_checks: input.selfChecks } : {}),
|
|
571
572
|
},
|
|
572
573
|
}, signal);
|
|
573
574
|
}
|
|
575
|
+
/** A refused setup or daemon start, reported once before the process exits (Work Item 2549b1b0). */
|
|
576
|
+
async reportRunnerChecks(input, signal) {
|
|
577
|
+
return this.callTool("local_agent_runner_check_report", {
|
|
578
|
+
runner_id: this.runnerId,
|
|
579
|
+
device_fingerprint: input.deviceFingerprint,
|
|
580
|
+
self_checks: input.selfChecks,
|
|
581
|
+
}, signal);
|
|
582
|
+
}
|
|
574
583
|
async waitForSessionOpens(waitSeconds = 25, signal) {
|
|
575
584
|
const result = await this.callTool("local_agent_session_opens_wait", {
|
|
576
585
|
runner_id: this.runnerId,
|
|
@@ -9,7 +9,8 @@ import { protocolHash } from './candidate.js';
|
|
|
9
9
|
import { deflateObject, RawGitObjects } from './raw-git-objects.js';
|
|
10
10
|
const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
|
|
11
11
|
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
12
|
-
const
|
|
12
|
+
const structural = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^\.git$/i.test(s));
|
|
13
|
+
const safe = (p) => structural(p) && p.split('/').every(s => !/^(?:\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
|
|
13
14
|
/** P1 branch shape only. Authority still requires exact frozen-fence equality. */
|
|
14
15
|
/** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
|
|
15
16
|
* file of the checkout, so callers bound candidate size on the changed files instead. */
|
|
@@ -89,8 +90,8 @@ export class GitSnapshotHost {
|
|
|
89
90
|
throw denied();
|
|
90
91
|
return result;
|
|
91
92
|
}
|
|
92
|
-
async currentFile(relative) {
|
|
93
|
-
if (!safe(relative))
|
|
93
|
+
async currentFile(relative, protectedBase = false) {
|
|
94
|
+
if (!(protectedBase ? structural(relative) : safe(relative)))
|
|
94
95
|
throw denied();
|
|
95
96
|
const absolute = path.join(this.options.root, relative);
|
|
96
97
|
let component = this.options.root;
|
|
@@ -148,7 +149,7 @@ export class GitSnapshotHost {
|
|
|
148
149
|
const o = this.options;
|
|
149
150
|
await this.head();
|
|
150
151
|
const head = o.baseCommitSha, branch = o.workingBranch;
|
|
151
|
-
const base = new Map(), gitlinks = new Map(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
152
|
+
const base = new Map(), gitlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
152
153
|
for (const line of baseListing.toString().split('\0').filter(Boolean)) {
|
|
153
154
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
154
155
|
if (link) {
|
|
@@ -158,16 +159,19 @@ export class GitSnapshotHost {
|
|
|
158
159
|
continue;
|
|
159
160
|
}
|
|
160
161
|
const match = /^(100644|100755) blob ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
161
|
-
if (!match || !
|
|
162
|
+
if (!match || !structural(match[3]))
|
|
162
163
|
throw denied();
|
|
163
164
|
base.set(match[3], { mode: parseInt(match[1], 8), blob: match[2] });
|
|
165
|
+
if (!safe(match[3]))
|
|
166
|
+
protectedNames.add(match[3]);
|
|
164
167
|
}
|
|
168
|
+
// Protected names the base commit already tracks stay as exact base content; only a change to one is refused.
|
|
165
169
|
// Gitlinks remain exact base-tree references. Never traverse or copy their source.
|
|
166
170
|
// Staged replacement/deletion is refused rather than publishing hidden changes.
|
|
167
171
|
const staged = new Map(), stagedListing = await this.git(['ls-files', '--stage', '-z']);
|
|
168
172
|
for (const line of stagedListing.toString().split('\0').filter(Boolean)) {
|
|
169
173
|
const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
170
|
-
if (!entry || !
|
|
174
|
+
if (!entry || !structural(entry[3]))
|
|
171
175
|
throw denied();
|
|
172
176
|
staged.set(entry[3], entry[1] + ' ' + entry[2]);
|
|
173
177
|
}
|
|
@@ -184,7 +188,7 @@ export class GitSnapshotHost {
|
|
|
184
188
|
for (const name of names) {
|
|
185
189
|
if ([...gitlinks.keys()].some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
|
|
186
190
|
throw denied();
|
|
187
|
-
const current = await this.currentFile(name), prior = base.get(name);
|
|
191
|
+
const current = await this.currentFile(name, protectedNames.has(name)), prior = base.get(name);
|
|
188
192
|
let before = null;
|
|
189
193
|
if (prior) {
|
|
190
194
|
const blob = blobs.get(prior.blob);
|
|
@@ -199,11 +203,14 @@ export class GitSnapshotHost {
|
|
|
199
203
|
throw denied();
|
|
200
204
|
if (current)
|
|
201
205
|
files.push(current);
|
|
202
|
-
|
|
206
|
+
const changed = !prior || !current || !before.equals(current.bytes) || prior.mode !== current.mode;
|
|
207
|
+
if (changed && protectedNames.has(name))
|
|
208
|
+
throw denied();
|
|
209
|
+
if (changed)
|
|
203
210
|
changes.push({ path: name, status: !prior ? 'added' : !current ? 'deleted' : 'modified', before: before?.toString('base64') ?? null, after: current?.bytes.toString('base64') ?? null, before_mode: prior?.mode ?? null, after_mode: current?.mode ?? null });
|
|
204
211
|
}
|
|
205
212
|
const observation = { base_commit_sha: o.baseCommitSha, head_commit_sha: head, working_branch: branch, diff: JSON.stringify({ format: 'local-candidate-binary-diff-v1', changes }), changed_files: changes.map(({ path, status }) => ({ path, status })) };
|
|
206
|
-
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names };
|
|
213
|
+
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames };
|
|
207
214
|
return { observation, files, listing, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
|
|
208
215
|
}
|
|
209
216
|
/** The checkout still equals a capture when HEAD, the branch, the three Git listings and every
|
|
@@ -217,7 +224,7 @@ export class GitSnapshotHost {
|
|
|
217
224
|
const files = [];
|
|
218
225
|
let total = 0;
|
|
219
226
|
for (const name of listing.names) {
|
|
220
|
-
const current = await this.currentFile(name);
|
|
227
|
+
const current = await this.currentFile(name, listing.protectedNames.has(name));
|
|
221
228
|
if (!current)
|
|
222
229
|
continue;
|
|
223
230
|
total += current.bytes.length;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FileOAuthCredentialStore } from "../auth/credential-store.js";
|
|
2
|
+
import { OAuthClient } from "../auth/oauth-client.js";
|
|
3
|
+
import { OAuthTokenProvider } from "../auth/token-provider.js";
|
|
4
|
+
import { McpGatewayClient } from "../gateway-client.js";
|
|
5
|
+
import { FileLocalConfigStore } from "./local-config.js";
|
|
6
|
+
import { runnerDeviceFingerprint } from "../daemon/runtime.js";
|
|
7
|
+
const REPORT_TIMEOUT_MS = 5000;
|
|
8
|
+
/**
|
|
9
|
+
* Tell the Gateway once that setup or the daemon start refused (Work Item 2549b1b0), with the identity and login an
|
|
10
|
+
* earlier setup saved. A first install has neither, so it reports nothing. Advisory: it never throws and never waits
|
|
11
|
+
* more than 5 seconds, so the refusal's message and exit code stay as they were.
|
|
12
|
+
*/
|
|
13
|
+
export async function reportRefusal(input) {
|
|
14
|
+
try {
|
|
15
|
+
const local = await new FileLocalConfigStore(input.stateRoot).load();
|
|
16
|
+
if (!local)
|
|
17
|
+
return;
|
|
18
|
+
const tokenProvider = new OAuthTokenProvider({ store: new FileOAuthCredentialStore(input.stateRoot),
|
|
19
|
+
oauthClient: new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn: input.fetchFn }), resource: local.gatewayMcpUrl,
|
|
20
|
+
tokenOverride: input.env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, clock: () => Date.now() });
|
|
21
|
+
const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn: input.fetchFn });
|
|
22
|
+
await gateway.reportRunnerChecks({ deviceFingerprint: runnerDeviceFingerprint(local.gatewayMcpUrl), selfChecks: input.report }, AbortSignal.timeout(REPORT_TIMEOUT_MS));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// The refusal itself is what the employee must act on; the report is only for the owner.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A hint for the reader; local_tools.status is the signal. */
|
|
2
|
+
export function nodeKind(execPath) {
|
|
3
|
+
if (execPath.includes("/opt/homebrew/") || execPath.includes("/Cellar/"))
|
|
4
|
+
return "homebrew";
|
|
5
|
+
if (execPath.includes("/.nvm/"))
|
|
6
|
+
return "nvm";
|
|
7
|
+
if (execPath === "/usr/local/bin/node")
|
|
8
|
+
return "official";
|
|
9
|
+
return "other";
|
|
10
|
+
}
|
|
11
|
+
export function selfChecksReport(input) {
|
|
12
|
+
const tools = input.tools;
|
|
13
|
+
return {
|
|
14
|
+
version: 1,
|
|
15
|
+
source: input.source,
|
|
16
|
+
package_version: input.packageVersion,
|
|
17
|
+
checked_at: input.checkedAt,
|
|
18
|
+
local_tools: tools === null ? null : {
|
|
19
|
+
status: tools.status,
|
|
20
|
+
...(tools.status === "failed" && tools.tool ? { tool: tools.tool } : {}),
|
|
21
|
+
...(tools.sandbox?.reason ? { sandbox_reason: tools.sandbox.reason } : {}),
|
|
22
|
+
node_kind: nodeKind(tools.node),
|
|
23
|
+
node_version: input.nodeVersion ?? process.version,
|
|
24
|
+
},
|
|
25
|
+
hook: input.hook === null ? null : {
|
|
26
|
+
status: input.hook.enforcing ? "enforcing" : "hook_not_enforcing",
|
|
27
|
+
...(input.hook.enforcing ? {} : { reason: input.hook.reason }),
|
|
28
|
+
},
|
|
29
|
+
...(input.refusalCode ? { refusal_code: input.refusalCode } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|