@saluzi/saluzi-edu 0.2.63 → 0.2.65

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.
@@ -1,19 +1,153 @@
1
1
  import { log, error as logError } from '../../logger'
2
2
  import { Hono } from 'hono'
3
3
  import {
4
+ archiveSession,
5
+ canManageSession,
4
6
  createSession,
5
7
  getSession,
6
- updateSessionTitle,
7
- archiveSession,
8
+ isSessionClosedStatus,
8
9
  resolveExistingSessionId,
10
+ updateSessionTitle,
9
11
  } from '../../services/session'
10
12
  import { createWorkItem } from '../../services/work-dispatch'
11
- import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
12
13
  import { publishSessionEvent } from '../../services/transport'
13
- import { storeBindSession, storeGetEnvironmentOwnership } from '../../store'
14
+ import { loadPersistedEvents } from '../../services/event-persistence'
15
+ import { getEventBus } from '../../transport/event-bus'
16
+ import { acceptCliHeaders, apiKeyAuth } from '../../auth/middleware'
17
+ import {
18
+ bridgeAuth,
19
+ resolveAttachCaller,
20
+ type AttachCaller,
21
+ } from '../../auth/bridge-auth'
22
+ import { getDb } from '../../db/sqlite'
23
+ import {
24
+ storeBindSession,
25
+ storeConsumeAttachCode,
26
+ storeGetEnvironment,
27
+ storeGetEnvironmentOwnership,
28
+ storeGetActiveWorkItemBySession,
29
+ storeListSessions,
30
+ storeListSessionsByEnvironment,
31
+ storeRebindSessionEnvironment,
32
+ type SessionRecord,
33
+ } from '../../store'
14
34
 
15
35
  const app = new Hono()
16
36
 
37
+ // ---------------------------------------------------------------------------
38
+ // Session Attach — bind an existing session to a CLI-registered environment
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** 503 body shared by every attach-family route on a DB outage. */
42
+ function dbUnavailable(c: import('hono').Context): Response {
43
+ return c.json(
44
+ {
45
+ error: {
46
+ type: 'database_unavailable',
47
+ message: 'Database unavailable',
48
+ },
49
+ },
50
+ 503,
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Management check for attach endpoints. Returns an error Response, or null
56
+ * when the caller may manage the session:
57
+ * - rct (SQLite) or in-memory identity → must resolve to a user with manage
58
+ * rights (canManageSession: direct owner, team owner/admin, system admin)
59
+ * - bare admin API key (no identity) → allowed with an audit trail
60
+ * (RCS_API_KEYS are admin credentials — mirrors validateTeamBinding)
61
+ */
62
+ function ensureAttachAccess(
63
+ c: import('hono').Context,
64
+ caller: AttachCaller,
65
+ sessionId: string,
66
+ ): Response | null {
67
+ if (caller.uuid) {
68
+ if (!canManageSession(sessionId, caller.uuid, getDb())) {
69
+ return c.json(
70
+ {
71
+ error: {
72
+ type: 'forbidden',
73
+ message: 'Not allowed to manage this session',
74
+ },
75
+ },
76
+ 403,
77
+ )
78
+ }
79
+ return null
80
+ }
81
+ if (!caller.hasIdentity && caller.isApiAdmin) {
82
+ console.log(`[audit] Bare admin API key attached session ${sessionId}`)
83
+ return null
84
+ }
85
+ return c.json(
86
+ {
87
+ error: {
88
+ type: caller.hasIdentity ? 'forbidden' : 'unauthorized',
89
+ message: caller.hasIdentity
90
+ ? 'Identity does not resolve to a user'
91
+ : 'User identity required to attach sessions',
92
+ },
93
+ },
94
+ caller.hasIdentity ? 403 : 401,
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Occupancy check shared by POST /:id/attach and GET /attachable.
100
+ * A session is occupied when:
101
+ * 1. A non-completed work item exists on a DIFFERENT, STILL-ACTIVE
102
+ * environment (a live worker holds the lease). A work item on a
103
+ * dead/deregistered env does NOT occupy: a crashed attach CLI's acked
104
+ * item would otherwise block re-attach forever (deregisterEnvironment
105
+ * completes that env's open items too, but a kill -9 that skips the
106
+ * deregister leaves the item behind until the server restarts — the
107
+ * env-status check is the recovery path).
108
+ * 2. No work item remains but the session is mid-turn (running /
109
+ * requires_action) on a live, active, non-ACP environment.
110
+ * An active work item on the TARGET environment is not occupied — that is
111
+ * the idempotent re-entry path (CLI retry after a network blip).
112
+ */
113
+ function isSessionOccupied(
114
+ sessionId: string,
115
+ targetEnvId: string | null,
116
+ ): boolean {
117
+ const active = storeGetActiveWorkItemBySession(sessionId)
118
+ if (active) {
119
+ if (active.environmentId === targetEnvId) return false
120
+ const holdingEnv = storeGetEnvironment(active.environmentId)
121
+ // No env record / not active → the lease holder is gone; the session is
122
+ // free to attach.
123
+ if (!holdingEnv || holdingEnv.status !== 'active') return false
124
+ return true
125
+ }
126
+ const session = getSession(sessionId)
127
+ if (!session) return false
128
+ if (session.status !== 'running' && session.status !== 'requires_action') {
129
+ return false
130
+ }
131
+ if (!session.environment_id || session.environment_id === targetEnvId) {
132
+ return false
133
+ }
134
+ const curEnv = storeGetEnvironment(session.environment_id)
135
+ return !!curEnv && curEnv.status === 'active' && curEnv.workerType !== 'acp'
136
+ }
137
+
138
+ /** Shape returned by GET /attachable and consumed by the CLI picker. */
139
+ function toAttachableEntry(session: SessionRecord) {
140
+ return {
141
+ id: session.id,
142
+ title: session.title,
143
+ status: session.status,
144
+ source: session.source,
145
+ environment_id: session.environmentId,
146
+ updated_at: session.updatedAt.getTime() / 1000,
147
+ busy: isSessionOccupied(session.id, null),
148
+ }
149
+ }
150
+
17
151
  /** POST /v1/sessions — Create session.
18
152
  * Used by replBridge/ACP workers to auto-create their initial session.
19
153
  * Session ownership: user owner = env.owner_user_id, team owner = env.team_id
@@ -84,6 +218,460 @@ app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
84
218
  return c.json(session, 200)
85
219
  })
86
220
 
221
+ /** GET /v1/sessions/attachable — List sessions the caller can attach to.
222
+ * Must be registered BEFORE GET /:id so "attachable" is not captured as an
223
+ * id. Visibility = canManageSession (direct owner, team owner/admin, system
224
+ * admin); bare admin API key sees all. ACP sessions are excluded — their
225
+ * transport is bound to the acp-link WS and cannot be driven by a bridge
226
+ * worker. */
227
+ app.get('/attachable', bridgeAuth, async c => {
228
+ const caller = resolveAttachCaller(c)
229
+ if (caller.dbUnavailable) {
230
+ return dbUnavailable(c)
231
+ }
232
+ // An explicit-but-unresolvable identity (e.g. admin API key +
233
+ // X-Username matching no user) is a misconfiguration, not "see
234
+ // everything": 403, mirroring POST /:id/attach's ensureAttachAccess —
235
+ // the listing previously was the laxer of the two.
236
+ if (caller.uuid === null && caller.hasIdentity) {
237
+ return c.json(
238
+ {
239
+ error: {
240
+ type: 'forbidden',
241
+ message: 'Identity does not resolve to a user',
242
+ },
243
+ },
244
+ 403,
245
+ )
246
+ }
247
+ if (caller.uuid === null && !caller.hasIdentity && !caller.isApiAdmin) {
248
+ return c.json(
249
+ { error: { type: 'unauthorized', message: 'User identity required' } },
250
+ 401,
251
+ )
252
+ }
253
+
254
+ const uuid = caller.uuid
255
+ const open = storeListSessions()
256
+ .filter(s => !isSessionClosedStatus(s.status))
257
+ .filter(s => s.source !== 'acp')
258
+ // Bare admin API key (uuid null) sees all; an identity sees only what it
259
+ // can manage. getDb() is touched only when the filter actually runs.
260
+ let visible = open
261
+ if (uuid !== null) {
262
+ const db = getDb()
263
+ visible = open.filter(s => canManageSession(s.id, uuid, db))
264
+ }
265
+
266
+ return c.json(
267
+ visible.map(s => toAttachableEntry(s)),
268
+ 200,
269
+ )
270
+ })
271
+
272
+ /** POST /v1/sessions/:id/attach — Bind an existing session to a CLI
273
+ * environment (Session Attach). Re-points session.environment_id (ownership
274
+ * in session_owners is NEVER touched) and dispatches a work item so the
275
+ * polling worker picks the session up through the standard work path.
276
+ * Idempotent: an active work item on the same environment returns 200 with
277
+ * the existing binding instead of 409 (CLI network retries). */
278
+ app.post('/:id/attach', bridgeAuth, async c => {
279
+ const caller = resolveAttachCaller(c)
280
+ const sessionId =
281
+ resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
282
+ const body = await c.req.json().catch(() => ({}) as Record<string, unknown>)
283
+ const envId =
284
+ typeof body.environment_id === 'string' ? body.environment_id : undefined
285
+ if (!envId) {
286
+ return c.json(
287
+ {
288
+ error: {
289
+ type: 'bad_request',
290
+ message: 'environment_id is required',
291
+ },
292
+ },
293
+ 400,
294
+ )
295
+ }
296
+ return performAttach(c, {
297
+ sessionId,
298
+ envId,
299
+ caller,
300
+ skipAccessCheck: false,
301
+ })
302
+ })
303
+
304
+ /**
305
+ * Core attach logic shared by POST /:id/attach (identity-based) and
306
+ * POST /attach-code/exchange (code-based — the one-time code replaces the
307
+ * caller's own manage rights, everything else is identical).
308
+ */
309
+ async function performAttach(
310
+ c: import('hono').Context,
311
+ opts: {
312
+ sessionId: string
313
+ envId: string
314
+ caller: AttachCaller
315
+ skipAccessCheck: boolean
316
+ },
317
+ ): Promise<Response> {
318
+ const { sessionId, envId, caller, skipAccessCheck } = opts
319
+ if (caller.dbUnavailable) {
320
+ return dbUnavailable(c)
321
+ }
322
+ const session = getSession(sessionId)
323
+ if (!session) {
324
+ return c.json(
325
+ { error: { type: 'not_found', message: 'Session not found' } },
326
+ 404,
327
+ )
328
+ }
329
+
330
+ // Idempotent re-entry BEFORE session-manage authorization: a CLI retrying
331
+ // after a network blip re-presents the same session+env pair — returning
332
+ // the existing binding is not a new grant, so it must not depend on the
333
+ // caller still holding manage rights (e.g. a doReconnect re-bind where the
334
+ // bound session was assigned by a team admin, or an expired share). The
335
+ // check only matches an existing work item on the SAME environment — no
336
+ // state is created.
337
+ //
338
+ // Still gated on environment ownership (or admin): the CLI re-entering is
339
+ // the env's own worker, so ownership always holds for legitimate retries,
340
+ // while a random authenticated caller who merely knows session+env IDs
341
+ // must not be able to probe the binding (the response echoes the work
342
+ // item id). Non-owners fall through to the full checks below, which
343
+ // reject them.
344
+ const activeItem = storeGetActiveWorkItemBySession(sessionId)
345
+ if (activeItem && activeItem.environmentId === envId) {
346
+ let idempotentOwner = false
347
+ try {
348
+ const ownership = storeGetEnvironmentOwnership(envId)
349
+ idempotentOwner =
350
+ caller.isApiAdmin ||
351
+ (caller.uuid !== null && ownership?.ownerUserId === caller.uuid)
352
+ } catch (err) {
353
+ logError(
354
+ `[RCS] Ownership lookup failed during attach re-entry (session=${sessionId} env=${envId}): ${err}`,
355
+ )
356
+ return dbUnavailable(c)
357
+ }
358
+ if (idempotentOwner) {
359
+ log(
360
+ `[RCS] Attach idempotent re-entry: session=${sessionId} env=${envId} work=${activeItem.id}`,
361
+ )
362
+ return c.json(
363
+ {
364
+ status: 'attached',
365
+ session_id: sessionId,
366
+ work_item_id: activeItem.id,
367
+ rebind: false,
368
+ idempotent: true,
369
+ },
370
+ 200,
371
+ )
372
+ }
373
+ }
374
+
375
+ // Authorization: identity-based attach requires manage rights (G9). The
376
+ // code-based path skips this — presenting a valid unconsumed code IS the
377
+ // authorization (the code was minted by a session manager).
378
+ if (!skipAccessCheck) {
379
+ const accessError = ensureAttachAccess(c, caller, sessionId)
380
+ if (accessError) return accessError
381
+ }
382
+
383
+ if (isSessionClosedStatus(session.status)) {
384
+ return c.json(
385
+ {
386
+ error: {
387
+ type: 'session_closed',
388
+ message: `Session is ${session.status}`,
389
+ },
390
+ },
391
+ 409,
392
+ )
393
+ }
394
+
395
+ // ACP sessions are transport-bound to the acp-link WebSocket.
396
+ if (session.source === 'acp') {
397
+ return c.json(
398
+ {
399
+ error: {
400
+ type: 'acp_session',
401
+ message: 'ACP sessions cannot be attached to a bridge worker',
402
+ },
403
+ },
404
+ 409,
405
+ )
406
+ }
407
+
408
+ const env = storeGetEnvironment(envId)
409
+ if (!env || env.status === 'deregistered') {
410
+ return c.json(
411
+ { error: { type: 'not_found', message: 'Environment not found' } },
412
+ 404,
413
+ )
414
+ }
415
+ if (env.status !== 'active') {
416
+ return c.json(
417
+ {
418
+ error: {
419
+ type: 'environment_inactive',
420
+ message: `Environment is ${env.status}`,
421
+ },
422
+ },
423
+ 409,
424
+ )
425
+ }
426
+ if (env.workerType === 'acp') {
427
+ return c.json(
428
+ {
429
+ error: {
430
+ type: 'acp_environment',
431
+ message: 'ACP environments manage their own sessions',
432
+ },
433
+ },
434
+ 409,
435
+ )
436
+ }
437
+
438
+ // Environment ownership: the attaching worker must be driving an env it
439
+ // owns (the env it just registered). Admin may attach on behalf.
440
+ const ownership = storeGetEnvironmentOwnership(envId)
441
+ if (!ownership?.ownerUserId) {
442
+ return c.json(
443
+ {
444
+ error: {
445
+ type: 'environment_unowned',
446
+ message:
447
+ 'Environment has no owner yet; claim it via the printed URL before attaching sessions',
448
+ },
449
+ },
450
+ 409,
451
+ )
452
+ }
453
+ if (!caller.isApiAdmin && ownership.ownerUserId !== caller.uuid) {
454
+ return c.json(
455
+ {
456
+ error: {
457
+ type: 'forbidden',
458
+ message: 'Cannot attach to an environment owned by another user',
459
+ },
460
+ },
461
+ 403,
462
+ )
463
+ }
464
+
465
+ // In-process (replBridge) workers serve exactly one session at a time.
466
+ if (env.workerType === 'repl') {
467
+ const openOthers = storeListSessionsByEnvironment(envId).filter(
468
+ s => s.id !== sessionId && !isSessionClosedStatus(s.status),
469
+ )
470
+ if (openOthers.length > 0) {
471
+ return c.json(
472
+ {
473
+ error: {
474
+ type: 'worker_at_capacity',
475
+ message:
476
+ '该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。',
477
+ },
478
+ },
479
+ 409,
480
+ )
481
+ }
482
+ }
483
+
484
+ // Occupancy: a live worker elsewhere holds the session. Same-environment
485
+ // re-entry was already handled above (before authorization).
486
+ if (isSessionOccupied(sessionId, envId)) {
487
+ return c.json(
488
+ {
489
+ error: {
490
+ type: 'session_busy',
491
+ message: 'Session is being driven by another worker',
492
+ },
493
+ },
494
+ 409,
495
+ )
496
+ }
497
+
498
+ const rebind = session.environment_id !== envId
499
+ if (rebind) {
500
+ storeRebindSessionEnvironment(sessionId, envId)
501
+ }
502
+
503
+ let workItemId: string
504
+ try {
505
+ workItemId = await createWorkItem(envId, sessionId)
506
+ } catch (err) {
507
+ // Roll the rebind back so a failed dispatch doesn't leave the session
508
+ // pointing at an environment that never picked it up — including back to
509
+ // "no environment" when the session had none. A rollback failure must
510
+ // not mask the original dispatch error.
511
+ if (rebind) {
512
+ try {
513
+ storeRebindSessionEnvironment(sessionId, session.environment_id)
514
+ } catch (rollbackErr) {
515
+ logError(
516
+ `[RCS] Attach rollback failed for session ${sessionId}: ${rollbackErr}`,
517
+ )
518
+ }
519
+ }
520
+ return c.json(
521
+ {
522
+ error: {
523
+ type: 'work_dispatch_failed',
524
+ message: (err as Error).message,
525
+ },
526
+ },
527
+ 409,
528
+ )
529
+ }
530
+
531
+ log(
532
+ `[RCS] Session ${sessionId} attached to env=${envId} (rebind=${rebind} work=${workItemId})`,
533
+ )
534
+ return c.json(
535
+ {
536
+ status: 'attached',
537
+ session_id: sessionId,
538
+ work_item_id: workItemId,
539
+ rebind,
540
+ },
541
+ 200,
542
+ )
543
+ }
544
+
545
+ /** POST /v1/sessions/attach-code/exchange — Redeem a one-time attach code.
546
+ * The code was minted by a session manager (POST /web/sessions/:id/attach-code)
547
+ * and IS the authorization: the CLI holding it needs no manage rights of
548
+ * its own (typical for shared admin API keys that can't enumerate
549
+ * sessions). Single-use — the code is consumed on success AND on any
550
+ * validation failure after redemption to prevent replay probing. */
551
+ app.post('/attach-code/exchange', bridgeAuth, async c => {
552
+ const caller = resolveAttachCaller(c)
553
+ const body = await c.req.json().catch(() => ({}) as Record<string, unknown>)
554
+ const code = typeof body.code === 'string' ? body.code.trim() : ''
555
+ const envId =
556
+ typeof body.environment_id === 'string' ? body.environment_id : undefined
557
+ if (!code || !envId) {
558
+ return c.json(
559
+ {
560
+ error: {
561
+ type: 'bad_request',
562
+ message: 'code and environment_id are required',
563
+ },
564
+ },
565
+ 400,
566
+ )
567
+ }
568
+
569
+ const record = storeConsumeAttachCode(code)
570
+ if (!record) {
571
+ return c.json(
572
+ {
573
+ error: {
574
+ type: 'invalid_attach_code',
575
+ message: 'Attach code is unknown, expired, or already used',
576
+ },
577
+ },
578
+ 403,
579
+ )
580
+ }
581
+
582
+ return performAttach(c, {
583
+ sessionId: record.sessionId,
584
+ envId,
585
+ caller,
586
+ skipAccessCheck: true,
587
+ })
588
+ })
589
+
590
+ /** GET /v1/sessions/:id/events — Historical events for session (bridge auth).
591
+ * CLI-side hydration for Session Attach: merges the durable SQLite log with
592
+ * the in-memory bus window (same merge as the web /history route), deduped
593
+ * by id, chronological.
594
+ *
595
+ * Access: session management rights (canManageSession), a bare admin API
596
+ * key, OR ownership of the environment the session is currently bound to.
597
+ * The env-owner relaxation is what makes wait-mode hydration work: a
598
+ * team-admin binds a session to someone else's CLI env, and that CLI must
599
+ * read the history to hydrate — it already sees the live traffic through
600
+ * the work-item ingress token, so refusing only the history would gate the
601
+ * wrong layer (P0-1). */
602
+ app.get('/:id/events', bridgeAuth, async c => {
603
+ const caller = resolveAttachCaller(c)
604
+ if (caller.dbUnavailable) {
605
+ return dbUnavailable(c)
606
+ }
607
+ const sessionId =
608
+ resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
609
+ const session = getSession(sessionId)
610
+ if (!session) {
611
+ return c.json(
612
+ { error: { type: 'not_found', message: 'Session not found' } },
613
+ 404,
614
+ )
615
+ }
616
+
617
+ const db = getDb()
618
+ const manages =
619
+ (caller.uuid !== null && canManageSession(sessionId, caller.uuid, db)) ||
620
+ (!caller.hasIdentity && caller.isApiAdmin)
621
+ let envOwned = false
622
+ if (
623
+ !manages &&
624
+ caller.uuid !== null &&
625
+ session.environment_id !== null &&
626
+ session.environment_id !== undefined
627
+ ) {
628
+ const ownership = storeGetEnvironmentOwnership(session.environment_id)
629
+ envOwned = ownership?.ownerUserId === caller.uuid
630
+ }
631
+ if (!manages && !envOwned) {
632
+ return c.json(
633
+ {
634
+ error: {
635
+ type: caller.hasIdentity ? 'forbidden' : 'unauthorized',
636
+ message: caller.hasIdentity
637
+ ? 'Not allowed to read this session'
638
+ : 'User identity required to read session events',
639
+ },
640
+ },
641
+ caller.hasIdentity ? 403 : 401,
642
+ )
643
+ }
644
+
645
+ const persisted = loadPersistedEvents(sessionId)
646
+ const inMemory = getEventBus(sessionId).getEventsSince(0)
647
+ const byId = new Map<string, (typeof persisted)[number]>()
648
+ for (const event of persisted) byId.set(event.id, event)
649
+ for (const event of inMemory) {
650
+ if (!byId.has(event.id)) byId.set(event.id, event)
651
+ }
652
+ const events = [...byId.values()].sort(
653
+ (a, b) => a.createdAt - b.createdAt || a.seqNum - b.seqNum,
654
+ )
655
+ // models_state fallback — mirrors the web history route so a CLI attaching
656
+ // right after a /clear still receives the model selector snapshot.
657
+ if (!events.some(e => e.type === 'models_state')) {
658
+ const snapshot = getEventBus(sessionId).modelsStateSnapshot
659
+ if (snapshot) {
660
+ const lastCreated = events[events.length - 1]?.createdAt ?? Date.now()
661
+ events.push({
662
+ id: `models_state_snapshot_${sessionId}`,
663
+ sessionId,
664
+ seqNum: 0,
665
+ type: 'models_state',
666
+ payload: { models_state: snapshot },
667
+ direction: 'inbound',
668
+ createdAt: lastCreated + 1,
669
+ })
670
+ }
671
+ }
672
+ return c.json({ events }, 200)
673
+ })
674
+
87
675
  /** GET /v1/sessions/:id — Get session */
88
676
  app.get('/:id', acceptCliHeaders, apiKeyAuth, async c => {
89
677
  const sessionId =
@@ -29,6 +29,7 @@ import {
29
29
  storeGetEnvironment,
30
30
  storeGetSessionWorker,
31
31
  storeGetActiveWorkItemBySession,
32
+ storeIssueAttachCode,
32
33
  storeListSessionsByEnvironment,
33
34
  } from '../../store'
34
35
  import { createWorkItem, stopWork } from '../../services/work-dispatch'
@@ -195,6 +196,72 @@ app.get('/sessions/all', sessionAuth, async c => {
195
196
  return c.json(sessions, 200)
196
197
  })
197
198
 
199
+ /** POST /web/sessions/:id/attach-code — Mint a one-time attach code (P3).
200
+ * The session manager shows/copies `slz rc --attach <code>`; the CLI
201
+ * redeems it via POST /v1/sessions/attach-code/exchange. The code IS the
202
+ * authorization (15-min TTL, single use) — the CLI needs no manage rights
203
+ * of its own. */
204
+ app.post('/sessions/:id/attach-code', sessionAuth, async c => {
205
+ const uuid = c.get('userId')!
206
+ const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
207
+ if (!sessionId) {
208
+ return c.json(
209
+ { error: { type: 'forbidden', message: 'Not your session' } },
210
+ 403,
211
+ )
212
+ }
213
+ if (!canManageSession(sessionId, uuid, getDb())) {
214
+ return c.json(
215
+ {
216
+ error: {
217
+ type: 'forbidden',
218
+ message: 'Only session owner or team admin can mint attach codes',
219
+ },
220
+ },
221
+ 403,
222
+ )
223
+ }
224
+ const session = getSession(sessionId)
225
+ if (!session) {
226
+ return c.json(
227
+ { error: { type: 'not_found', message: 'Session not found' } },
228
+ 404,
229
+ )
230
+ }
231
+ if (isSessionClosedStatus(session.status)) {
232
+ return c.json(
233
+ {
234
+ error: {
235
+ type: 'session_closed',
236
+ message: `Session is ${session.status}`,
237
+ },
238
+ },
239
+ 409,
240
+ )
241
+ }
242
+ if (session.source === 'acp') {
243
+ return c.json(
244
+ {
245
+ error: {
246
+ type: 'acp_session',
247
+ message: 'ACP sessions cannot be attached to a bridge worker',
248
+ },
249
+ },
250
+ 409,
251
+ )
252
+ }
253
+
254
+ const record = storeIssueAttachCode(sessionId)
255
+ return c.json(
256
+ {
257
+ code: record.code,
258
+ expires_at: record.expiresAt.getTime(),
259
+ attach_command: `slz rc --attach ${record.code}`,
260
+ },
261
+ 201,
262
+ )
263
+ })
264
+
198
265
  /** GET /web/sessions/:id — Session detail */
199
266
  app.get('/sessions/:id', sessionAuth, async c => {
200
267
  const uuid = c.get('userId')!