@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.
- package/dist/cli.js +176 -176
- package/dist/guide/assets/main-Bx1xb7yl.css +1 -0
- package/dist/guide/assets/main-CdOE18lg.js +277 -0
- package/dist/guide/guide-data.json +27 -5
- package/dist/guide/index.html +2 -2
- package/dist/rcs-schema.sql +2 -1
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/attach-code.test.ts +195 -0
- package/packages/remote-control-server/src/__tests__/attach-wait.test.ts +270 -0
- package/packages/remote-control-server/src/__tests__/attach.test.ts +580 -0
- package/packages/remote-control-server/src/auth/bridge-auth.ts +120 -0
- package/packages/remote-control-server/src/db/rcs-schema.sql +2 -1
- package/packages/remote-control-server/src/db/sqlite.ts +1 -0
- package/packages/remote-control-server/src/routes/v1/environments.ts +97 -73
- package/packages/remote-control-server/src/routes/v1/sessions.ts +592 -4
- package/packages/remote-control-server/src/routes/web/sessions.ts +67 -0
- package/packages/remote-control-server/src/services/environment.ts +27 -0
- package/packages/remote-control-server/src/store.ts +123 -2
- package/packages/remote-control-server/src/types/api.ts +2 -0
- package/dist/guide/assets/main-CSpz9pKf.css +0 -1
- package/dist/guide/assets/main-C_ykjDzn.js +0 -167
|
@@ -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
|
-
|
|
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 {
|
|
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')!
|