claude-slack-channel-bots 0.7.1 → 0.7.2
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 +1 -1
- package/src/permission-click-handler.ts +185 -1
- package/src/permission-poller.ts +108 -2
- package/src/permission-trail.ts +234 -0
- package/src/server.ts +23 -4
package/package.json
CHANGED
|
@@ -23,12 +23,26 @@ import type { WebClient } from '@slack/web-api'
|
|
|
23
23
|
import { decideWithToken } from './agent-director-client.ts'
|
|
24
24
|
import { parsePermissionActionId, type PermissionDecision } from './permission-action-id.ts'
|
|
25
25
|
import { getLivePermission, markHandled } from './permission-poller.ts'
|
|
26
|
+
import { emitTrail as defaultEmitTrail } from './permission-trail.ts'
|
|
27
|
+
import type {
|
|
28
|
+
AdDecideResponseClass,
|
|
29
|
+
ClosureVerdictTag,
|
|
30
|
+
ParseFailureReason,
|
|
31
|
+
TrailEventBase,
|
|
32
|
+
} from './permission-trail.ts'
|
|
26
33
|
|
|
27
34
|
export interface ClickDeps {
|
|
28
35
|
/** Returns an AD Client whose `decide` method this handler will invoke. */
|
|
29
36
|
getClient: () => Pick<Client, 'decide'>
|
|
30
37
|
web: Pick<WebClient, 'chat'>
|
|
31
38
|
log?: (...args: unknown[]) => void
|
|
39
|
+
/**
|
|
40
|
+
* Trail emitter hook (SR-V). Defaults to `emitTrail` from
|
|
41
|
+
* `permission-trail.ts`. Tests override with a capture stub.
|
|
42
|
+
*/
|
|
43
|
+
emitTrail?: (
|
|
44
|
+
partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
|
|
45
|
+
) => void
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
function logDeps(deps: ClickDeps, ...args: unknown[]): void {
|
|
@@ -36,6 +50,34 @@ function logDeps(deps: ClickDeps, ...args: unknown[]): void {
|
|
|
36
50
|
else console.error(...args)
|
|
37
51
|
}
|
|
38
52
|
|
|
53
|
+
/**
|
|
54
|
+
* SR-V-2.7 call-side classification of an agent-director `decide` error.
|
|
55
|
+
* Mirrors the existing branches in `handlePermissionClick`'s catch so the
|
|
56
|
+
* trail's `result_class` is identical to the operational discriminator.
|
|
57
|
+
*/
|
|
58
|
+
function classifyAdDecideError(err: unknown): AdDecideResponseClass {
|
|
59
|
+
if (err instanceof ErrAlreadyDecided) return 'ErrAlreadyDecided'
|
|
60
|
+
if (err instanceof AgentDirectorError && err.errName === 'ErrInvalidFlags') return 'ErrInvalidFlags'
|
|
61
|
+
if (err instanceof AgentDirectorError && err.errName === 'ErrAmbiguousRequest') return 'ErrAmbiguousRequest'
|
|
62
|
+
return 'other'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** SR-V-2.5 Slack error class string (mirrors permission-poller.ts). */
|
|
66
|
+
function classifySlackError(err: unknown): string {
|
|
67
|
+
if (err !== null && typeof err === 'object') {
|
|
68
|
+
const data = (err as { data?: unknown }).data
|
|
69
|
+
if (data !== null && typeof data === 'object') {
|
|
70
|
+
const e = (data as { error?: unknown }).error
|
|
71
|
+
if (typeof e === 'string' && e.length > 0) return e
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (err instanceof Error) {
|
|
75
|
+
if (err.name === 'AbortError') return 'aborted'
|
|
76
|
+
return 'network_error'
|
|
77
|
+
}
|
|
78
|
+
return 'unknown_error'
|
|
79
|
+
}
|
|
80
|
+
|
|
39
81
|
// Match the poller's SR-2.4 terminal verdict text exactly so the click-handler
|
|
40
82
|
// render and the next-tick reconciliation render are byte-identical — no
|
|
41
83
|
// visible flicker.
|
|
@@ -51,6 +93,22 @@ function buildDecisionBlocks(decision: PermissionDecision): unknown[] {
|
|
|
51
93
|
]
|
|
52
94
|
}
|
|
53
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Inbound context captured from the Socket Mode interactive payload. Used by
|
|
98
|
+
* the SR-V-2.6 `cscb.click_handler.invoked` event so investigations can
|
|
99
|
+
* pivot by clicking user or by the Slack message being clicked against.
|
|
100
|
+
* Optional so unit-test call sites can omit it; the trail event records the
|
|
101
|
+
* fields as undefined/missing when not supplied (open envelope per SR-V-1).
|
|
102
|
+
*/
|
|
103
|
+
export interface ClickInteractionContext {
|
|
104
|
+
/** Inbound Slack channel id from the interactive payload. */
|
|
105
|
+
channel?: string
|
|
106
|
+
/** The clicked-message ts from the interactive payload. */
|
|
107
|
+
messageTs?: string
|
|
108
|
+
/** Clicking user's Slack id from the interactive payload. */
|
|
109
|
+
user?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
54
112
|
/**
|
|
55
113
|
* Handle a permission Block Kit click. Returns true when the action_id was a
|
|
56
114
|
* valid permission decision (the caller has already ack'd); false when the
|
|
@@ -59,23 +117,66 @@ function buildDecisionBlocks(decision: PermissionDecision): unknown[] {
|
|
|
59
117
|
export async function handlePermissionClick(
|
|
60
118
|
actionId: string,
|
|
61
119
|
deps: ClickDeps,
|
|
120
|
+
context: ClickInteractionContext = {},
|
|
62
121
|
): Promise<boolean> {
|
|
63
122
|
const parsed = parsePermissionActionId(actionId)
|
|
64
123
|
if (parsed === null) return false
|
|
65
124
|
|
|
66
125
|
const { decision, claudeInstanceId, requestToken } = parsed
|
|
126
|
+
const emit = deps.emitTrail ?? defaultEmitTrail
|
|
127
|
+
// SR-V-2.6: live_pending captures the click-time state. Read the entry
|
|
128
|
+
// here so the trail event records the value at handler entry, before any
|
|
129
|
+
// markHandled / drop later in this function (or in the next tick) mutates
|
|
130
|
+
// the map.
|
|
131
|
+
const earlyEntry = getLivePermission(claudeInstanceId, requestToken)
|
|
132
|
+
emit({
|
|
133
|
+
event: 'cscb.click_handler.invoked',
|
|
134
|
+
claude_instance_id: claudeInstanceId,
|
|
135
|
+
request_token: requestToken,
|
|
136
|
+
channel: context.channel ?? earlyEntry?.channelId,
|
|
137
|
+
message_ts: context.messageTs ?? earlyEntry?.messageTs,
|
|
138
|
+
user: context.user,
|
|
139
|
+
raw_action_id: actionId,
|
|
140
|
+
decision,
|
|
141
|
+
live_pending: earlyEntry !== undefined,
|
|
142
|
+
})
|
|
67
143
|
|
|
68
144
|
// SR-4.1, SR-4.2, SR-7.2: AD is the source of truth. Always call decide
|
|
69
145
|
// with the decoded request_token — including for stale clicks whose
|
|
70
146
|
// composite key is no longer in the pending map. This is the click's ONLY
|
|
71
147
|
// AD interaction (SR-4.3 retires the pre-decide get).
|
|
148
|
+
const decideEnvelope = {
|
|
149
|
+
event: 'cscb.ad_decide.attempted',
|
|
150
|
+
claude_instance_id: claudeInstanceId,
|
|
151
|
+
request_token: requestToken,
|
|
152
|
+
decision,
|
|
153
|
+
}
|
|
72
154
|
try {
|
|
73
155
|
await decideWithToken(deps.getClient(), {
|
|
74
156
|
claude_instance_id: claudeInstanceId,
|
|
75
157
|
decision,
|
|
76
158
|
request_token: requestToken,
|
|
77
159
|
})
|
|
160
|
+
// SR-V-2.7 call-side success emission.
|
|
161
|
+
emit({ ...decideEnvelope, result_class: 'ok' satisfies AdDecideResponseClass })
|
|
78
162
|
} catch (err) {
|
|
163
|
+
// SR-V-2.7 call-side failure emission. Classify against the same AD
|
|
164
|
+
// error names the existing branches discriminate on so the trail's
|
|
165
|
+
// result_class stays consistent with src/agent-director-errors.ts. The
|
|
166
|
+
// existing logDeps operational logging is preserved alongside the
|
|
167
|
+
// canonical trail event — the two serve different purposes (live
|
|
168
|
+
// stderr vs after-the-fact debugging).
|
|
169
|
+
const result_class: AdDecideResponseClass = classifyAdDecideError(err)
|
|
170
|
+
const emission: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown } = {
|
|
171
|
+
...decideEnvelope,
|
|
172
|
+
result_class,
|
|
173
|
+
}
|
|
174
|
+
if (result_class === 'other') {
|
|
175
|
+
const raw = err instanceof Error ? err.message : String(err)
|
|
176
|
+
emission['raw_error_message'] = raw
|
|
177
|
+
}
|
|
178
|
+
emit(emission)
|
|
179
|
+
|
|
79
180
|
// SR-4.4: ErrAlreadyDecided is silently swallowed — the next poller tick
|
|
80
181
|
// reconciles the operator-visible Slack rendering via SR-2.4 / SR-5.
|
|
81
182
|
if (err instanceof ErrAlreadyDecided) return true
|
|
@@ -104,16 +205,99 @@ export async function handlePermissionClick(
|
|
|
104
205
|
const text = decision === 'allow'
|
|
105
206
|
? '*Permission* — Allowed'
|
|
106
207
|
: '*Permission* — Denied by operator'
|
|
208
|
+
const blocks = buildDecisionBlocks(decision)
|
|
209
|
+
const verdictTag: ClosureVerdictTag = decision === 'allow'
|
|
210
|
+
? 'click_handler_allow'
|
|
211
|
+
: 'click_handler_deny'
|
|
212
|
+
const envelope = {
|
|
213
|
+
event: 'cscb.chat_update.attempted',
|
|
214
|
+
claude_instance_id: claudeInstanceId,
|
|
215
|
+
request_token: requestToken,
|
|
216
|
+
channel: entry.channelId,
|
|
217
|
+
message_ts: entry.messageTs,
|
|
218
|
+
text,
|
|
219
|
+
blocks,
|
|
220
|
+
verdict_tag: verdictTag,
|
|
221
|
+
triggered_by: 'click_handler' as const,
|
|
222
|
+
}
|
|
107
223
|
try {
|
|
108
224
|
await deps.web.chat.update({
|
|
109
225
|
channel: entry.channelId,
|
|
110
226
|
ts: entry.messageTs,
|
|
111
227
|
text,
|
|
112
|
-
blocks:
|
|
228
|
+
blocks: blocks as never,
|
|
113
229
|
})
|
|
114
230
|
markHandled(claudeInstanceId, requestToken)
|
|
231
|
+
emit({ ...envelope, ok: true })
|
|
115
232
|
} catch (err) {
|
|
233
|
+
// b.emk: failures land in BOTH server.log and the trail JSONL.
|
|
116
234
|
logDeps(deps, `[slack] permission-click: decision chat.update failed for ${claudeInstanceId}:`, err)
|
|
235
|
+
emit({ ...envelope, ok: false, error: classifySlackError(err) })
|
|
117
236
|
}
|
|
118
237
|
return true
|
|
119
238
|
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// SR-V-2.9 inbound block_actions emission
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Classify a `block_actions` action_id for the SR-V-2.9 trail event without
|
|
246
|
+
* mutating state. Exported so `src/server.ts`'s `socket.on('interactive')`
|
|
247
|
+
* handler stays a thin wiring layer and so this branch can be unit-tested
|
|
248
|
+
* in isolation (server.ts has module-load side effects and is not directly
|
|
249
|
+
* importable from tests).
|
|
250
|
+
*
|
|
251
|
+
* The decision split is intentional: a `perm_(allow|deny)_*` prefix
|
|
252
|
+
* indicates a CSCB permission button whose body failed to parse
|
|
253
|
+
* (`malformed_token`); anything else is foreign (`foreign_action_id`). Note
|
|
254
|
+
* that `stale_prompt` is NOT a parse-failure reason here — a stale click
|
|
255
|
+
* decodes fine and shows up as `cscb.click_handler.invoked{live_pending:false}`.
|
|
256
|
+
*/
|
|
257
|
+
const PERMISSION_BUTTON_PREFIX_RE = /^perm_(allow|deny)_/
|
|
258
|
+
|
|
259
|
+
export interface BlockActionTrailContext {
|
|
260
|
+
/** Inbound Slack channel id from the interactive payload. */
|
|
261
|
+
channel?: string
|
|
262
|
+
/** Clicked message ts from the interactive payload. */
|
|
263
|
+
messageTs?: string
|
|
264
|
+
/** Clicking user's Slack id. */
|
|
265
|
+
user?: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Emit one `cscb.block_action.received` event for an inbound `block_actions`
|
|
270
|
+
* action_id. Called once per action in the payload's `actions` array,
|
|
271
|
+
* regardless of whether the action_id decodes (SR-V-2.9 — the
|
|
272
|
+
* decode-failure case is the diagnostically critical surface for
|
|
273
|
+
* "I clicked Allow and nothing happened").
|
|
274
|
+
*/
|
|
275
|
+
export function emitBlockActionReceived(
|
|
276
|
+
actionId: string,
|
|
277
|
+
context: BlockActionTrailContext,
|
|
278
|
+
emit: (
|
|
279
|
+
partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
|
|
280
|
+
) => void = defaultEmitTrail,
|
|
281
|
+
): void {
|
|
282
|
+
const parsed = parsePermissionActionId(actionId)
|
|
283
|
+
const base = {
|
|
284
|
+
event: 'cscb.block_action.received',
|
|
285
|
+
channel: context.channel,
|
|
286
|
+
message_ts: context.messageTs,
|
|
287
|
+
user: context.user,
|
|
288
|
+
raw_action_id: actionId,
|
|
289
|
+
}
|
|
290
|
+
if (parsed !== null) {
|
|
291
|
+
emit({
|
|
292
|
+
...base,
|
|
293
|
+
claude_instance_id: parsed.claudeInstanceId,
|
|
294
|
+
request_token: parsed.requestToken,
|
|
295
|
+
decision: parsed.decision,
|
|
296
|
+
})
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
const reason: ParseFailureReason = PERMISSION_BUTTON_PREFIX_RE.test(actionId)
|
|
300
|
+
? 'malformed_token'
|
|
301
|
+
: 'foreign_action_id'
|
|
302
|
+
emit({ ...base, parse_failure_reason: reason })
|
|
303
|
+
}
|
package/src/permission-poller.ts
CHANGED
|
@@ -51,6 +51,12 @@ import type {
|
|
|
51
51
|
GetPermissionResult,
|
|
52
52
|
} from './agent-director-client.ts'
|
|
53
53
|
import { encodePermissionActionId } from './permission-action-id.ts'
|
|
54
|
+
import { emitTrail as defaultEmitTrail } from './permission-trail.ts'
|
|
55
|
+
import type {
|
|
56
|
+
ClosureVerdictTag,
|
|
57
|
+
RowDecisionAction,
|
|
58
|
+
TrailEventBase,
|
|
59
|
+
} from './permission-trail.ts'
|
|
54
60
|
|
|
55
61
|
// ---------------------------------------------------------------------------
|
|
56
62
|
// Wire-shape types (local — mirrors the `^0.6.0` wire)
|
|
@@ -136,6 +142,14 @@ export interface PollerDeps {
|
|
|
136
142
|
/** Hook factories so tests can stub timer scheduling. */
|
|
137
143
|
setInterval?: (cb: () => void, ms: number) => ReturnType<typeof setInterval>
|
|
138
144
|
clearInterval?: (handle: ReturnType<typeof setInterval>) => void
|
|
145
|
+
/**
|
|
146
|
+
* Trail emitter hook (SR-V). Defaults to `emitTrail` from
|
|
147
|
+
* `permission-trail.ts`. Tests override with a capture stub to assert
|
|
148
|
+
* emission shape without touching the on-disk JSONL.
|
|
149
|
+
*/
|
|
150
|
+
emitTrail?: (
|
|
151
|
+
partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
|
|
152
|
+
) => void
|
|
139
153
|
}
|
|
140
154
|
|
|
141
155
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +259,47 @@ function logViaDeps(deps: PollerDeps, ...args: unknown[]): void {
|
|
|
245
259
|
else console.error(...args)
|
|
246
260
|
}
|
|
247
261
|
|
|
262
|
+
/**
|
|
263
|
+
* SR-V-2.3 row-decision emitter. Builds a `cscb.poller.row_decision` event
|
|
264
|
+
* with the canonical envelope fields and the action identifier. `request_token`
|
|
265
|
+
* is omitted (not empty) when absent per SR-V-1.1.
|
|
266
|
+
*/
|
|
267
|
+
function emitRowDecision(
|
|
268
|
+
deps: PollerDeps,
|
|
269
|
+
action: RowDecisionAction,
|
|
270
|
+
claudeInstanceId: string,
|
|
271
|
+
requestToken: string | undefined,
|
|
272
|
+
): void {
|
|
273
|
+
const emit = deps.emitTrail ?? defaultEmitTrail
|
|
274
|
+
const event: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown } = {
|
|
275
|
+
event: 'cscb.poller.row_decision',
|
|
276
|
+
claude_instance_id: claudeInstanceId,
|
|
277
|
+
action,
|
|
278
|
+
}
|
|
279
|
+
if (requestToken !== undefined) event.request_token = requestToken
|
|
280
|
+
emit(event)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Map an unknown Slack error to a stable error-class string per SR-V-2.4.
|
|
285
|
+
* Slack platform errors expose `data.error` (e.g. `channel_not_found`); other
|
|
286
|
+
* errors collapse to short class labels.
|
|
287
|
+
*/
|
|
288
|
+
function classifySlackError(err: unknown): string {
|
|
289
|
+
if (err !== null && typeof err === 'object') {
|
|
290
|
+
const data = (err as { data?: unknown }).data
|
|
291
|
+
if (data !== null && typeof data === 'object') {
|
|
292
|
+
const e = (data as { error?: unknown }).error
|
|
293
|
+
if (typeof e === 'string' && e.length > 0) return e
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (err instanceof Error) {
|
|
297
|
+
if (err.name === 'AbortError') return 'aborted'
|
|
298
|
+
return 'network_error'
|
|
299
|
+
}
|
|
300
|
+
return 'unknown_error'
|
|
301
|
+
}
|
|
302
|
+
|
|
248
303
|
async function runTick(deps: PollerDeps): Promise<void> {
|
|
249
304
|
if (tickInFlight) {
|
|
250
305
|
skippedTicks++
|
|
@@ -284,13 +339,19 @@ async function runTick(deps: PollerDeps): Promise<void> {
|
|
|
284
339
|
if (got.permission_requests === null || got.permission_requests === undefined) {
|
|
285
340
|
logViaDeps(deps, `[slack] permission-poller: non-conforming open-rows response for ${row.claude_instance_id} — skipping`)
|
|
286
341
|
nonConformingInstanceIds.add(row.claude_instance_id)
|
|
342
|
+
// SR-V-2.3: request_token omitted (no row was readable) per SR-V-1.1.
|
|
343
|
+
emitRowDecision(deps, 'non_conforming_skipped', row.claude_instance_id, undefined)
|
|
287
344
|
continue
|
|
288
345
|
}
|
|
289
346
|
|
|
290
347
|
for (const perm of got.permission_requests) {
|
|
291
348
|
const key = makeCompositeKey(row.claude_instance_id, perm.request_token)
|
|
292
349
|
seenComposite.add(key)
|
|
293
|
-
if (livePermissions.has(key))
|
|
350
|
+
if (livePermissions.has(key)) {
|
|
351
|
+
emitRowDecision(deps, 'already_tracked', row.claude_instance_id, perm.request_token)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
emitRowDecision(deps, 'post_attempted', row.claude_instance_id, perm.request_token)
|
|
294
355
|
await postPermissionPrompt(deps, row, perm)
|
|
295
356
|
}
|
|
296
357
|
}
|
|
@@ -311,6 +372,7 @@ async function runTick(deps: PollerDeps): Promise<void> {
|
|
|
311
372
|
} catch (err) {
|
|
312
373
|
if (isErrPermissionRequestNotFound(err)) {
|
|
313
374
|
logViaDeps(deps, `[slack] permission-poller: get-permission not-found for ${entry.claudeInstanceId} token=${entry.requestToken} — generic deny + drop`)
|
|
375
|
+
emitRowDecision(deps, 'not_found_generic_deny', entry.claudeInstanceId, entry.requestToken)
|
|
314
376
|
await renderClosureUpdate(deps, entry, 'not_found')
|
|
315
377
|
dropPermission(entry.claudeInstanceId, entry.requestToken)
|
|
316
378
|
continue
|
|
@@ -318,6 +380,7 @@ async function runTick(deps: PollerDeps): Promise<void> {
|
|
|
318
380
|
const e = err instanceof AgentDirectorError ? err : null
|
|
319
381
|
logViaDeps(deps, `[slack] permission-poller: get-permission failed for ${entry.claudeInstanceId} token=${entry.requestToken}: ${e?.errName ?? String(err)}`)
|
|
320
382
|
// SR-2.4 transient retry: leave entry alive; next tick will retry.
|
|
383
|
+
emitRowDecision(deps, 'transient_retry', entry.claudeInstanceId, entry.requestToken)
|
|
321
384
|
continue
|
|
322
385
|
}
|
|
323
386
|
|
|
@@ -325,6 +388,7 @@ async function runTick(deps: PollerDeps): Promise<void> {
|
|
|
325
388
|
if (verdict === 'unknown') {
|
|
326
389
|
logViaDeps(deps, `[slack] permission-poller: unknown verdict for ${entry.claudeInstanceId} token=${entry.requestToken} decision=${info.decision} decision_reason=${String(info.decision_reason)} — fail-closed generic deny`)
|
|
327
390
|
}
|
|
391
|
+
emitRowDecision(deps, 'reconciled_closed', entry.claudeInstanceId, entry.requestToken)
|
|
328
392
|
await renderClosureUpdate(deps, entry, verdict)
|
|
329
393
|
dropPermission(entry.claudeInstanceId, entry.requestToken)
|
|
330
394
|
}
|
|
@@ -364,13 +428,27 @@ async function postPermissionPrompt(
|
|
|
364
428
|
permission.request_token,
|
|
365
429
|
)
|
|
366
430
|
|
|
431
|
+
const text = `🤖🛠️ permission request: ${permission.tool_name}`
|
|
432
|
+
const emit = deps.emitTrail ?? defaultEmitTrail
|
|
367
433
|
try {
|
|
368
434
|
const response = await deps.web.chat.postMessage({
|
|
369
435
|
channel: channelId,
|
|
370
|
-
text
|
|
436
|
+
text,
|
|
371
437
|
blocks: blocks as never,
|
|
372
438
|
})
|
|
373
439
|
const messageTs = (response as { ts?: string }).ts
|
|
440
|
+
// SR-V-2.4: emit on success (and on the no-ts edge case below) with the
|
|
441
|
+
// Slack-returned ts. Full text + blocks pass through verbatim (SR-V-3.1).
|
|
442
|
+
emit({
|
|
443
|
+
event: 'cscb.chat_post.attempted',
|
|
444
|
+
claude_instance_id: row.claude_instance_id,
|
|
445
|
+
request_token: permission.request_token,
|
|
446
|
+
channel: channelId,
|
|
447
|
+
text,
|
|
448
|
+
blocks,
|
|
449
|
+
ok: true,
|
|
450
|
+
slack_ts: messageTs,
|
|
451
|
+
})
|
|
374
452
|
if (!messageTs) {
|
|
375
453
|
logViaDeps(deps, `[slack] permission-poller: chat.postMessage returned no ts for ${row.claude_instance_id}`)
|
|
376
454
|
return
|
|
@@ -384,7 +462,20 @@ async function postPermissionPrompt(
|
|
|
384
462
|
handled: false,
|
|
385
463
|
})
|
|
386
464
|
} catch (err) {
|
|
465
|
+
// b.emk: failures land in BOTH server.log (real-time visibility) AND the
|
|
466
|
+
// trail JSONL (after-the-fact debugging). Success paths stay trail-only,
|
|
467
|
+
// preserving the SR-V-2.4 asymmetric-behavior fix.
|
|
387
468
|
logViaDeps(deps, `[slack] permission-poller: chat.postMessage failed for ${row.claude_instance_id}:`, err)
|
|
469
|
+
emit({
|
|
470
|
+
event: 'cscb.chat_post.attempted',
|
|
471
|
+
claude_instance_id: row.claude_instance_id,
|
|
472
|
+
request_token: permission.request_token,
|
|
473
|
+
channel: channelId,
|
|
474
|
+
text,
|
|
475
|
+
blocks,
|
|
476
|
+
ok: false,
|
|
477
|
+
error: classifySlackError(err),
|
|
478
|
+
})
|
|
388
479
|
}
|
|
389
480
|
}
|
|
390
481
|
|
|
@@ -472,6 +563,18 @@ async function renderClosureUpdate(
|
|
|
472
563
|
tag: VerdictTag,
|
|
473
564
|
): Promise<void> {
|
|
474
565
|
const { text, blocks } = buildVerdictRendering(tag)
|
|
566
|
+
const emit = deps.emitTrail ?? defaultEmitTrail
|
|
567
|
+
const envelope = {
|
|
568
|
+
event: 'cscb.chat_update.attempted',
|
|
569
|
+
claude_instance_id: entry.claudeInstanceId,
|
|
570
|
+
request_token: entry.requestToken,
|
|
571
|
+
channel: entry.channelId,
|
|
572
|
+
message_ts: entry.messageTs,
|
|
573
|
+
text,
|
|
574
|
+
blocks,
|
|
575
|
+
verdict_tag: tag as ClosureVerdictTag,
|
|
576
|
+
triggered_by: 'poller' as const,
|
|
577
|
+
}
|
|
475
578
|
try {
|
|
476
579
|
await deps.web.chat.update({
|
|
477
580
|
channel: entry.channelId,
|
|
@@ -479,8 +582,11 @@ async function renderClosureUpdate(
|
|
|
479
582
|
text,
|
|
480
583
|
blocks: blocks as never,
|
|
481
584
|
})
|
|
585
|
+
emit({ ...envelope, ok: true })
|
|
482
586
|
} catch (err) {
|
|
587
|
+
// b.emk: failures land in BOTH server.log and the trail JSONL.
|
|
483
588
|
logViaDeps(deps, `[slack] permission-poller: closure chat.update failed for ${entry.claudeInstanceId} token=${entry.requestToken} (verdict=${tag}):`, err)
|
|
589
|
+
emit({ ...envelope, ok: false, error: classifySlackError(err) })
|
|
484
590
|
}
|
|
485
591
|
}
|
|
486
592
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission-trail.ts — Append-only JSONL event store for the CSCB-side
|
|
3
|
+
* visibility trail of the AD↔CSCB tool-permission relay (SRD t1.cdb.4g).
|
|
4
|
+
*
|
|
5
|
+
* One JSON object per newline-terminated line in
|
|
6
|
+
* <SLACK_STATE_DIR>/permission-trail.jsonl
|
|
7
|
+
* (default: ~/.claude/channels/slack/permission-trail.jsonl).
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
* - TrailEvent — enforced line schema
|
|
11
|
+
* - emitTrail(partial) — auto-stamps `ts`, recommended call site API
|
|
12
|
+
* - emitTrailEvent(event) — raw, accepts caller-supplied `ts`
|
|
13
|
+
* - _resetTrailFdForTests() — test-only fd reset
|
|
14
|
+
*
|
|
15
|
+
* The emitter is the single enforcement point for SR-V-1 (correlation
|
|
16
|
+
* envelope), SR-V-3 (no truncation in the canonical store), and SR-V-4.5
|
|
17
|
+
* (RFC 3339 sub-second `ts`, stable hierarchical `event` identifier).
|
|
18
|
+
*
|
|
19
|
+
* SPDX-License-Identifier: MIT
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, openSync, writeSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Line schema
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Required envelope fields enforced by the type system on every emitted line.
|
|
32
|
+
* `event` is a stable hierarchical identifier in the `cscb.*` namespace
|
|
33
|
+
* (SR-V-4.5). `ts` is RFC 3339 with at least millisecond precision
|
|
34
|
+
* (SR-V-4.3, SR-V-4.5).
|
|
35
|
+
*/
|
|
36
|
+
export interface TrailEventBase {
|
|
37
|
+
ts: string
|
|
38
|
+
event: string
|
|
39
|
+
/** SR-V-1.1: AD-minted UUID, absent for events before token assignment (SR-V-1.2). */
|
|
40
|
+
request_token?: string
|
|
41
|
+
/** SR-V-1.2: present whenever known. */
|
|
42
|
+
claude_instance_id?: string
|
|
43
|
+
/** SR-V-1.3: Slack channel id for events that touch Slack. */
|
|
44
|
+
channel?: string
|
|
45
|
+
/** SR-V-1.3: Slack message ts for events that touch Slack. */
|
|
46
|
+
message_ts?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One emitted JSONL line. Extends the required envelope with an open index
|
|
51
|
+
* signature so per-event-class fields pass through verbatim (no truncation
|
|
52
|
+
* per SR-V-3.1) without a cast at the call site.
|
|
53
|
+
*/
|
|
54
|
+
export type TrailEvent = TrailEventBase & { [extra: string]: unknown }
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Per-event-class field types
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The six canonical row-decision identifiers used as the `action` field on
|
|
62
|
+
* `cscb.poller.row_decision` events (SR-V-2.3). Exported for type-narrowing
|
|
63
|
+
* at the emission site.
|
|
64
|
+
*
|
|
65
|
+
* The set is open to extension per SR-V-2.3: the `(string & {})` widening
|
|
66
|
+
* keeps existing literals discoverable in editor completions while still
|
|
67
|
+
* accepting new identifiers without a type change.
|
|
68
|
+
*/
|
|
69
|
+
export const ROW_DECISION_ACTIONS = [
|
|
70
|
+
'post_attempted',
|
|
71
|
+
'already_tracked',
|
|
72
|
+
'reconciled_closed',
|
|
73
|
+
'not_found_generic_deny',
|
|
74
|
+
'non_conforming_skipped',
|
|
75
|
+
'transient_retry',
|
|
76
|
+
] as const
|
|
77
|
+
|
|
78
|
+
export type CanonicalRowDecisionAction = (typeof ROW_DECISION_ACTIONS)[number]
|
|
79
|
+
|
|
80
|
+
/** Open string-literal union per SR-V-2.3 — canonical values plus any new identifier. */
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
82
|
+
export type RowDecisionAction = CanonicalRowDecisionAction | (string & {})
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The 8 canonical verdict tags carried on `cscb.chat_update.attempted` events
|
|
86
|
+
* (SR-V-2.5). 6 are emitted by the poller's `renderClosureUpdate`; 2 are
|
|
87
|
+
* emitted by the click handler's verdict-render path. Open to extension.
|
|
88
|
+
*/
|
|
89
|
+
export const CLOSURE_VERDICT_TAGS = [
|
|
90
|
+
'operator_allow',
|
|
91
|
+
'operator_deny',
|
|
92
|
+
'timeout',
|
|
93
|
+
'find_missing',
|
|
94
|
+
'unknown',
|
|
95
|
+
'not_found',
|
|
96
|
+
'click_handler_allow',
|
|
97
|
+
'click_handler_deny',
|
|
98
|
+
] as const
|
|
99
|
+
|
|
100
|
+
export type CanonicalClosureVerdictTag = (typeof CLOSURE_VERDICT_TAGS)[number]
|
|
101
|
+
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
103
|
+
export type ClosureVerdictTag = CanonicalClosureVerdictTag | (string & {})
|
|
104
|
+
|
|
105
|
+
/** Which surface triggered a closure `chat.update` (SR-V-2.5). Open to extension. */
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
107
|
+
export type TriggeredBy = 'poller' | 'click_handler' | (string & {})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stable reasons a `cscb.block_action.received` event records when the
|
|
111
|
+
* inbound `action_id` failed `parsePermissionActionId` (SR-V-2.9). Open to
|
|
112
|
+
* extension. `stale_prompt` is intentionally NOT in this set — stale clicks
|
|
113
|
+
* decode fine and show up as `cscb.click_handler.invoked{live_pending:false}`.
|
|
114
|
+
*/
|
|
115
|
+
export const PARSE_FAILURE_REASONS = [
|
|
116
|
+
'foreign_action_id',
|
|
117
|
+
'malformed_token',
|
|
118
|
+
] as const
|
|
119
|
+
|
|
120
|
+
export type CanonicalParseFailureReason = (typeof PARSE_FAILURE_REASONS)[number]
|
|
121
|
+
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
123
|
+
export type ParseFailureReason = CanonicalParseFailureReason | (string & {})
|
|
124
|
+
|
|
125
|
+
/** Decoded permission decision carried on inbound click events (SR-V-2.6 / SR-V-2.9). */
|
|
126
|
+
export type ClickDecision = 'allow' | 'deny'
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Classification of an agent-director `decide` response on the CSCB call
|
|
130
|
+
* side (SR-V-2.7). The four named error classes match the AD error
|
|
131
|
+
* identifiers in `src/agent-director-errors.ts`. `"other"` is the catch-all
|
|
132
|
+
* for any other thrown value; the emit additionally carries a
|
|
133
|
+
* `raw_error_message` string in that case. Open extension allowed.
|
|
134
|
+
*/
|
|
135
|
+
export const AD_DECIDE_RESPONSE_CLASSES = [
|
|
136
|
+
'ok',
|
|
137
|
+
'ErrAlreadyDecided',
|
|
138
|
+
'ErrInvalidFlags',
|
|
139
|
+
'ErrAmbiguousRequest',
|
|
140
|
+
'other',
|
|
141
|
+
] as const
|
|
142
|
+
|
|
143
|
+
export type CanonicalAdDecideResponseClass = (typeof AD_DECIDE_RESPONSE_CLASSES)[number]
|
|
144
|
+
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
146
|
+
export type AdDecideResponseClass = CanonicalAdDecideResponseClass | (string & {})
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// State-dir + path resolution
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const TRAIL_FILENAME = 'permission-trail.jsonl'
|
|
153
|
+
|
|
154
|
+
function resolveStateDir(): string {
|
|
155
|
+
const fromEnv = process.env['SLACK_STATE_DIR']
|
|
156
|
+
return fromEnv ? resolve(fromEnv) : join(homedir(), '.claude', 'channels', 'slack')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveTrailPath(): string {
|
|
160
|
+
return join(resolveStateDir(), TRAIL_FILENAME)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Lazy-open module-scoped fd (mirrors src/logging.ts)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
let fd: number | null = null
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Test-only: reset the module-scoped fd so a subsequent emit reopens the file
|
|
171
|
+
* (typically against a fresh `SLACK_STATE_DIR` temp directory). Mirrors the
|
|
172
|
+
* `resetClientForTests` pattern in `src/agent-director-client.ts`.
|
|
173
|
+
*
|
|
174
|
+
* @internal
|
|
175
|
+
*/
|
|
176
|
+
export function _resetTrailFdForTests(): void {
|
|
177
|
+
fd = null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Emission
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3,}Z$/
|
|
185
|
+
|
|
186
|
+
function isValidTs(ts: unknown): ts is string {
|
|
187
|
+
return typeof ts === 'string' && ts.trim() !== '' && ISO_TIMESTAMP_RE.test(ts)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Append one fully-formed `TrailEvent` to the trail file. Use this when the
|
|
192
|
+
* caller supplies its own `ts` (e.g. test fixtures replaying historical
|
|
193
|
+
* events). Most production call sites should prefer `emitTrail`.
|
|
194
|
+
*
|
|
195
|
+
* Defensive guard: if `ts` is missing, empty, or not RFC 3339 with ms
|
|
196
|
+
* precision, substitute `new Date().toISOString()` and log a `[slack]`
|
|
197
|
+
* warning. Silently corrupting a trail line is worse than logging.
|
|
198
|
+
*
|
|
199
|
+
* Never throws. Write failures are caught and logged to stderr with the
|
|
200
|
+
* `[slack]` prefix per engineering-guide convention.
|
|
201
|
+
*/
|
|
202
|
+
export function emitTrailEvent(event: TrailEvent): void {
|
|
203
|
+
let toWrite: TrailEvent = event
|
|
204
|
+
if (!isValidTs(event.ts)) {
|
|
205
|
+
const stamped = new Date().toISOString()
|
|
206
|
+
console.error(
|
|
207
|
+
`[slack] permission-trail: missing/invalid ts on event=${String(event.event)} — substituting ${stamped}`,
|
|
208
|
+
)
|
|
209
|
+
toWrite = { ...event, ts: stamped }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
if (fd === null) {
|
|
214
|
+
const path = resolveTrailPath()
|
|
215
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
216
|
+
fd = openSync(path, 'a')
|
|
217
|
+
}
|
|
218
|
+
const line = JSON.stringify(toWrite) + '\n'
|
|
219
|
+
writeSync(fd, line)
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error('[slack] permission-trail: write failed', err)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Recommended call-site API for Epics 2–5: stamps `ts` from
|
|
227
|
+
* `new Date().toISOString()` (RFC 3339 with ms precision) and delegates to
|
|
228
|
+
* `emitTrailEvent`. Callers pass everything except `ts`.
|
|
229
|
+
*/
|
|
230
|
+
export function emitTrail(
|
|
231
|
+
partial: Omit<TrailEventBase, 'ts'> & { [extra: string]: unknown },
|
|
232
|
+
): void {
|
|
233
|
+
emitTrailEvent({ ...partial, ts: new Date().toISOString() })
|
|
234
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -55,7 +55,10 @@ import {
|
|
|
55
55
|
import { cleanSession, getCozempicAvailable } from './cozempic.ts'
|
|
56
56
|
import { ErrSpawnNotFound } from 'agent-director'
|
|
57
57
|
import { getClient, closeClient } from './agent-director-client.ts'
|
|
58
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
emitBlockActionReceived,
|
|
60
|
+
handlePermissionClick,
|
|
61
|
+
} from './permission-click-handler.ts'
|
|
59
62
|
import { trustBootstrap } from './trust-bootstrap.ts'
|
|
60
63
|
import { startPermissionPoller, stopPermissionPoller } from './permission-poller.ts'
|
|
61
64
|
import {
|
|
@@ -734,12 +737,28 @@ socket.on('interactive', async (evt) => {
|
|
|
734
737
|
const { ack } = evt as { ack: () => Promise<void> }
|
|
735
738
|
const p = ((evt as any).body ?? (evt as any).payload ?? evt) as Record<string, unknown>
|
|
736
739
|
const actions = (Array.isArray(p['actions']) ? p['actions'] : []) as Array<{ action_id: string }>
|
|
740
|
+
// SR-V-2.6 / SR-V-2.9 envelope: pull the inbound channel / message ts /
|
|
741
|
+
// clicking user once per payload — all actions in this payload share them.
|
|
742
|
+
const channelId = ((p['channel'] as { id?: string } | undefined)?.id) ?? undefined
|
|
743
|
+
const messageTs = ((p['message'] as { ts?: string } | undefined)?.ts) ?? undefined
|
|
744
|
+
const userId = ((p['user'] as { id?: string } | undefined)?.id) ?? undefined
|
|
745
|
+
|
|
737
746
|
for (const action of actions) {
|
|
738
747
|
const actionId = action.action_id
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
748
|
+
// SR-V-2.9: emit cscb.block_action.received for every action regardless
|
|
749
|
+
// of whether handlePermissionClick decides to engage. Decode-failure
|
|
750
|
+
// cases are diagnostically critical for "I clicked and nothing happened".
|
|
751
|
+
emitBlockActionReceived(actionId, {
|
|
752
|
+
channel: channelId,
|
|
753
|
+
messageTs,
|
|
754
|
+
user: userId,
|
|
742
755
|
})
|
|
756
|
+
|
|
757
|
+
const handled = await handlePermissionClick(
|
|
758
|
+
actionId,
|
|
759
|
+
{ getClient, web },
|
|
760
|
+
{ channel: channelId, messageTs, user: userId },
|
|
761
|
+
)
|
|
743
762
|
if (handled) {
|
|
744
763
|
await ack()
|
|
745
764
|
return
|