switchroom 0.15.36 โ 0.15.38
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/dist/agent-scheduler/index.js +10 -9
- package/dist/auth-broker/index.js +9 -9
- package/dist/cli/autoaccept-poll.js +13 -7
- package/dist/cli/notion-write-pretool.mjs +9 -9
- package/dist/cli/switchroom.js +480 -217
- package/dist/cli/ui/index.html +87 -17
- package/dist/host-control/main.js +10 -10
- package/dist/vault/approvals/kernel-server.js +9 -9
- package/dist/vault/broker/server.js +9 -9
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +25 -0
- package/skills/switchroom-manage/SKILL.md +1 -1
- package/skills/switchroom-runtime/SKILL.md +1 -1
- package/telegram-plugin/answer-stream.ts +1 -1
- package/telegram-plugin/bridge/bridge.ts +50 -1
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/bridge/tool-filter.ts +77 -0
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/credits-watch.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +60 -3
- package/telegram-plugin/dist/gateway/gateway.js +753 -207
- package/telegram-plugin/dist/server.js +64 -4
- package/telegram-plugin/gateway/auto-classify-mid-turn.ts +1 -1
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +62 -0
- package/telegram-plugin/gateway/cron-session.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +254 -15
- package/telegram-plugin/gateway/grant-restart.ts +1 -1
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +1 -1
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +1 -1
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +1 -1
- package/telegram-plugin/gateway/interrupt-defer.ts +1 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +12 -0
- package/telegram-plugin/gateway/linear-activity.ts +56 -0
- package/telegram-plugin/gateway/linear-auth-watch.ts +102 -0
- package/telegram-plugin/gateway/linear-setup.ts +196 -0
- package/telegram-plugin/gateway/permission-card-origin.ts +62 -0
- package/telegram-plugin/gateway/permission-timeout.ts +70 -0
- package/telegram-plugin/gateway/prefix-warmup.ts +1 -1
- package/telegram-plugin/gateway/webhook-ingest-server.test.ts +1 -1
- package/telegram-plugin/gateway/webhook-ingest-server.ts +1 -1
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +1 -1
- package/telegram-plugin/interrupt-marker.ts +1 -1
- package/telegram-plugin/over-ping-safety-net.ts +1 -1
- package/telegram-plugin/scoped-approval.ts +1 -1
- package/telegram-plugin/secret-detect/vault-error.ts +1 -1
- package/telegram-plugin/silence-poke.ts +2 -2
- package/telegram-plugin/silent-reply-anchor.ts +1 -1
- package/telegram-plugin/slot-banner-driver.ts +1 -1
- package/telegram-plugin/startup-reset.ts +1 -1
- package/telegram-plugin/tests/boot-probes-connections.test.ts +66 -0
- package/telegram-plugin/tests/gateway-startup-reset.test.ts +1 -1
- package/telegram-plugin/tests/inbound-delivery-machine.test.ts +1 -1
- package/telegram-plugin/tests/linear-agent-activity.test.ts +77 -0
- package/telegram-plugin/tests/linear-agent-setup.test.ts +132 -0
- package/telegram-plugin/tests/linear-auth-watch.test.ts +79 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +3 -1
- package/telegram-plugin/tests/permission-card-origin.test.ts +97 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +23 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +76 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +87 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +1 -1
- package/telegram-plugin/tests/silence-poke.test.ts +1 -1
- package/telegram-plugin/tests/tool-filter.test.ts +87 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +1 -1
- package/telegram-plugin/turn-flush-safety.ts +1 -1
- package/telegram-plugin/uat/assertions.ts +1 -1
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-fast-ack-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-fast-trivial-dm.test.ts +2 -2
- package/telegram-plugin/uat/scenarios/jtbd-forwarded-burst-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-reflective-status-reaction-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-wake-audit-content-dm.test.ts +1 -1
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import {
|
|
4
4
|
emitLinearAgentActivity,
|
|
5
|
+
buildLinearAuthDeadMessage,
|
|
5
6
|
type LinearTokenResult,
|
|
6
7
|
} from '../gateway/linear-activity.js'
|
|
7
8
|
|
|
@@ -197,3 +198,79 @@ describe('linear_agent_activity โ auto-refresh on 401 (#2298 durability)', ()
|
|
|
197
198
|
expect(calls.length).toBe(1)
|
|
198
199
|
})
|
|
199
200
|
})
|
|
201
|
+
|
|
202
|
+
/** RefreshIO whose bundle is absent โ performLinearRefresh returns no_bundle
|
|
203
|
+
* (the silent-setup-failure case clerk/carrie hit in prod). */
|
|
204
|
+
function noBundleRefreshIO(): RefreshIO {
|
|
205
|
+
return { readBundle: async () => null, writeToken: async () => {}, writeBundle: async () => {} }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
describe('emitLinearAgentActivity โ operator alert when auth is unrecoverable (FIX 1)', () => {
|
|
209
|
+
it('no refresh bundle โ onAuthUnrecoverable(no_bundle) fires', async () => {
|
|
210
|
+
const { fetchImpl } = refreshAwareFetch()
|
|
211
|
+
const alerts: Array<{ agent: string; reason: string }> = []
|
|
212
|
+
const r = await emitLinearAgentActivity(
|
|
213
|
+
{ agent_session_id: 'sess', type: 'thought', body: 'hi' },
|
|
214
|
+
{
|
|
215
|
+
agent: 'clerk',
|
|
216
|
+
resolveToken: okToken('lin_expired'),
|
|
217
|
+
fetchImpl,
|
|
218
|
+
refreshIO: () => noBundleRefreshIO(),
|
|
219
|
+
log: () => {},
|
|
220
|
+
onAuthUnrecoverable: (i) => alerts.push(i),
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
224
|
+
expect(alerts).toEqual([{ agent: 'clerk', reason: 'no_bundle', detail: expect.any(String) }])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('revoked refresh token โ onAuthUnrecoverable(revoked) fires', async () => {
|
|
228
|
+
const { fetchImpl } = refreshAwareFetch({ tokenStatus: 400, tokenBody: 'invalid_grant' })
|
|
229
|
+
const alerts: Array<{ agent: string; reason: string }> = []
|
|
230
|
+
await emitLinearAgentActivity(
|
|
231
|
+
{ agent_session_id: 'sess', type: 'thought', body: 'hi' },
|
|
232
|
+
{ agent: 'carrie', resolveToken: okToken('lin_dead'), fetchImpl, refreshIO: () => fakeRefreshIO().io, log: () => {}, onAuthUnrecoverable: (i) => alerts.push(i) },
|
|
233
|
+
)
|
|
234
|
+
expect(alerts.map((a) => a.reason)).toEqual(['revoked'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('transient refresh failure (HTTP 500) does NOT page the operator', async () => {
|
|
238
|
+
const { fetchImpl } = refreshAwareFetch({ tokenStatus: 500, tokenBody: 'upstream boom' })
|
|
239
|
+
const alerts: unknown[] = []
|
|
240
|
+
await emitLinearAgentActivity(
|
|
241
|
+
{ agent_session_id: 'sess', type: 'thought', body: 'hi' },
|
|
242
|
+
{ agent: 'carrie', resolveToken: okToken('lin_x'), fetchImpl, refreshIO: () => fakeRefreshIO().io, log: () => {}, onAuthUnrecoverable: (i) => alerts.push(i) },
|
|
243
|
+
)
|
|
244
|
+
expect(alerts).toEqual([])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('buildLinearAuthDeadMessage: no_bundle names the missing oauth key + re-auth command', () => {
|
|
248
|
+
const msg = buildLinearAuthDeadMessage('clerk', 'no_bundle')
|
|
249
|
+
expect(msg).toMatch(/Linear auth needs you/)
|
|
250
|
+
expect(msg).toContain('<code>linear/clerk/oauth</code>')
|
|
251
|
+
expect(msg).toContain('switchroom linear-agent setup --agent clerk')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('buildLinearAuthDeadMessage: revoked says the refresh token was revoked', () => {
|
|
255
|
+
const msg = buildLinearAuthDeadMessage('carrie', 'revoked')
|
|
256
|
+
expect(msg).toMatch(/refresh token was revoked/)
|
|
257
|
+
expect(msg).not.toContain('/oauth</code> is missing')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('buildLinearAuthDeadMessage: HTML-escapes a hostile agent slug', () => {
|
|
261
|
+
const msg = buildLinearAuthDeadMessage('a<b>&c', 'no_bundle')
|
|
262
|
+
expect(msg).toContain('a<b>&c')
|
|
263
|
+
expect(msg).not.toContain('a<b>&c')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('successful auto-refresh does NOT page the operator', async () => {
|
|
267
|
+
const { fetchImpl } = refreshAwareFetch()
|
|
268
|
+
const alerts: unknown[] = []
|
|
269
|
+
const r = await emitLinearAgentActivity(
|
|
270
|
+
{ agent_session_id: 'sess', type: 'thought', body: 'hi' },
|
|
271
|
+
{ agent: 'carrie', resolveToken: okToken('lin_expired'), fetchImpl, refreshIO: () => fakeRefreshIO().io, log: () => {}, onAuthUnrecoverable: (i) => alerts.push(i) },
|
|
272
|
+
)
|
|
273
|
+
expect(r.content[0].text).toMatch(/emitted/)
|
|
274
|
+
expect(alerts).toEqual([])
|
|
275
|
+
})
|
|
276
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { runLinearAgentSetup } from '../gateway/linear-setup.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for the `linear_agent_setup` MCP tool (FIX 2 โ in-container,
|
|
7
|
+
* operator-approved Linear OAuth provisioning).
|
|
8
|
+
*
|
|
9
|
+
* Structural: assert the tool is declared in bridge.ts and allow-listed +
|
|
10
|
+
* dispatched in gateway.ts (the gateway IIFE can't be imported).
|
|
11
|
+
* Behavioural: the logic lives in gateway/linear-setup.ts with injected
|
|
12
|
+
* fetch + broker-put deps, so the OAuth exchange + write paths run offline.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type PutOutcome = { kind: 'ok' } | { kind: 'denied'; msg: string } | { kind: 'not_found'; msg: string } | { kind: 'unreachable'; msg: string }
|
|
16
|
+
|
|
17
|
+
function exchangeFetch(opts: { status?: number; body?: unknown } = {}): typeof fetch {
|
|
18
|
+
return (async () => {
|
|
19
|
+
const status = opts.status ?? 200
|
|
20
|
+
const body = opts.body ?? { access_token: 'lin_acc', refresh_token: 'lin_ref', expires_in: 86400, scope: 'read write' }
|
|
21
|
+
return {
|
|
22
|
+
ok: status >= 200 && status < 300,
|
|
23
|
+
status,
|
|
24
|
+
json: async () => body,
|
|
25
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
26
|
+
} as unknown as Response
|
|
27
|
+
}) as unknown as typeof fetch
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('linear_agent_setup โ wiring', () => {
|
|
31
|
+
const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
32
|
+
const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
|
|
33
|
+
|
|
34
|
+
it('declares the MCP tool with an action enum', () => {
|
|
35
|
+
const idx = bridge.indexOf(`name: 'linear_agent_setup'`)
|
|
36
|
+
expect(idx).toBeGreaterThan(0)
|
|
37
|
+
const schema = bridge.slice(idx, idx + 1600)
|
|
38
|
+
expect(schema).toMatch(/authorize_url/)
|
|
39
|
+
expect(schema).toMatch(/complete/)
|
|
40
|
+
expect(schema).toMatch(/client_id/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('is allow-listed and dispatched', () => {
|
|
44
|
+
expect(gw).toMatch(/'linear_agent_setup',/)
|
|
45
|
+
expect(gw).toMatch(/case 'linear_agent_setup':\s*\n\s*return executeLinearAgentSetup\(args\)/)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('runLinearAgentSetup โ authorize_url', () => {
|
|
50
|
+
it('returns the actor=app authorize URL', async () => {
|
|
51
|
+
const r = await runLinearAgentSetup(
|
|
52
|
+
{ action: 'authorize_url', client_id: 'cid', redirect_uri: 'http://localhost:3000/callback' },
|
|
53
|
+
{ agent: 'clerk', log: () => {} },
|
|
54
|
+
)
|
|
55
|
+
expect(r.content[0].text).toContain('https://linear.app/oauth/authorize?')
|
|
56
|
+
expect(r.content[0].text).toContain('actor=app')
|
|
57
|
+
expect(r.content[0].text).toContain('client_id=cid')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('rejects a non-http redirect_uri', async () => {
|
|
61
|
+
const r = await runLinearAgentSetup(
|
|
62
|
+
{ action: 'authorize_url', client_id: 'cid', redirect_uri: 'not-a-url' },
|
|
63
|
+
{ agent: 'clerk', log: () => {} },
|
|
64
|
+
)
|
|
65
|
+
expect(r.content[0].text).toMatch(/redirect_uri is required and must be an http/)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('runLinearAgentSetup โ complete', () => {
|
|
70
|
+
const base = { action: 'complete', client_id: 'cid', client_secret: 'sec', redirect_uri: 'http://localhost:3000/callback', code: 'auth_code' }
|
|
71
|
+
|
|
72
|
+
it('exchanges the code and writes bundle THEN token', async () => {
|
|
73
|
+
const writes: Array<{ which: string; value: string }> = []
|
|
74
|
+
const r = await runLinearAgentSetup(base, {
|
|
75
|
+
agent: 'clerk',
|
|
76
|
+
fetchImpl: exchangeFetch(),
|
|
77
|
+
putBundle: async (_a, j) => { writes.push({ which: 'bundle', value: j }); return { kind: 'ok' } },
|
|
78
|
+
putToken: async (_a, t) => { writes.push({ which: 'token', value: t }); return { kind: 'ok' } },
|
|
79
|
+
log: () => {},
|
|
80
|
+
})
|
|
81
|
+
expect(writes.map((w) => w.which)).toEqual(['bundle', 'token']) // bundle first
|
|
82
|
+
expect(writes[1].value).toBe('lin_acc')
|
|
83
|
+
const storedBundle = JSON.parse(writes[0].value)
|
|
84
|
+
expect(storedBundle).toMatchObject({ client_id: 'cid', client_secret: 'sec', refresh_token: 'lin_ref' })
|
|
85
|
+
expect(r.content[0].text).toMatch(/stored for clerk/)
|
|
86
|
+
expect(r.content[0].text).toMatch(/config_propose_edit/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('bad authorization code โ re-authorize guidance, no writes', async () => {
|
|
90
|
+
let wrote = false
|
|
91
|
+
const r = await runLinearAgentSetup(base, {
|
|
92
|
+
agent: 'clerk',
|
|
93
|
+
fetchImpl: exchangeFetch({ status: 400, body: 'invalid_grant' }),
|
|
94
|
+
putBundle: async () => { wrote = true; return { kind: 'ok' } },
|
|
95
|
+
putToken: async () => { wrote = true; return { kind: 'ok' } },
|
|
96
|
+
log: () => {},
|
|
97
|
+
})
|
|
98
|
+
expect(wrote).toBe(false)
|
|
99
|
+
expect(r.content[0].text).toMatch(/Re-run action "authorize_url"/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('vault has no write-grant (UNKNOWN_KEY) โ write-grant guidance', async () => {
|
|
103
|
+
const r = await runLinearAgentSetup(base, {
|
|
104
|
+
agent: 'clerk',
|
|
105
|
+
fetchImpl: exchangeFetch(),
|
|
106
|
+
putBundle: async () => ({ kind: 'not_found', msg: 'Key not found' }),
|
|
107
|
+
putToken: async () => ({ kind: 'ok' }),
|
|
108
|
+
log: () => {},
|
|
109
|
+
})
|
|
110
|
+
expect(r.content[0].text).toMatch(/vault_request_access/)
|
|
111
|
+
expect(r.content[0].text).toContain('linear/clerk/oauth')
|
|
112
|
+
expect(r.content[0].text).toContain('linear/clerk/token')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('requires client_secret and code', async () => {
|
|
116
|
+
const r1 = await runLinearAgentSetup({ ...base, client_secret: undefined }, { agent: 'clerk', log: () => {} })
|
|
117
|
+
expect(r1.content[0].text).toMatch(/client_secret is required/)
|
|
118
|
+
const r2 = await runLinearAgentSetup({ ...base, code: undefined }, { agent: 'clerk', log: () => {} })
|
|
119
|
+
expect(r2.content[0].text).toMatch(/code .*is required/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('never lets a write failure strand a half-provisioned state (token write fails โ guidance)', async () => {
|
|
123
|
+
const r = await runLinearAgentSetup(base, {
|
|
124
|
+
agent: 'clerk',
|
|
125
|
+
fetchImpl: exchangeFetch(),
|
|
126
|
+
putBundle: async () => ({ kind: 'ok' }),
|
|
127
|
+
putToken: async () => ({ kind: 'denied', msg: 'scope-allow' }),
|
|
128
|
+
log: () => {},
|
|
129
|
+
})
|
|
130
|
+
expect(r.content[0].text).toMatch(/vault_request_access/)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { runLinearAuthCheck, type LinearAuthWatchDeps } from '../gateway/linear-auth-watch.js'
|
|
3
|
+
import { serializeBundle } from '../../src/linear/oauth-refresh.js'
|
|
4
|
+
|
|
5
|
+
function baseDeps(over: Partial<LinearAuthWatchDeps> = {}): { deps: LinearAuthWatchDeps; alerts: Array<{ reason: string }> } {
|
|
6
|
+
const alerts: Array<{ reason: string }> = []
|
|
7
|
+
const deps: LinearAuthWatchDeps = {
|
|
8
|
+
agent: 'clerk',
|
|
9
|
+
linearEnabled: () => true,
|
|
10
|
+
readBundle: async () => serializeBundle({ clientId: 'c', clientSecret: 's', refreshToken: 'rt', expiresAt: 10_000 }),
|
|
11
|
+
refresh: async () => ({ ok: true, accessToken: 'lin_new', expiresAt: 20_000 }),
|
|
12
|
+
onAuthDead: (i) => alerts.push(i),
|
|
13
|
+
nowSec: () => 1_000, // far before expiry โ fresh
|
|
14
|
+
log: () => {},
|
|
15
|
+
...over,
|
|
16
|
+
}
|
|
17
|
+
return { deps, alerts }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('runLinearAuthCheck (proactive Linear auth watch)', () => {
|
|
21
|
+
it('disabled agent โ no-op, no alert', async () => {
|
|
22
|
+
const { deps, alerts } = baseDeps({ linearEnabled: () => false })
|
|
23
|
+
expect(await runLinearAuthCheck(deps)).toBe('disabled')
|
|
24
|
+
expect(alerts).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('fresh token (not near expiry) โ fresh, no refresh, no alert', async () => {
|
|
28
|
+
let refreshed = false
|
|
29
|
+
const { deps, alerts } = baseDeps({ refresh: async () => { refreshed = true; return { ok: true, accessToken: 'x', expiresAt: 1 } } })
|
|
30
|
+
expect(await runLinearAuthCheck(deps)).toBe('fresh')
|
|
31
|
+
expect(refreshed).toBe(false)
|
|
32
|
+
expect(alerts).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('missing bundle โ no_bundle + proactive operator alert', async () => {
|
|
36
|
+
const { deps, alerts } = baseDeps({ readBundle: async () => null })
|
|
37
|
+
expect(await runLinearAuthCheck(deps)).toBe('no_bundle')
|
|
38
|
+
expect(alerts).toEqual([{ agent: 'clerk', reason: 'no_bundle', detail: expect.any(String) }])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('near-expiry token โ proactively refreshes, no alert', async () => {
|
|
42
|
+
// now=9999 is within the 2h skew of expiresAt=10000
|
|
43
|
+
const { deps, alerts } = baseDeps({ nowSec: () => 9_999 })
|
|
44
|
+
expect(await runLinearAuthCheck(deps)).toBe('refreshed')
|
|
45
|
+
expect(alerts).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('near-expiry + revoked refresh token โ alert(revoked)', async () => {
|
|
49
|
+
const { deps, alerts } = baseDeps({
|
|
50
|
+
nowSec: () => 9_999,
|
|
51
|
+
refresh: async () => ({ ok: false, reason: 'revoked', detail: 'invalid_grant' }),
|
|
52
|
+
})
|
|
53
|
+
expect(await runLinearAuthCheck(deps)).toBe('revoked')
|
|
54
|
+
expect(alerts.map((a) => a.reason)).toEqual(['revoked'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('near-expiry + transient refresh failure โ no alert (retries later)', async () => {
|
|
58
|
+
const { deps, alerts } = baseDeps({
|
|
59
|
+
nowSec: () => 9_999,
|
|
60
|
+
refresh: async () => ({ ok: false, reason: 'network', detail: 'boom' }),
|
|
61
|
+
})
|
|
62
|
+
expect(await runLinearAuthCheck(deps)).toBe('refresh_failed')
|
|
63
|
+
expect(alerts).toEqual([])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('broker read error โ refresh_failed, no alert (transient infra)', async () => {
|
|
67
|
+
const { deps, alerts } = baseDeps({ readBundle: async () => { throw new Error('broker down') } })
|
|
68
|
+
expect(await runLinearAuthCheck(deps)).toBe('refresh_failed')
|
|
69
|
+
expect(alerts).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('expiry-untracked bundle (no expiresAt) โ fresh (reactive path covers it)', async () => {
|
|
73
|
+
const { deps, alerts } = baseDeps({
|
|
74
|
+
readBundle: async () => serializeBundle({ clientId: 'c', clientSecret: 's', refreshToken: 'rt' }),
|
|
75
|
+
})
|
|
76
|
+
expect(await runLinearAuthCheck(deps)).toBe('fresh')
|
|
77
|
+
expect(alerts).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -75,7 +75,9 @@ describe('linear_create_issue โ gateway wiring (#2312)', () => {
|
|
|
75
75
|
})
|
|
76
76
|
|
|
77
77
|
it('is allow-listed and dispatched', () => {
|
|
78
|
-
|
|
78
|
+
// Membership in ALLOWED_TOOLS (not position โ new linear tools may be
|
|
79
|
+
// appended after it, e.g. linear_agent_setup).
|
|
80
|
+
expect(gw).toMatch(/^\s*'linear_create_issue',\s*$/m)
|
|
79
81
|
expect(gw).toMatch(/case 'linear_create_issue':\s*\n\s*return executeLinearCreateIssue\(args\)/)
|
|
80
82
|
})
|
|
81
83
|
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure permission-card origin-recovery helper.
|
|
3
|
+
*
|
|
4
|
+
* Pins the behaviour that fixes the marko Rentals-budget incident
|
|
5
|
+
* (2026-06-17): when the gateway's `currentTurn` was force-closed by the
|
|
6
|
+
* orphaned-reply backstop but the claude session kept running into a
|
|
7
|
+
* permission-gated tool, the card must recover its origin from the most-recent
|
|
8
|
+
* still-fresh turn โ so it lands in the forum topic the operator is working in
|
|
9
|
+
* rather than fanning out to operator DMs where it auto-denies on the 10-min
|
|
10
|
+
* TTL.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
pickRecoveredPermissionOrigin,
|
|
16
|
+
type RecoverableTurn,
|
|
17
|
+
} from '../gateway/permission-card-origin.js'
|
|
18
|
+
|
|
19
|
+
const NOW = 1_000_000_000_000
|
|
20
|
+
const MAX_AGE = 30 * 60_000 // 30 min, mirrors PERMISSION_CARD_ORIGIN_MAX_AGE_MS
|
|
21
|
+
|
|
22
|
+
function turn(
|
|
23
|
+
chatId: string,
|
|
24
|
+
threadId: number | undefined,
|
|
25
|
+
ageMs: number,
|
|
26
|
+
): RecoverableTurn {
|
|
27
|
+
return { sessionChatId: chatId, sessionThreadId: threadId, startedAt: NOW - ageMs }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('pickRecoveredPermissionOrigin', () => {
|
|
31
|
+
it('returns null for an empty registry (caller keeps the DM fan-out)', () => {
|
|
32
|
+
expect(pickRecoveredPermissionOrigin([], NOW, MAX_AGE)).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('recovers the supergroup chat + topic of the most-recent fresh turn', () => {
|
|
36
|
+
// The marko shape: the force-closed turn was in supergroup topic 3.
|
|
37
|
+
const recovered = pickRecoveredPermissionOrigin(
|
|
38
|
+
[turn('-1001234567890', 3, 11 * 60_000)],
|
|
39
|
+
NOW,
|
|
40
|
+
MAX_AGE,
|
|
41
|
+
)
|
|
42
|
+
expect(recovered).toEqual({ chatId: '-1001234567890', threadId: 3 })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('picks the most-recently-started turn when several are fresh', () => {
|
|
46
|
+
const recovered = pickRecoveredPermissionOrigin(
|
|
47
|
+
[
|
|
48
|
+
turn('-100aaa', 1, 20 * 60_000),
|
|
49
|
+
turn('-100bbb', 3, 2 * 60_000), // most recent
|
|
50
|
+
turn('-100ccc', 4, 9 * 60_000),
|
|
51
|
+
],
|
|
52
|
+
NOW,
|
|
53
|
+
MAX_AGE,
|
|
54
|
+
)
|
|
55
|
+
expect(recovered).toEqual({ chatId: '-100bbb', threadId: 3 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('selects by startedAt, not iteration order (robust to out-of-order inserts)', () => {
|
|
59
|
+
const recovered = pickRecoveredPermissionOrigin(
|
|
60
|
+
[
|
|
61
|
+
turn('-100recent', 1, 1 * 60_000), // freshest, but listed first
|
|
62
|
+
turn('-100older', 2, 15 * 60_000),
|
|
63
|
+
],
|
|
64
|
+
NOW,
|
|
65
|
+
MAX_AGE,
|
|
66
|
+
)
|
|
67
|
+
expect(recovered).toEqual({ chatId: '-100recent', threadId: 1 })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('ignores turns older than the freshness ceiling', () => {
|
|
71
|
+
expect(
|
|
72
|
+
pickRecoveredPermissionOrigin([turn('-100stale', 7, 45 * 60_000)], NOW, MAX_AGE),
|
|
73
|
+
).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('recovers a DM-origin turn thread-less (threadId undefined)', () => {
|
|
77
|
+
const recovered = pickRecoveredPermissionOrigin(
|
|
78
|
+
[turn('12345', undefined, 3 * 60_000)],
|
|
79
|
+
NOW,
|
|
80
|
+
MAX_AGE,
|
|
81
|
+
)
|
|
82
|
+
expect(recovered).toEqual({ chatId: '12345', threadId: undefined })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps the freshest in-window turn even when stale turns are present', () => {
|
|
86
|
+
const recovered = pickRecoveredPermissionOrigin(
|
|
87
|
+
[
|
|
88
|
+
turn('-100stale', 1, 90 * 60_000),
|
|
89
|
+
turn('-100fresh', 3, 5 * 60_000),
|
|
90
|
+
turn('-100ancient', 2, 600 * 60_000),
|
|
91
|
+
],
|
|
92
|
+
NOW,
|
|
93
|
+
MAX_AGE,
|
|
94
|
+
)
|
|
95
|
+
expect(recovered).toEqual({ chatId: '-100fresh', threadId: 3 })
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -74,4 +74,27 @@ describe('permission card routing', () => {
|
|
|
74
74
|
const body = GATEWAY_SRC.slice(start, start + 1400)
|
|
75
75
|
expect(body).toContain('resolvePermissionCardTargets()')
|
|
76
76
|
})
|
|
77
|
+
|
|
78
|
+
// marko Rentals-budget incident (2026-06-17): a turn force-closed by the
|
|
79
|
+
// orphaned-reply backstop nulled currentTurn, so a permission gate that
|
|
80
|
+
// fired afterwards fell through to the operator-DM fan-out instead of the
|
|
81
|
+
// forum topic. The helper must first try to recover the origin from the
|
|
82
|
+
// recently-started turn registry.
|
|
83
|
+
it('resolvePermissionCardTargets recovers origin from recent turns when currentTurn is null', () => {
|
|
84
|
+
const start = GATEWAY_SRC.indexOf('function resolvePermissionCardTargets(')
|
|
85
|
+
expect(start).toBeGreaterThan(-1)
|
|
86
|
+
const end = GATEWAY_SRC.indexOf('\n}', start)
|
|
87
|
+
const body = GATEWAY_SRC.slice(start, end)
|
|
88
|
+
// Recovery is attempted via the pure helper over the turn registry...
|
|
89
|
+
expect(body).toContain('pickRecoveredPermissionOrigin')
|
|
90
|
+
expect(body).toContain('recentTurnsById')
|
|
91
|
+
// ...before the operator-DM fan-out (recovery branch precedes allowFrom).
|
|
92
|
+
expect(body.indexOf('pickRecoveredPermissionOrigin')).toBeLessThan(
|
|
93
|
+
body.indexOf('allowFrom'),
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('the origin-recovery path has a kill switch', () => {
|
|
98
|
+
expect(GATEWAY_SRC).toContain('SWITCHROOM_PERMISSION_CARD_ORIGIN_RECOVERY')
|
|
99
|
+
})
|
|
77
100
|
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-text pins for the no-repeat-on-timeout wiring (marko Rentals-budget
|
|
3
|
+
* loop, 2026-06-17). gateway.ts / bridge.ts have top-level side effects and
|
|
4
|
+
* aren't unit-importable; the decision logic is unit-tested in
|
|
5
|
+
* permission-timeout.test.ts. These pins lock the wiring so it can't silently
|
|
6
|
+
* regress.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { dirname, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
|
|
16
|
+
const GATEWAY = read('gateway/gateway.ts')
|
|
17
|
+
const BRIDGE = read('bridge/bridge.ts')
|
|
18
|
+
const IPC_PROTOCOL = read('gateway/ipc-protocol.ts')
|
|
19
|
+
const IPC_CLIENT = read('bridge/ipc-client.ts')
|
|
20
|
+
|
|
21
|
+
function slice(src: string, fnHeader: string, span = 1600): string {
|
|
22
|
+
const start = src.indexOf(fnHeader)
|
|
23
|
+
expect(start, `expected to find ${fnHeader}`).toBeGreaterThan(-1)
|
|
24
|
+
return src.slice(start, start + span)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('no-repeat-on-timeout wiring', () => {
|
|
28
|
+
it('PermissionEvent carries an optional message field', () => {
|
|
29
|
+
const evt = slice(IPC_PROTOCOL, 'export interface PermissionEvent', 2400)
|
|
30
|
+
expect(evt).toMatch(/message\?:\s*string/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('the bridge IPC validator accepts an optional non-empty message', () => {
|
|
34
|
+
expect(IPC_CLIENT).toMatch(/m\.message === undefined/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('the bridge forwards message on the permission channel notification', () => {
|
|
38
|
+
const fn = slice(BRIDGE, 'function onPermission(', 1600)
|
|
39
|
+
expect(fn).toContain('notifications/claude/channel/permission')
|
|
40
|
+
expect(fn).toMatch(/msg\.message/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('the TTL auto-deny attaches a timeout message and records the signature', () => {
|
|
44
|
+
// Within the pending-permission sweep block.
|
|
45
|
+
const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 2200)
|
|
46
|
+
expect(sweep).toContain('timeoutDenyMessage(')
|
|
47
|
+
expect(sweep).toContain('permissionTimeoutSignatures.set(')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('onPermissionRequest short-circuits a recent-timeout duplicate before posting a card', () => {
|
|
51
|
+
const fn = slice(GATEWAY, 'onPermissionRequest(', 4000)
|
|
52
|
+
const dupIdx = fn.indexOf('isRecentTimeoutDuplicate(')
|
|
53
|
+
const cardIdx = fn.indexOf('pendingPermissions.set(requestId')
|
|
54
|
+
expect(dupIdx).toBeGreaterThan(-1)
|
|
55
|
+
expect(cardIdx).toBeGreaterThan(-1)
|
|
56
|
+
// The duplicate check must run BEFORE the card is registered/posted.
|
|
57
|
+
expect(dupIdx).toBeLessThan(cardIdx)
|
|
58
|
+
expect(fn).toContain('duplicateDenyMessage')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('suppression is reset on operator activity (inbound + card verdict + slash)', () => {
|
|
62
|
+
// Three distinct reset points so a returning operator always gets a fresh card.
|
|
63
|
+
const resets = GATEWAY.match(/clearPermissionTimeoutSuppression\(/g) ?? []
|
|
64
|
+
// 1 definition call inside the helper + at least 3 reset callsites.
|
|
65
|
+
expect(resets.length).toBeGreaterThanOrEqual(3)
|
|
66
|
+
expect(GATEWAY).toContain("clearPermissionTimeoutSuppression('operator inbound')")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('has a kill switch', () => {
|
|
70
|
+
expect(GATEWAY).toContain('SWITCHROOM_PERMISSION_NO_REPEAT')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('sweeps stale suppression entries past the safety-cap window', () => {
|
|
74
|
+
expect(GATEWAY).toMatch(/permissionTimeoutSignatures\.delete\(sig\)/)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure permission-timeout helpers (no-repeat-on-timeout).
|
|
3
|
+
*
|
|
4
|
+
* Pins the behaviour that closes the marko Rentals-budget retry loop
|
|
5
|
+
* (2026-06-17): a TTL auto-deny must be distinguishable from a real denial,
|
|
6
|
+
* and an identical retry shortly after a timeout (operator still absent) must
|
|
7
|
+
* be recognisable so the gateway can suppress the duplicate card.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
permissionSignature,
|
|
13
|
+
timeoutDenyMessage,
|
|
14
|
+
duplicateDenyMessage,
|
|
15
|
+
isRecentTimeoutDuplicate,
|
|
16
|
+
} from '../gateway/permission-timeout.js'
|
|
17
|
+
|
|
18
|
+
describe('permissionSignature', () => {
|
|
19
|
+
it('is stable for the same tool + input', () => {
|
|
20
|
+
expect(permissionSignature('mcp__meta_ads__set_budget', '{"id":"1","budget":1400}'))
|
|
21
|
+
.toBe(permissionSignature('mcp__meta_ads__set_budget', '{"id":"1","budget":1400}'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('differs when the tool differs', () => {
|
|
25
|
+
expect(permissionSignature('toolA', 'x')).not.toBe(permissionSignature('toolB', 'x'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('differs when the input differs', () => {
|
|
29
|
+
expect(permissionSignature('t', 'Rentals $14')).not.toBe(permissionSignature('t', 'Land $60'))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not collide across the tool/input boundary (NUL-separated)', () => {
|
|
33
|
+
// A space separator would make ("a b","c") and ("a","b c") collide.
|
|
34
|
+
expect(permissionSignature('a b', 'c')).not.toBe(permissionSignature('a', 'b c'))
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('timeoutDenyMessage', () => {
|
|
39
|
+
it('names the timeout, the minutes, and tells the model not to retry', () => {
|
|
40
|
+
const msg = timeoutDenyMessage(10)
|
|
41
|
+
expect(msg).toContain('10 minutes')
|
|
42
|
+
expect(msg).toMatch(/timeout/i)
|
|
43
|
+
expect(msg).toMatch(/not a denial/i)
|
|
44
|
+
expect(msg).toMatch(/do not retry/i)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('is a non-empty string (wire-validator requires non-empty)', () => {
|
|
48
|
+
expect(timeoutDenyMessage(5).length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('duplicateDenyMessage', () => {
|
|
53
|
+
it('tells the model to stop re-requesting and is non-empty', () => {
|
|
54
|
+
expect(duplicateDenyMessage).toMatch(/do not keep re-requesting/i)
|
|
55
|
+
expect(duplicateDenyMessage.length).toBeGreaterThan(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('isRecentTimeoutDuplicate', () => {
|
|
60
|
+
const WINDOW = 60 * 60_000
|
|
61
|
+
const NOW = 1_000_000_000_000
|
|
62
|
+
|
|
63
|
+
it('false when the signature was never recorded', () => {
|
|
64
|
+
expect(isRecentTimeoutDuplicate(new Map(), 'sig', NOW, WINDOW)).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('true when the signature timed out within the window', () => {
|
|
68
|
+
const m = new Map([['sig', NOW - 5 * 60_000]])
|
|
69
|
+
expect(isRecentTimeoutDuplicate(m, 'sig', NOW, WINDOW)).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('false when the timeout is older than the window', () => {
|
|
73
|
+
const m = new Map([['sig', NOW - 2 * WINDOW]])
|
|
74
|
+
expect(isRecentTimeoutDuplicate(m, 'sig', NOW, WINDOW)).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('true exactly at the window boundary', () => {
|
|
78
|
+
const m = new Map([['sig', NOW - WINDOW]])
|
|
79
|
+
expect(isRecentTimeoutDuplicate(m, 'sig', NOW, WINDOW)).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('only matches the exact signature', () => {
|
|
83
|
+
const m = new Map([[permissionSignature('t', 'Rentals'), NOW]])
|
|
84
|
+
expect(isRecentTimeoutDuplicate(m, permissionSignature('t', 'Land'), NOW, WINDOW)).toBe(false)
|
|
85
|
+
expect(isRecentTimeoutDuplicate(m, permissionSignature('t', 'Rentals'), NOW, WINDOW)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the middle rung between "Allow once" and "๐ Always".
|
|
4
4
|
*
|
|
5
5
|
* These pin the access-model invariants the adversarial review flagged as
|
|
6
|
-
* load-bearing (reference/access-model.md "you hold the leash"):
|
|
6
|
+
* load-bearing (reference/rfcs/access-model.md "you hold the leash"):
|
|
7
7
|
* - no tool call can SEED a grant (first contact never auto-allows);
|
|
8
8
|
* - no tool call can EXTEND the window (fixed box โ expiresAt is set once
|
|
9
9
|
* at the operator tap and never moves on a match);
|
|
@@ -528,7 +528,7 @@ describe('silence-poke โ fallback handler errors do not break timer', () => {
|
|
|
528
528
|
})
|
|
529
529
|
|
|
530
530
|
// CC-4 from `docs/status-ask-cause-classes.md`: wording is load-bearing
|
|
531
|
-
// (`reference/conversational-pacing.md` ยง Safety net). Snapshot the exact
|
|
531
|
+
// (`reference/rfcs/conversational-pacing.md` ยง Safety net). Snapshot the exact
|
|
532
532
|
// strings here so a refactor that drops a key phrase fails loud at test
|
|
533
533
|
// time. If you genuinely need to change the wording, update the snapshot
|
|
534
534
|
// AND the design doc together.
|