switchroom 0.15.35 → 0.15.37

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.
@@ -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
- expect(gw).toMatch(/'linear_create_issue',\n\]\)/)
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
  })