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