@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.2.64",
3
+ "version": "0.2.66",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 caller's env;
212
- // the caller itself has no manage rights. The re-bind after env loss must
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(200)
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
- updatePollTime,
7
+ touchEnvironmentActivity,
8
8
  } from '../../services/environment'
9
9
  import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
10
- import { bridgeAuth, resolveUserIdUuid } from '../../auth/bridge-auth'
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
- if (!env || env.status !== 'active') {
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, assignment starts 404ing, and the
299
- // CLI silently spins until its 24h deadline (P0-2).
300
- updatePollTime(envId)
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 { updatePollTime } from '../../services/environment'
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
- updatePollTime(envId)
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