@saluzi/saluzi-edu 0.2.64 → 0.2.66
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.js +46 -46
- package/dist/guide/assets/main-Bx1xb7yl.css +1 -0
- package/dist/guide/assets/main-CdOE18lg.js +277 -0
- package/dist/guide/guide-data.json +17 -7
- package/dist/guide/index.html +2 -2
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +12 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +111 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +110 -14
- package/packages/remote-control-server/src/__tests__/low-priority-fixes.test.ts +95 -0
- package/packages/remote-control-server/src/__tests__/routes.test.ts +69 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +44 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +65 -7
- package/packages/remote-control-server/src/routes/v1/environments.work.ts +2 -2
- package/packages/remote-control-server/src/routes/v1/sessions.ts +143 -79
- package/packages/remote-control-server/src/routes/web/sessions.ts +25 -22
- package/packages/remote-control-server/src/services/environment.ts +25 -0
- package/packages/remote-control-server/src/store.ts +17 -1
- package/dist/guide/assets/main-CSpz9pKf.css +0 -1
- package/dist/guide/assets/main-C_ykjDzn.js +0 -167
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
storeCreateEnvironment,
|
|
17
17
|
storeIssueAttachCode,
|
|
18
18
|
storeConsumeAttachCode,
|
|
19
|
+
storeAttachCodesSize,
|
|
19
20
|
} from '../store'
|
|
20
21
|
import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
|
|
21
22
|
import { issueSessionToken } from '../auth/session'
|
|
@@ -172,6 +173,17 @@ describe('attach codes', () => {
|
|
|
172
173
|
expect(storeConsumeAttachCode(record.code)).toBeNull()
|
|
173
174
|
})
|
|
174
175
|
|
|
176
|
+
test('unconsumed expired codes are swept on the next issue (memory hygiene)', () => {
|
|
177
|
+
expect(storeAttachCodesSize()).toBe(0)
|
|
178
|
+
// Expired immediately and never presented — consume() would never see it.
|
|
179
|
+
storeIssueAttachCode(sessionId, -1000)
|
|
180
|
+
expect(storeAttachCodesSize()).toBe(1)
|
|
181
|
+
// The next issue sweeps it instead of letting it accumulate forever.
|
|
182
|
+
const live = storeIssueAttachCode(sessionId)
|
|
183
|
+
expect(storeAttachCodesSize()).toBe(1)
|
|
184
|
+
expect(storeConsumeAttachCode(live.code)?.sessionId).toBe(sessionId)
|
|
185
|
+
})
|
|
186
|
+
|
|
175
187
|
test('missing code or environment_id → 400', async () => {
|
|
176
188
|
const res = await app.request('/v1/sessions/attach-code/exchange', {
|
|
177
189
|
method: 'POST',
|
|
@@ -30,6 +30,7 @@ import { storeBindSession } from '../store'
|
|
|
30
30
|
* - POST /v1/environments/bridge persists session_mode:'attach'.
|
|
31
31
|
* - GET /v1/environments/:id/assignment returns the first open non-ACP
|
|
32
32
|
* session on the env (assigned:false until the Web UI binds one).
|
|
33
|
+
* Gated on env ownership (admin bypasses; unowned envs stay open).
|
|
33
34
|
* - GET /web/environments surfaces attach_waiting for waiting workers.
|
|
34
35
|
*/
|
|
35
36
|
|
|
@@ -124,6 +125,116 @@ describe('Session Attach wait mode', () => {
|
|
|
124
125
|
expect(after ?? 0).toBeGreaterThan(stale.getTime())
|
|
125
126
|
})
|
|
126
127
|
|
|
128
|
+
test('assignment: owned env + owner rct token → 200 (worker polls as itself)', async () => {
|
|
129
|
+
const env = storeCreateEnvironment({
|
|
130
|
+
secret: 'test-api-key',
|
|
131
|
+
machineName: 'mac1',
|
|
132
|
+
ownerUserId: owner.id,
|
|
133
|
+
sessionMode: 'attach',
|
|
134
|
+
})
|
|
135
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
136
|
+
headers: { Authorization: rctAuth(owner.id) },
|
|
137
|
+
})
|
|
138
|
+
expect(res.status).toBe(200)
|
|
139
|
+
expect(await res.json()).toEqual({ assigned: false })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('assignment: owned env + stranger rct token → 403, heartbeat NOT refreshed', async () => {
|
|
143
|
+
// Assignment discloses the env's bound session AND is its liveness
|
|
144
|
+
// signal — neither may be driven by a third party.
|
|
145
|
+
const env = storeCreateEnvironment({
|
|
146
|
+
secret: 'test-api-key',
|
|
147
|
+
machineName: 'mac1',
|
|
148
|
+
ownerUserId: owner.id,
|
|
149
|
+
sessionMode: 'attach',
|
|
150
|
+
})
|
|
151
|
+
const stale = new Date(Date.now() - 10 * 60 * 1000)
|
|
152
|
+
storeUpdateEnvironment(env.id, { lastPollAt: stale })
|
|
153
|
+
|
|
154
|
+
const stranger = createUser(db)
|
|
155
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
156
|
+
headers: { Authorization: rctAuth(stranger.id) },
|
|
157
|
+
})
|
|
158
|
+
expect(res.status).toBe(403)
|
|
159
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
160
|
+
expect(body.error.type).toBe('forbidden')
|
|
161
|
+
// updatePollTime runs only AFTER the gate — a 403 must not keep a dead
|
|
162
|
+
// env looking alive.
|
|
163
|
+
expect(storeGetEnvironment(env.id)?.lastPollAt?.getTime()).toBe(
|
|
164
|
+
stale.getTime(),
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('assignment: unowned env (claim pending) + any auth → 200 assigned:false', async () => {
|
|
169
|
+
// Two-phase wait (claim → attach): while unowned there is nothing to
|
|
170
|
+
// disclose, so the poll stays open to any authenticated caller.
|
|
171
|
+
const env = storeCreateEnvironment({
|
|
172
|
+
secret: 'test-api-key',
|
|
173
|
+
machineName: 'unclaimed',
|
|
174
|
+
sessionMode: 'attach',
|
|
175
|
+
})
|
|
176
|
+
const stranger = createUser(db)
|
|
177
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
178
|
+
headers: { Authorization: rctAuth(stranger.id) },
|
|
179
|
+
})
|
|
180
|
+
expect(res.status).toBe(200)
|
|
181
|
+
expect(await res.json()).toEqual({ assigned: false })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('assignment: disconnected env + authorized caller → reactivated, not 404', async () => {
|
|
185
|
+
// A wait-mode worker whose env the disconnect monitor flipped (laptop
|
|
186
|
+
// sleep during the 24h wait, pre-fix claim-wait, etc.) must recover on
|
|
187
|
+
// its next poll instead of dead-waiting on swallowed 404s — the CLI
|
|
188
|
+
// ignores assignment errors until its deadline.
|
|
189
|
+
const env = storeCreateEnvironment({
|
|
190
|
+
secret: 'test-api-key',
|
|
191
|
+
machineName: 'mac1',
|
|
192
|
+
ownerUserId: owner.id,
|
|
193
|
+
sessionMode: 'attach',
|
|
194
|
+
})
|
|
195
|
+
storeUpdateEnvironment(env.id, { status: 'disconnected' })
|
|
196
|
+
|
|
197
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
198
|
+
headers: { ...ADMIN_KEY },
|
|
199
|
+
})
|
|
200
|
+
expect(res.status).toBe(200)
|
|
201
|
+
expect(await res.json()).toEqual({ assigned: false })
|
|
202
|
+
expect(storeGetEnvironment(env.id)?.status).toBe('active')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('assignment: disconnected env + stranger → 403, env NOT reactivated', async () => {
|
|
206
|
+
// Same invariant as the heartbeat test above: a 403 must not resurrect
|
|
207
|
+
// or keep alive an env the caller has no business touching.
|
|
208
|
+
const env = storeCreateEnvironment({
|
|
209
|
+
secret: 'test-api-key',
|
|
210
|
+
machineName: 'mac1',
|
|
211
|
+
ownerUserId: owner.id,
|
|
212
|
+
sessionMode: 'attach',
|
|
213
|
+
})
|
|
214
|
+
storeUpdateEnvironment(env.id, { status: 'disconnected' })
|
|
215
|
+
const stranger = createUser(db)
|
|
216
|
+
|
|
217
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
218
|
+
headers: { Authorization: rctAuth(stranger.id) },
|
|
219
|
+
})
|
|
220
|
+
expect(res.status).toBe(403)
|
|
221
|
+
expect(storeGetEnvironment(env.id)?.status).toBe('disconnected')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('assignment: deregistered env → 404 (terminal)', async () => {
|
|
225
|
+
const env = storeCreateEnvironment({
|
|
226
|
+
secret: 'test-api-key',
|
|
227
|
+
machineName: 'mac1',
|
|
228
|
+
ownerUserId: owner.id,
|
|
229
|
+
sessionMode: 'attach',
|
|
230
|
+
})
|
|
231
|
+
storeUpdateEnvironment(env.id, { status: 'deregistered' })
|
|
232
|
+
const res = await app.request(`/v1/environments/${env.id}/assignment`, {
|
|
233
|
+
headers: { ...ADMIN_KEY },
|
|
234
|
+
})
|
|
235
|
+
expect(res.status).toBe(404)
|
|
236
|
+
})
|
|
237
|
+
|
|
127
238
|
test('assignment: session bound to the env (web attach) → assigned with session id', async () => {
|
|
128
239
|
const env = storeCreateEnvironment({
|
|
129
240
|
secret: 'test-api-key',
|
|
@@ -207,33 +207,62 @@ describe('POST /v1/sessions/:id/attach', () => {
|
|
|
207
207
|
expect(body.idempotent).toBeUndefined()
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
-
test('idempotent re-entry bypasses manage-rights check (doReconnect re-bind)', async () => {
|
|
211
|
-
// Wait-mode scenario: a team admin bound the session to the
|
|
212
|
-
// the
|
|
213
|
-
// still succeed via the same-env idempotent path
|
|
210
|
+
test('idempotent re-entry bypasses manage-rights check for the env owner (doReconnect re-bind)', async () => {
|
|
211
|
+
// Wait-mode scenario: a team admin bound the session to the CLI worker's
|
|
212
|
+
// env; the worker itself has no manage rights on the session. The re-bind
|
|
213
|
+
// after env loss must still succeed via the same-env idempotent path —
|
|
214
|
+
// gated on env ownership (the worker owns its env), not session rights.
|
|
215
|
+
const world = seedWorld(db)
|
|
216
|
+
const cliOwner = createUser(db)
|
|
217
|
+
const cliEnv = storeCreateEnvironment({
|
|
218
|
+
secret: 'test-api-key',
|
|
219
|
+
machineName: 'cli-worker',
|
|
220
|
+
ownerUserId: cliOwner.id,
|
|
221
|
+
})
|
|
222
|
+
// The team-admin bind: the session is re-pointed to the CLI env with a
|
|
223
|
+
// live work item (bare admin API key performs the attach).
|
|
224
|
+
const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { ...JSON_HDRS, ...ADMIN_KEY },
|
|
227
|
+
body: JSON.stringify({ environment_id: cliEnv.id }),
|
|
228
|
+
})
|
|
229
|
+
expect(first.status).toBe(200)
|
|
230
|
+
const { work_item_id } = (await first.json()) as { work_item_id: string }
|
|
231
|
+
|
|
232
|
+
// Re-entry as the CLI owner (no manage rights, owns the env) → 200 with
|
|
233
|
+
// the SAME work item.
|
|
234
|
+
const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { ...JSON_HDRS, Authorization: rctAuth(cliOwner.id) },
|
|
237
|
+
body: JSON.stringify({ environment_id: cliEnv.id }),
|
|
238
|
+
})
|
|
239
|
+
expect(second.status).toBe(200)
|
|
240
|
+
const body = (await second.json()) as {
|
|
241
|
+
idempotent: boolean
|
|
242
|
+
work_item_id: string
|
|
243
|
+
}
|
|
244
|
+
expect(body.idempotent).toBe(true)
|
|
245
|
+
expect(body.work_item_id).toBe(work_item_id)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('idempotent re-entry by a non-owner who knows session+env IDs → 403', async () => {
|
|
249
|
+
// The idempotent path echoes the work-item id; a random authenticated
|
|
250
|
+
// caller must not be able to probe bindings. Falls through to the full
|
|
251
|
+
// checks → manage-rights 403.
|
|
214
252
|
const world = seedWorld(db)
|
|
215
|
-
const admin = createUser(db, 'admin')
|
|
216
|
-
// Attach once WITH rights (owner) so a work item exists on world.envId.
|
|
217
253
|
const first = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
218
254
|
method: 'POST',
|
|
219
255
|
headers: { ...JSON_HDRS, Authorization: rctAuth(world.owner.id) },
|
|
220
256
|
body: JSON.stringify({ environment_id: world.envId }),
|
|
221
257
|
})
|
|
222
258
|
expect(first.status).toBe(200)
|
|
223
|
-
void admin
|
|
224
259
|
|
|
225
|
-
// Re-entry as the STRANGER (no manage rights) but same env → 200.
|
|
226
|
-
// Note: env ownership also matches the stranger? No — the env belongs to
|
|
227
|
-
// owner; but the idempotent check runs BEFORE both the access and env
|
|
228
|
-
// ownership checks, so the re-entry returns the existing binding.
|
|
229
260
|
const second = await app.request(`/v1/sessions/${world.sessionId}/attach`, {
|
|
230
261
|
method: 'POST',
|
|
231
262
|
headers: { ...JSON_HDRS, Authorization: rctAuth(world.stranger.id) },
|
|
232
263
|
body: JSON.stringify({ environment_id: world.envId }),
|
|
233
264
|
})
|
|
234
|
-
expect(second.status).toBe(
|
|
235
|
-
const body = (await second.json()) as { idempotent: boolean }
|
|
236
|
-
expect(body.idempotent).toBe(true)
|
|
265
|
+
expect(second.status).toBe(403)
|
|
237
266
|
})
|
|
238
267
|
|
|
239
268
|
test("stranger's rct token → 403 (canManageSession, G9)", async () => {
|
|
@@ -284,6 +313,43 @@ describe('POST /v1/sessions/:id/attach', () => {
|
|
|
284
313
|
expect(body.error.type).toBe('environment_unowned')
|
|
285
314
|
})
|
|
286
315
|
|
|
316
|
+
test('bare admin API key attaches to an unowned env (documented worker setup)', async () => {
|
|
317
|
+
// Self-hosted workers connect with SALUZI_BRIDGE_OAUTH_TOKEN=<RCS_API_KEYS>
|
|
318
|
+
// and no SALUZI_BRIDGE_USERNAME — the env they register is unowned until
|
|
319
|
+
// claimed, and the direct-attach path never prints a claim URL. A hard
|
|
320
|
+
// 409 here made `/rc --attach <id>` fail out of the box. Bare admin keys
|
|
321
|
+
// are trusted credentials (ensureAttachAccess already audited their
|
|
322
|
+
// session-manage bypass) — the bind is allowed.
|
|
323
|
+
const owner = createUser(db)
|
|
324
|
+
const env = storeCreateEnvironment({
|
|
325
|
+
secret: 'test-api-key',
|
|
326
|
+
machineName: 'cli-worker',
|
|
327
|
+
})
|
|
328
|
+
const session = createSession({
|
|
329
|
+
environment_id: null,
|
|
330
|
+
title: 'Web session',
|
|
331
|
+
source: 'web',
|
|
332
|
+
})
|
|
333
|
+
storeBindSession(session.id, owner.id, 'user')
|
|
334
|
+
|
|
335
|
+
const res = await app.request(`/v1/sessions/${session.id}/attach`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: { ...JSON_HDRS, ...ADMIN_KEY },
|
|
338
|
+
body: JSON.stringify({ environment_id: env.id }),
|
|
339
|
+
})
|
|
340
|
+
expect(res.status).toBe(200)
|
|
341
|
+
const body = (await res.json()) as {
|
|
342
|
+
status: string
|
|
343
|
+
session_id: string
|
|
344
|
+
work_item_id: string
|
|
345
|
+
rebind: boolean
|
|
346
|
+
}
|
|
347
|
+
expect(body.status).toBe('attached')
|
|
348
|
+
expect(body.session_id).toBe(session.id)
|
|
349
|
+
expect(body.work_item_id).toMatch(/^work_/)
|
|
350
|
+
expect(body.rebind).toBe(true)
|
|
351
|
+
})
|
|
352
|
+
|
|
287
353
|
test('archived session → 409 session_closed', async () => {
|
|
288
354
|
const world = seedWorld(db)
|
|
289
355
|
const { archiveSession } = await import('../services/session')
|
|
@@ -411,6 +477,19 @@ describe('GET /v1/sessions/attachable', () => {
|
|
|
411
477
|
const list = (await res.json()) as Array<{ id: string }>
|
|
412
478
|
expect(list.some(s => s.id === world.sessionId)).toBe(true)
|
|
413
479
|
})
|
|
480
|
+
|
|
481
|
+
test('admin API key + unresolvable X-Username → 403 (consistent with attach)', async () => {
|
|
482
|
+
// An explicit-but-unknown identity is a misconfiguration, not "see
|
|
483
|
+
// everything" — the listing must be exactly as strict as
|
|
484
|
+
// POST /:id/attach's ensureAttachAccess.
|
|
485
|
+
await seedWorld(db)
|
|
486
|
+
const res = await app.request('/v1/sessions/attachable', {
|
|
487
|
+
headers: { ...ADMIN_KEY, 'X-Username': 'no_such_user_xyz' },
|
|
488
|
+
})
|
|
489
|
+
expect(res.status).toBe(403)
|
|
490
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
491
|
+
expect(body.error.type).toBe('forbidden')
|
|
492
|
+
})
|
|
414
493
|
})
|
|
415
494
|
|
|
416
495
|
describe('GET /v1/sessions/:id/events (bridge auth)', () => {
|
|
@@ -518,4 +597,21 @@ describe('storeRebindSessionEnvironment', () => {
|
|
|
518
597
|
storeReset()
|
|
519
598
|
expect(storeRebindSessionEnvironment('ses_missing', 'env_x')).toBe(false)
|
|
520
599
|
})
|
|
600
|
+
|
|
601
|
+
test('rebinding to null clears the binding (dispatch-failure rollback)', () => {
|
|
602
|
+
const db = freshDb()
|
|
603
|
+
storeReset()
|
|
604
|
+
const world = seedWorld(db)
|
|
605
|
+
const web = createSession({
|
|
606
|
+
environment_id: null,
|
|
607
|
+
title: 'Web session',
|
|
608
|
+
source: 'web',
|
|
609
|
+
})
|
|
610
|
+
expect(storeRebindSessionEnvironment(web.id, world.envId)).toBe(true)
|
|
611
|
+
expect(storeRebindSessionEnvironment(web.id, null)).toBe(true)
|
|
612
|
+
const row = db
|
|
613
|
+
.query('SELECT environment_id FROM sessions WHERE id = $id')
|
|
614
|
+
.get({ $id: web.id }) as { environment_id: string | null }
|
|
615
|
+
expect(row.environment_id).toBeNull()
|
|
616
|
+
})
|
|
521
617
|
})
|
|
@@ -10,8 +10,11 @@ import { Hono } from 'hono'
|
|
|
10
10
|
import {
|
|
11
11
|
storeReset,
|
|
12
12
|
storeCreateSession,
|
|
13
|
+
storeCreateEnvironment,
|
|
13
14
|
storeBindSession,
|
|
14
15
|
storeListSessionsByOwnerUuid,
|
|
16
|
+
storeListSessionsByEnvironment,
|
|
17
|
+
storeGetActiveWorkItemBySession,
|
|
15
18
|
} from '../store'
|
|
16
19
|
import { initDatabase, resetDbSingleton } from '../db/sqlite'
|
|
17
20
|
import { issueToken } from '../auth/token'
|
|
@@ -176,3 +179,95 @@ describe('M2: resolveOwnedWebSessionId removes redundant fallback', () => {
|
|
|
176
179
|
expect(source).not.toMatch(/import\s*\{[^}]*storeIsSessionOwner[^}]*\}/)
|
|
177
180
|
})
|
|
178
181
|
})
|
|
182
|
+
|
|
183
|
+
// =============================================================================
|
|
184
|
+
// worker_at_capacity pre-validation: the repl-worker capacity check must run
|
|
185
|
+
// BEFORE createSession. It used to run after, where the just-created session
|
|
186
|
+
// (already bound to body.environment_id) always tripped it — every web session
|
|
187
|
+
// creation on a repl worker env 409'd AND left a stray bound-but-never-
|
|
188
|
+
// dispatched session behind on each retry.
|
|
189
|
+
// =============================================================================
|
|
190
|
+
describe('worker_at_capacity pre-validation (repl workers)', () => {
|
|
191
|
+
let app: Hono
|
|
192
|
+
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
freshDb()
|
|
195
|
+
storeReset()
|
|
196
|
+
app = createApp()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('idle repl worker env → 200, session created AND work item dispatched', async () => {
|
|
200
|
+
const env = storeCreateEnvironment({
|
|
201
|
+
secret: 'test-api-key',
|
|
202
|
+
machineName: 'cli-worker',
|
|
203
|
+
workerType: 'repl',
|
|
204
|
+
})
|
|
205
|
+
const res = await app.request('/web/sessions', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {
|
|
208
|
+
Authorization: userAuth('webuser'),
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({ title: 'On Worker', environment_id: env.id }),
|
|
212
|
+
})
|
|
213
|
+
expect(res.status).toBe(200)
|
|
214
|
+
const body = (await res.json()) as { id: string; environment_id: string }
|
|
215
|
+
expect(body.environment_id).toBe(env.id)
|
|
216
|
+
// Pre-fix the 409 returned before createWorkItem — the CLI would poll a
|
|
217
|
+
// session that never received work.
|
|
218
|
+
const work = storeGetActiveWorkItemBySession(body.id)
|
|
219
|
+
expect(work).not.toBeNull()
|
|
220
|
+
expect(work?.environmentId).toBe(env.id)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('repl env with an open session → 409 and NO stray session left behind', async () => {
|
|
224
|
+
const env = storeCreateEnvironment({
|
|
225
|
+
secret: 'test-api-key',
|
|
226
|
+
machineName: 'cli-worker',
|
|
227
|
+
workerType: 'repl',
|
|
228
|
+
})
|
|
229
|
+
storeCreateSession({
|
|
230
|
+
environmentId: env.id,
|
|
231
|
+
title: 'Existing',
|
|
232
|
+
source: 'web',
|
|
233
|
+
})
|
|
234
|
+
const before = storeListSessionsByEnvironment(env.id).length
|
|
235
|
+
|
|
236
|
+
const res = await app.request('/web/sessions', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: userAuth('webuser'),
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify({ title: 'Overflow', environment_id: env.id }),
|
|
243
|
+
})
|
|
244
|
+
expect(res.status).toBe(409)
|
|
245
|
+
const body = (await res.json()) as { error: { type: string } }
|
|
246
|
+
expect(body.error.type).toBe('worker_at_capacity')
|
|
247
|
+
// Pre-fix the session record was created (and bound) before the 409.
|
|
248
|
+
expect(storeListSessionsByEnvironment(env.id).length).toBe(before)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('non-repl (spawn) env skips the capacity check', async () => {
|
|
252
|
+
const env = storeCreateEnvironment({
|
|
253
|
+
secret: 'test-api-key',
|
|
254
|
+
machineName: 'spawn-worker',
|
|
255
|
+
workerType: 'claude_code',
|
|
256
|
+
maxSessions: 4,
|
|
257
|
+
})
|
|
258
|
+
storeCreateSession({
|
|
259
|
+
environmentId: env.id,
|
|
260
|
+
title: 'Existing',
|
|
261
|
+
source: 'web',
|
|
262
|
+
})
|
|
263
|
+
const res = await app.request('/web/sessions', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: userAuth('webuser'),
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify({ title: 'Second', environment_id: env.id }),
|
|
270
|
+
})
|
|
271
|
+
expect(res.status).toBe(200)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
storeCreateSession,
|
|
11
11
|
storeCreateEnvironment,
|
|
12
12
|
storeBindSession,
|
|
13
|
+
storeUpdateEnvironment,
|
|
14
|
+
storeGetEnvironment,
|
|
15
|
+
storeGetActiveWorkItemBySession,
|
|
13
16
|
} from '../store'
|
|
14
17
|
import {
|
|
15
18
|
removeEventBus,
|
|
@@ -323,6 +326,72 @@ describe('V1 Session Routes', () => {
|
|
|
323
326
|
expect(body.id).toMatch(/^session_/)
|
|
324
327
|
})
|
|
325
328
|
|
|
329
|
+
test('POST /v1/sessions with environment_id refreshes env liveness (claim-wait retry loop)', async () => {
|
|
330
|
+
// An unowned env's claim-wait loop retries POST /v1/sessions every 5s
|
|
331
|
+
// (409 environment_unowned) for up to 24h — that retry IS the worker's
|
|
332
|
+
// only traffic. Without touching lastPollAt here the disconnect monitor
|
|
333
|
+
// flips the env after RCS_DISCONNECT_TIMEOUT, and when the claim finally
|
|
334
|
+
// lands the work item dispatch rejects the non-active env: a session
|
|
335
|
+
// that exists yet never receives work (the CLI polls 204s forever).
|
|
336
|
+
ensureUser('testuser')
|
|
337
|
+
const envRes = await app.request('/v1/environments/bridge', {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: {
|
|
340
|
+
...AUTH_HEADERS,
|
|
341
|
+
'Content-Type': 'application/json',
|
|
342
|
+
'X-User-Id': 'testuser',
|
|
343
|
+
},
|
|
344
|
+
body: JSON.stringify({ machine_name: 'test' }),
|
|
345
|
+
})
|
|
346
|
+
const { environment_id } = await envRes.json()
|
|
347
|
+
// Age the heartbeat past the disconnect timeout, then simulate one
|
|
348
|
+
// claim-wait retry.
|
|
349
|
+
const stale = new Date(Date.now() - 10 * 60 * 1000)
|
|
350
|
+
storeUpdateEnvironment(environment_id, { lastPollAt: stale })
|
|
351
|
+
const before = storeGetEnvironment(environment_id)?.lastPollAt?.getTime()
|
|
352
|
+
|
|
353
|
+
const sessRes = await app.request('/v1/sessions', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
356
|
+
body: JSON.stringify({ environment_id }),
|
|
357
|
+
})
|
|
358
|
+
expect(sessRes.status).toBe(200)
|
|
359
|
+
const after = storeGetEnvironment(environment_id)?.lastPollAt?.getTime()
|
|
360
|
+
expect(after).not.toBe(before)
|
|
361
|
+
expect(after ?? 0).toBeGreaterThan(stale.getTime())
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('POST /v1/sessions reactivates a monitor-flipped env so the work item is created', async () => {
|
|
365
|
+
// Recovery path for envs already flipped to 'disconnected' (CLI waited
|
|
366
|
+
// out the timeout before these fixes, laptop sleep, etc.): the retry
|
|
367
|
+
// reactivates the env, so the session gets its work item instead of
|
|
368
|
+
// becoming a zombie the CLI polls forever.
|
|
369
|
+
ensureUser('testuser')
|
|
370
|
+
const envRes = await app.request('/v1/environments/bridge', {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: {
|
|
373
|
+
...AUTH_HEADERS,
|
|
374
|
+
'Content-Type': 'application/json',
|
|
375
|
+
'X-User-Id': 'testuser',
|
|
376
|
+
},
|
|
377
|
+
body: JSON.stringify({ machine_name: 'test' }),
|
|
378
|
+
})
|
|
379
|
+
const { environment_id } = await envRes.json()
|
|
380
|
+
storeUpdateEnvironment(environment_id, { status: 'disconnected' })
|
|
381
|
+
|
|
382
|
+
const sessRes = await app.request('/v1/sessions', {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
385
|
+
body: JSON.stringify({ environment_id }),
|
|
386
|
+
})
|
|
387
|
+
expect(sessRes.status).toBe(200)
|
|
388
|
+
const body = await sessRes.json()
|
|
389
|
+
expect(storeGetEnvironment(environment_id)?.status).toBe('active')
|
|
390
|
+
const work = storeGetActiveWorkItemBySession(body.id)
|
|
391
|
+
expect(work).not.toBeNull()
|
|
392
|
+
expect(work?.environmentId).toBe(environment_id)
|
|
393
|
+
})
|
|
394
|
+
|
|
326
395
|
test('POST /v1/sessions with events — publishes initial events', async () => {
|
|
327
396
|
const sessRes = await app.request('/v1/sessions', {
|
|
328
397
|
method: 'POST',
|
|
@@ -74,3 +74,47 @@ export function resolveUserIdUuid(
|
|
|
74
74
|
.get({ $value: value }) as { id: string } | null
|
|
75
75
|
return row?.id ?? null
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Caller identity resolved for attach-family endpoints. */
|
|
79
|
+
export type AttachCaller = {
|
|
80
|
+
/** Resolved users.id UUID — null for a bare admin API key. */
|
|
81
|
+
uuid: string | null
|
|
82
|
+
isApiAdmin: boolean
|
|
83
|
+
/** True when no identity at all was presented (bare admin API key). */
|
|
84
|
+
hasIdentity: boolean
|
|
85
|
+
/**
|
|
86
|
+
* True when the users-table lookup itself failed (DB unavailable). Routes
|
|
87
|
+
* must return 503 BEFORE treating uuid:null as an unresolvable identity —
|
|
88
|
+
* a DB outage must not masquerade as a 403.
|
|
89
|
+
*/
|
|
90
|
+
dbUnavailable: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the bridge caller identity for attach endpoints.
|
|
95
|
+
*
|
|
96
|
+
* bridgeAuth's userId may be a UUID (rct_* token), a username (in-memory
|
|
97
|
+
* token) or an arbitrary X-Username string (shared admin API key) — only
|
|
98
|
+
* users-table UUIDs can hold session management rights, so everything goes
|
|
99
|
+
* through resolveUserIdUuid (G9: the API layer must self-enforce
|
|
100
|
+
* canManageSession; the ingress WS accepting a global API key is not a
|
|
101
|
+
* boundary we may lean on).
|
|
102
|
+
*/
|
|
103
|
+
export function resolveAttachCaller(c: Context): AttachCaller {
|
|
104
|
+
const raw = c.get('userId') as string | undefined
|
|
105
|
+
const isApiAdmin = (c.get('isApiAdmin') as boolean | undefined) ?? false
|
|
106
|
+
if (!raw) {
|
|
107
|
+
return { uuid: null, isApiAdmin, hasIdentity: false, dbUnavailable: false }
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return {
|
|
111
|
+
uuid: resolveUserIdUuid(getDb(), raw),
|
|
112
|
+
isApiAdmin,
|
|
113
|
+
hasIdentity: true,
|
|
114
|
+
dbUnavailable: false,
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[bridge] DB error resolving attach caller identity:', err)
|
|
118
|
+
return { uuid: null, isApiAdmin, hasIdentity: true, dbUnavailable: true }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -4,10 +4,14 @@ import {
|
|
|
4
4
|
registerEnvironment,
|
|
5
5
|
deregisterEnvironment,
|
|
6
6
|
reconnectEnvironment,
|
|
7
|
-
|
|
7
|
+
touchEnvironmentActivity,
|
|
8
8
|
} from '../../services/environment'
|
|
9
9
|
import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
bridgeAuth,
|
|
12
|
+
resolveAttachCaller,
|
|
13
|
+
resolveUserIdUuid,
|
|
14
|
+
} from '../../auth/bridge-auth'
|
|
11
15
|
import { getDb } from '../../db/sqlite'
|
|
12
16
|
import { isSessionClosedStatus } from '../../services/session'
|
|
13
17
|
import {
|
|
@@ -282,22 +286,76 @@ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => {
|
|
|
282
286
|
* waiting for the Web UI to bind a session (POST /v1/sessions/:id/attach).
|
|
283
287
|
* Returns the first open, non-ACP session on the environment, or
|
|
284
288
|
* assigned:false to keep waiting. Long-polling is unnecessary — the worker
|
|
285
|
-
* already runs a poll loop cadence for work items.
|
|
289
|
+
* already runs a poll loop cadence for work items.
|
|
290
|
+
* Access: the env owner (the polling worker itself) or an admin. */
|
|
286
291
|
app.get('/:id/assignment', acceptCliHeaders, bridgeAuth, async c => {
|
|
287
292
|
const envId = c.req.param('id')!
|
|
288
293
|
const env = storeGetEnvironment(envId)
|
|
289
|
-
|
|
294
|
+
// 'disconnected' is NOT a 404 here: the ownership gate below authorizes the
|
|
295
|
+
// caller first, then touchEnvironmentActivity reactivates the env — a live
|
|
296
|
+
// wait-mode worker polling a monitor-flipped env recovers instead of
|
|
297
|
+
// dead-waiting on swallowed 404s for its 24h deadline. Deregistered is
|
|
298
|
+
// terminal.
|
|
299
|
+
if (!env || env.status === 'deregistered') {
|
|
290
300
|
return c.json(
|
|
291
301
|
{ error: { type: 'not_found', message: 'Environment not found' } },
|
|
292
302
|
404,
|
|
293
303
|
)
|
|
294
304
|
}
|
|
305
|
+
|
|
306
|
+
// Ownership gate: assignment discloses the env's bound session AND doubles
|
|
307
|
+
// as its liveness signal (touchEnvironmentActivity below) — both are
|
|
308
|
+
// restricted to the env owner (the wait-mode worker polls under the same
|
|
309
|
+
// identity it registered with) and admins. Unowned envs (claim pending)
|
|
310
|
+
// stay open: nothing is bound yet, so there is nothing to disclose or keep
|
|
311
|
+
// alive.
|
|
312
|
+
const caller = resolveAttachCaller(c)
|
|
313
|
+
if (caller.dbUnavailable) {
|
|
314
|
+
return c.json(
|
|
315
|
+
{
|
|
316
|
+
error: {
|
|
317
|
+
type: 'database_unavailable',
|
|
318
|
+
message: 'Database unavailable',
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
503,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
let ownerUserId: string | null = null
|
|
325
|
+
try {
|
|
326
|
+
ownerUserId = storeGetEnvironmentOwnership(envId)?.ownerUserId ?? null
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error('[bridge] DB error during assignment ownership lookup:', err)
|
|
329
|
+
return c.json(
|
|
330
|
+
{
|
|
331
|
+
error: {
|
|
332
|
+
type: 'database_unavailable',
|
|
333
|
+
message: 'Database unavailable',
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
503,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
if (ownerUserId && !caller.isApiAdmin && caller.uuid !== ownerUserId) {
|
|
340
|
+
return c.json(
|
|
341
|
+
{
|
|
342
|
+
error: {
|
|
343
|
+
type: 'forbidden',
|
|
344
|
+
message: 'Not allowed to poll this environment',
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
403,
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
295
351
|
// The attach-wait worker has no work item in flight, so the work-poll
|
|
296
352
|
// route never refreshes its heartbeat. This poll IS its liveness signal:
|
|
297
353
|
// without touching lastPollAt the disconnect monitor flips the env to
|
|
298
|
-
// disconnected after the idle timeout
|
|
299
|
-
//
|
|
300
|
-
|
|
354
|
+
// disconnected after the idle timeout and the CLI silently spins until its
|
|
355
|
+
// 24h deadline (P0-2). touchEnvironmentActivity also reactivates an env the
|
|
356
|
+
// monitor already flipped (runs only after the gate — a 403 must not keep
|
|
357
|
+
// a dead env looking alive).
|
|
358
|
+
touchEnvironmentActivity(envId)
|
|
301
359
|
const assigned = storeListSessionsByEnvironment(envId)
|
|
302
360
|
.filter(s => !isSessionClosedStatus(s.status))
|
|
303
361
|
.filter(s => s.source !== 'acp')
|
|
@@ -6,14 +6,14 @@ import {
|
|
|
6
6
|
heartbeatWork,
|
|
7
7
|
} from '../../services/work-dispatch'
|
|
8
8
|
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
|
|
9
|
-
import {
|
|
9
|
+
import { touchEnvironmentActivity } from '../../services/environment'
|
|
10
10
|
|
|
11
11
|
const app = new Hono()
|
|
12
12
|
|
|
13
13
|
/** GET /v1/environments/:id/work/poll — Long-poll for work */
|
|
14
14
|
app.get('/:id/work/poll', acceptCliHeaders, apiKeyAuth, async c => {
|
|
15
15
|
const envId = c.req.param('id')!
|
|
16
|
-
|
|
16
|
+
touchEnvironmentActivity(envId)
|
|
17
17
|
const result = await pollWork(envId)
|
|
18
18
|
if (!result) {
|
|
19
19
|
// Return 204 No Content so the client's axios parses it as null
|