switchroom 0.15.25 → 0.15.27
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/cli/switchroom.js +1293 -414
- package/dist/cli/ui/index.html +682 -60
- package/dist/host-control/main.js +279 -7
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +179 -18
- package/telegram-plugin/gateway/linear-activity.ts +102 -14
- package/telegram-plugin/tests/linear-agent-activity.test.ts +75 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +42 -0
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
getViaBrokerStructured,
|
|
20
|
+
putViaBroker,
|
|
20
21
|
readVaultTokenFile,
|
|
21
22
|
} from '../../src/vault/broker/client.js'
|
|
23
|
+
import { performLinearRefresh, type RefreshIO } from '../../src/linear/oauth-refresh.js'
|
|
22
24
|
|
|
23
25
|
export const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'
|
|
24
26
|
|
|
@@ -33,6 +35,10 @@ export interface LinearActivityDeps {
|
|
|
33
35
|
fetchImpl?: typeof fetch
|
|
34
36
|
/** Agent slug (defaults to SWITCHROOM_AGENT_NAME). */
|
|
35
37
|
agent?: string
|
|
38
|
+
/** Build the RefreshIO used to auto-refresh on a 401 (tests inject a
|
|
39
|
+
* fake; production uses the broker-backed `brokerRefreshIO`). When
|
|
40
|
+
* omitted, on-401 refresh uses the broker. */
|
|
41
|
+
refreshIO?: (agent: string) => RefreshIO
|
|
36
42
|
/** Default Linear team id for captured issues (multi-team workspaces);
|
|
37
43
|
* defaults to SWITCHROOM_LINEAR_DEFAULT_TEAM_ID. Tests inject directly. */
|
|
38
44
|
defaultTeamId?: string
|
|
@@ -56,6 +62,79 @@ export async function defaultResolveLinearToken(agent: string): Promise<LinearTo
|
|
|
56
62
|
return { ok: false, reason: 'unknown' }
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Broker-backed RefreshIO: reads `linear/<agent>/oauth` and rotates both
|
|
67
|
+
* `linear/<agent>/token` and the bundle via the broker `put` op (#950 — an
|
|
68
|
+
* agent rotates keys it can already read, no operator passphrase, no host
|
|
69
|
+
* round-trip). Requires `linear/<agent>/oauth` to be in the agent's ACL
|
|
70
|
+
* (linear-agent setup adds it to `secrets[]`).
|
|
71
|
+
*/
|
|
72
|
+
export function brokerRefreshIO(agent: string, fetchImpl?: typeof fetch): RefreshIO {
|
|
73
|
+
const token = readVaultTokenFile(agent) ?? undefined
|
|
74
|
+
const opt = token ? { token } : {}
|
|
75
|
+
return {
|
|
76
|
+
readBundle: async () => {
|
|
77
|
+
const r = await getViaBrokerStructured(`linear/${agent}/oauth`, opt)
|
|
78
|
+
return r.kind === 'ok' && r.entry.kind === 'string' ? r.entry.value : null
|
|
79
|
+
},
|
|
80
|
+
writeToken: async (t) => {
|
|
81
|
+
const r = await putViaBroker(`linear/${agent}/token`, { kind: 'string', value: t }, opt)
|
|
82
|
+
if (r.kind !== 'ok') throw new Error(`broker put linear/${agent}/token: ${r.kind}`)
|
|
83
|
+
},
|
|
84
|
+
writeBundle: async (j) => {
|
|
85
|
+
const r = await putViaBroker(`linear/${agent}/oauth`, { kind: 'string', value: j }, opt)
|
|
86
|
+
if (r.kind !== 'ok') throw new Error(`broker put linear/${agent}/oauth: ${r.kind}`)
|
|
87
|
+
},
|
|
88
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* POST to Linear's GraphQL endpoint; on a 401 (expired/invalid app token)
|
|
94
|
+
* auto-refresh the token once via the stored refresh bundle and retry with
|
|
95
|
+
* the fresh token. This is the durable self-heal: a Linear app token that
|
|
96
|
+
* expired (~24h–30d) is silently rotated in-container on its next use rather
|
|
97
|
+
* than failing the agent's turn. A `revoked` refresh token (the one case
|
|
98
|
+
* needing operator re-auth) is logged loudly and the original 401 surfaces.
|
|
99
|
+
*
|
|
100
|
+
* Returns the final Response and the token in force (callers read the body).
|
|
101
|
+
*/
|
|
102
|
+
async function linearPostWithRefresh(
|
|
103
|
+
body: string,
|
|
104
|
+
token: string,
|
|
105
|
+
agent: string,
|
|
106
|
+
fetchImpl: typeof fetch,
|
|
107
|
+
log: (s: string) => void,
|
|
108
|
+
refreshIO?: (agent: string) => RefreshIO,
|
|
109
|
+
): Promise<{ resp: Response; token: string }> {
|
|
110
|
+
const post = (t: string) =>
|
|
111
|
+
fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'Content-Type': 'application/json', Authorization: t },
|
|
114
|
+
body,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
let resp = await post(token)
|
|
118
|
+
if (resp.status !== 401) return { resp, token }
|
|
119
|
+
|
|
120
|
+
const io = (refreshIO ?? ((a) => brokerRefreshIO(a, fetchImpl)))(agent)
|
|
121
|
+
const refreshed = await performLinearRefresh({ ...io, fetchImpl })
|
|
122
|
+
if (!refreshed.ok) {
|
|
123
|
+
if (refreshed.reason === 'revoked') {
|
|
124
|
+
log(
|
|
125
|
+
`telegram gateway: linear token REVOKED agent=${agent} — refresh token is dead; ` +
|
|
126
|
+
`operator must re-authorize (linear-agent setup --refresh-token …)\n`,
|
|
127
|
+
)
|
|
128
|
+
} else {
|
|
129
|
+
log(`telegram gateway: linear token refresh failed agent=${agent} reason=${refreshed.reason}\n`)
|
|
130
|
+
}
|
|
131
|
+
return { resp, token } // surface the original 401
|
|
132
|
+
}
|
|
133
|
+
log(`telegram gateway: linear token auto-refreshed agent=${agent} (was 401)\n`)
|
|
134
|
+
resp = await post(refreshed.accessToken)
|
|
135
|
+
return { resp, token: refreshed.accessToken }
|
|
136
|
+
}
|
|
137
|
+
|
|
59
138
|
/**
|
|
60
139
|
* Emit a Linear AgentActivity. Validates args, resolves the token, POSTs
|
|
61
140
|
* the `agentActivityCreate` mutation, and returns an MCP text result. Never
|
|
@@ -118,14 +197,16 @@ export async function emitLinearAgentActivity(
|
|
|
118
197
|
const fetchImpl = deps.fetchImpl ?? fetch
|
|
119
198
|
let resp: Response
|
|
120
199
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
200
|
+
// Auto-refresh on a 401 (expired app token) and retry once — see
|
|
201
|
+
// linearPostWithRefresh.
|
|
202
|
+
;({ resp } = await linearPostWithRefresh(
|
|
203
|
+
JSON.stringify({ query: mutation, variables }),
|
|
204
|
+
tokenResult.token,
|
|
205
|
+
agent,
|
|
206
|
+
fetchImpl,
|
|
207
|
+
log,
|
|
208
|
+
deps.refreshIO,
|
|
209
|
+
))
|
|
129
210
|
} catch (err) {
|
|
130
211
|
return {
|
|
131
212
|
content: [{ type: 'text', text: `linear_agent_activity failed: request error: ${(err as Error).message}` }],
|
|
@@ -217,16 +298,23 @@ export async function createLinearIssue(
|
|
|
217
298
|
],
|
|
218
299
|
}
|
|
219
300
|
}
|
|
220
|
-
|
|
301
|
+
// Mutable so a 401-triggered refresh on one gql call carries the fresh
|
|
302
|
+
// token to subsequent calls in this issue-create flow.
|
|
303
|
+
let activeToken = tokenResult.token
|
|
221
304
|
|
|
222
305
|
const gql = async (query: string, variables: Record<string, unknown>): Promise<{ ok: true; data: any } | { ok: false; text: string }> => {
|
|
223
306
|
let resp: Response
|
|
224
307
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
308
|
+
const out = await linearPostWithRefresh(
|
|
309
|
+
JSON.stringify({ query, variables }),
|
|
310
|
+
activeToken,
|
|
311
|
+
agent,
|
|
312
|
+
fetchImpl,
|
|
313
|
+
log,
|
|
314
|
+
deps.refreshIO,
|
|
315
|
+
)
|
|
316
|
+
resp = out.resp
|
|
317
|
+
activeToken = out.token
|
|
230
318
|
} catch (err) {
|
|
231
319
|
return { ok: false, text: `request error: ${(err as Error).message}` }
|
|
232
320
|
}
|
|
@@ -122,3 +122,78 @@ describe('emitLinearAgentActivity — behaviour (#2298)', () => {
|
|
|
122
122
|
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
123
123
|
})
|
|
124
124
|
})
|
|
125
|
+
|
|
126
|
+
import { serializeBundle, type RefreshIO } from '../../src/linear/oauth-refresh.js'
|
|
127
|
+
|
|
128
|
+
/** fetch fake routing by URL: the GraphQL endpoint 401s on the first hit then
|
|
129
|
+
* 200s; the OAuth token endpoint returns a fresh token. Records the
|
|
130
|
+
* Authorization header per call so we can prove the retry used the new token. */
|
|
131
|
+
function refreshAwareFetch(opts: { tokenStatus?: number; tokenBody?: unknown } = {}) {
|
|
132
|
+
const calls: Array<{ url: string; auth?: string }> = []
|
|
133
|
+
let graphqlHits = 0
|
|
134
|
+
const fetchImpl = (async (url: string, init: { headers?: Record<string, string> }) => {
|
|
135
|
+
const auth = init?.headers?.Authorization
|
|
136
|
+
calls.push({ url, auth })
|
|
137
|
+
if (url.includes('/oauth/token')) {
|
|
138
|
+
const status = opts.tokenStatus ?? 200
|
|
139
|
+
const body = opts.tokenBody ?? { access_token: 'lin_fresh', refresh_token: 'rt_new', expires_in: 86400 }
|
|
140
|
+
return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)) } as unknown as Response
|
|
141
|
+
}
|
|
142
|
+
graphqlHits++
|
|
143
|
+
if (graphqlHits === 1) {
|
|
144
|
+
return { ok: false, status: 401, json: async () => ({}), text: async () => 'unauthorized' } as unknown as Response
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, status: 200, json: async () => ({ data: { agentActivityCreate: { success: true } } }), text: async () => '' } as unknown as Response
|
|
147
|
+
}) as unknown as typeof fetch
|
|
148
|
+
return { fetchImpl, calls }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fakeRefreshIO(): { io: RefreshIO; writes: { token?: string; bundle?: string } } {
|
|
152
|
+
const writes: { token?: string; bundle?: string } = {}
|
|
153
|
+
const io: RefreshIO = {
|
|
154
|
+
readBundle: async () => serializeBundle({ clientId: 'cid', clientSecret: 'csec', refreshToken: 'rt_old', expiresAt: 0 }),
|
|
155
|
+
writeToken: async (t) => { writes.token = t },
|
|
156
|
+
writeBundle: async (j) => { writes.bundle = j },
|
|
157
|
+
}
|
|
158
|
+
return { io, writes }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('linear_agent_activity — auto-refresh on 401 (#2298 durability)', () => {
|
|
162
|
+
it('refreshes the app token on a 401 and retries once with the fresh token', async () => {
|
|
163
|
+
const { fetchImpl, calls } = refreshAwareFetch()
|
|
164
|
+
const { io, writes } = fakeRefreshIO()
|
|
165
|
+
const r = await emitLinearAgentActivity(
|
|
166
|
+
{ agent_session_id: 'sess', type: 'thought', body: 'hi' },
|
|
167
|
+
{ agent: 'carrie', resolveToken: okToken('lin_expired'), fetchImpl, refreshIO: () => io, log: () => {} },
|
|
168
|
+
)
|
|
169
|
+
expect(r.content[0].text).toMatch(/emitted/)
|
|
170
|
+
// The refresh wrote the new access token; the GraphQL retry used it.
|
|
171
|
+
expect(writes.token).toBe('lin_fresh')
|
|
172
|
+
const graphqlAuths = calls.filter((c) => c.url.includes('/graphql')).map((c) => c.auth)
|
|
173
|
+
expect(graphqlAuths).toEqual(['lin_expired', 'lin_fresh'])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('a revoked refresh token surfaces the 401 (one retry, no loop) and logs REVOKED', async () => {
|
|
177
|
+
const { fetchImpl } = refreshAwareFetch({ tokenStatus: 400, tokenBody: 'invalid_grant' })
|
|
178
|
+
const { io } = fakeRefreshIO()
|
|
179
|
+
const logs: string[] = []
|
|
180
|
+
const r = await emitLinearAgentActivity(
|
|
181
|
+
{ agent_session_id: 'sess', type: 'thought', body: 'hi' },
|
|
182
|
+
{ agent: 'carrie', resolveToken: okToken('lin_dead'), fetchImpl, refreshIO: () => io, log: (s) => logs.push(s) },
|
|
183
|
+
)
|
|
184
|
+
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
185
|
+
expect(logs.join('')).toMatch(/REVOKED/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('no 401 → no refresh attempt (happy path unchanged)', async () => {
|
|
189
|
+
const { fetchImpl, calls } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
|
|
190
|
+
let refreshBuilt = false
|
|
191
|
+
const r = await emitLinearAgentActivity(
|
|
192
|
+
{ agent_session_id: 'sess', type: 'message', body: 'ok' },
|
|
193
|
+
{ agent: 'carrie', resolveToken: okToken('lin_good'), fetchImpl, refreshIO: () => { refreshBuilt = true; return fakeRefreshIO().io }, log: () => {} },
|
|
194
|
+
)
|
|
195
|
+
expect(r.content[0].text).toMatch(/emitted/)
|
|
196
|
+
expect(refreshBuilt).toBe(false)
|
|
197
|
+
expect(calls.length).toBe(1)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -209,3 +209,45 @@ describe('createLinearIssue — behaviour (#2312)', () => {
|
|
|
209
209
|
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
210
210
|
})
|
|
211
211
|
})
|
|
212
|
+
|
|
213
|
+
import { serializeBundle, type RefreshIO } from '../../src/linear/oauth-refresh.js'
|
|
214
|
+
|
|
215
|
+
describe('createLinearIssue — token threading across gql calls after a 401 refresh', () => {
|
|
216
|
+
it('a 401 on the teams resolve refreshes, and issueCreate carries the FRESH token', async () => {
|
|
217
|
+
// No team_id + no dedup → flow is: teams(query) then issueCreate(mutation).
|
|
218
|
+
// The teams call 401s → refresh → retry; activeToken must then carry the
|
|
219
|
+
// fresh token to issueCreate. Pins the mutable-activeToken threading.
|
|
220
|
+
const auths: Array<{ op: string; auth?: string }> = []
|
|
221
|
+
let teamsHits = 0
|
|
222
|
+
const fetchImpl = (async (url: string, init: { headers?: Record<string, string>; body?: string }) => {
|
|
223
|
+
const auth = init?.headers?.Authorization
|
|
224
|
+
const body = init?.body ?? ''
|
|
225
|
+
if (url.includes('/oauth/token')) {
|
|
226
|
+
return { ok: true, status: 200, json: async () => ({ access_token: 'lin_fresh', expires_in: 3600 }), text: async () => '' } as unknown as Response
|
|
227
|
+
}
|
|
228
|
+
if (body.includes('teams(')) {
|
|
229
|
+
auths.push({ op: 'teams', auth })
|
|
230
|
+
teamsHits++
|
|
231
|
+
if (teamsHits === 1) return { ok: false, status: 401, json: async () => ({}), text: async () => 'unauthorized' } as unknown as Response
|
|
232
|
+
return { ok: true, status: 200, json: async () => ({ data: { teams: { nodes: [{ id: 'team_1', key: 'ENG', name: 'Eng' }] } } }), text: async () => '' } as unknown as Response
|
|
233
|
+
}
|
|
234
|
+
// issueCreate
|
|
235
|
+
auths.push({ op: 'issueCreate', auth })
|
|
236
|
+
return { ok: true, status: 200, json: async () => ({ data: { issueCreate: { success: true, issue: { identifier: 'ENG-1', url: 'https://linear.app/x/issue/ENG-1' } } } }), text: async () => '' } as unknown as Response
|
|
237
|
+
}) as unknown as typeof fetch
|
|
238
|
+
|
|
239
|
+
const io: RefreshIO = {
|
|
240
|
+
readBundle: async () => serializeBundle({ clientId: 'c', clientSecret: 's', refreshToken: 'rt', expiresAt: 0 }),
|
|
241
|
+
writeToken: async () => {},
|
|
242
|
+
writeBundle: async () => {},
|
|
243
|
+
}
|
|
244
|
+
const r = await createLinearIssue(
|
|
245
|
+
{ title: 'a bug' },
|
|
246
|
+
{ agent: 'carrie', resolveToken: okToken('lin_expired'), fetchImpl, refreshIO: () => io, log: () => {} },
|
|
247
|
+
)
|
|
248
|
+
expect(r.content[0].text).toMatch(/Filed:/)
|
|
249
|
+
// teams: expired then fresh (retry); issueCreate: fresh (threaded).
|
|
250
|
+
expect(auths.find((a) => a.op === 'issueCreate')?.auth).toBe('lin_fresh')
|
|
251
|
+
expect(auths.filter((a) => a.op === 'teams').map((a) => a.auth)).toEqual(['lin_expired', 'lin_fresh'])
|
|
252
|
+
})
|
|
253
|
+
})
|