switchroom 0.15.36 → 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.
- package/dist/agent-scheduler/index.js +81 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +371 -357
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +25 -0
- package/telegram-plugin/bridge/bridge.ts +32 -0
- package/telegram-plugin/dist/bridge/bridge.js +143 -112
- package/telegram-plugin/dist/gateway/gateway.js +813 -378
- package/telegram-plugin/dist/server.js +191 -160
- package/telegram-plugin/gateway/gateway.ts +121 -3
- 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/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
|
@@ -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
|
})
|