@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,4 +1,5 @@
|
|
|
1
1
|
import { config } from '../config'
|
|
2
|
+
import { log } from '../logger'
|
|
2
3
|
import {
|
|
3
4
|
storeCreateEnvironment,
|
|
4
5
|
storeCreateSession,
|
|
@@ -14,7 +15,10 @@ import {
|
|
|
14
15
|
storeClaimEnvironment,
|
|
15
16
|
storeSetEnvironmentClaim,
|
|
16
17
|
storeBindEnvSessionsToUser,
|
|
18
|
+
storeListWorkItems,
|
|
19
|
+
storeUpdateWorkItem,
|
|
17
20
|
} from '../store'
|
|
21
|
+
import { isSessionClosedStatus } from './session'
|
|
18
22
|
import type {
|
|
19
23
|
RegisterEnvironmentRequest,
|
|
20
24
|
EnvironmentResponse,
|
|
@@ -42,6 +46,11 @@ function toResponse(
|
|
|
42
46
|
team_id: row.teamId ?? null,
|
|
43
47
|
team_name: row.teamName ?? null,
|
|
44
48
|
owner_user_id: row.ownerUserId ?? null,
|
|
49
|
+
attach_waiting:
|
|
50
|
+
row.sessionMode === 'attach' &&
|
|
51
|
+
!storeListSessionsByEnvironment(row.id).some(
|
|
52
|
+
s => !isSessionClosedStatus(s.status),
|
|
53
|
+
),
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
|
|
@@ -64,10 +73,13 @@ export function registerEnvironment(
|
|
|
64
73
|
teamId?: string | null
|
|
65
74
|
claimToken?: string | null
|
|
66
75
|
claimExpiresAt?: Date | null
|
|
76
|
+
session_mode?: string | null
|
|
67
77
|
},
|
|
68
78
|
): RegisterEnvironmentResult {
|
|
69
79
|
const secret = config.apiKeys[0] || ''
|
|
70
80
|
const workerType = req.worker_type || req.metadata?.worker_type
|
|
81
|
+
// Session Attach wait mode: only 'attach' is meaningful today.
|
|
82
|
+
const sessionMode = req.session_mode === 'attach' ? 'attach' : null
|
|
71
83
|
|
|
72
84
|
// Idempotent re-registration: the CLI echoes back a backend-issued
|
|
73
85
|
// environment_id on restart (bridge pointer / --session-id resume).
|
|
@@ -85,6 +97,7 @@ export function registerEnvironment(
|
|
|
85
97
|
machineName: req.machine_name,
|
|
86
98
|
maxSessions: req.max_sessions,
|
|
87
99
|
capabilities: req.capabilities ?? null,
|
|
100
|
+
sessionMode,
|
|
88
101
|
})
|
|
89
102
|
// Ownership is backfill-only: a restart must not reassign the env
|
|
90
103
|
// (the route layer already gated reattach on admin/owner/unowned).
|
|
@@ -201,6 +214,7 @@ export function registerEnvironment(
|
|
|
201
214
|
ownerUserId: req.ownerUserId,
|
|
202
215
|
claimToken: req.claimToken,
|
|
203
216
|
claimExpiresAt: req.claimExpiresAt,
|
|
217
|
+
sessionMode,
|
|
204
218
|
})
|
|
205
219
|
|
|
206
220
|
let sessionId: string | undefined
|
|
@@ -240,6 +254,19 @@ export function registerEnvironment(
|
|
|
240
254
|
|
|
241
255
|
export function deregisterEnvironment(envId: string) {
|
|
242
256
|
storeUpdateEnvironment(envId, { status: 'deregistered' })
|
|
257
|
+
// Complete the env's open work items. Without this, a clean CLI exit
|
|
258
|
+
// leaves the acked item behind and isSessionOccupied keeps reporting
|
|
259
|
+
// session_busy (the reclaim path only covers dispatched items on a LIVE
|
|
260
|
+
// env). kill -9 crashes skip this — the env-status check in
|
|
261
|
+
// isSessionOccupied is the recovery path for those.
|
|
262
|
+
for (const item of storeListWorkItems()) {
|
|
263
|
+
if (item.environmentId === envId && item.state !== 'completed') {
|
|
264
|
+
storeUpdateWorkItem(item.id, { state: 'completed' })
|
|
265
|
+
log(
|
|
266
|
+
`[RCS] Completed orphaned work item ${item.id} (session=${item.sessionId}) on deregistered env ${envId}`,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
243
270
|
}
|
|
244
271
|
|
|
245
272
|
export function getEnvironment(envId: string) {
|
|
@@ -24,6 +24,14 @@ export interface EnvironmentRecord {
|
|
|
24
24
|
lastPollAt: Date | null
|
|
25
25
|
createdAt: Date
|
|
26
26
|
updatedAt: Date
|
|
27
|
+
/**
|
|
28
|
+
* Session Attach (P2): 'attach' marks a worker that registered in
|
|
29
|
+
* wait-for-assignment mode — it created no session and is polling for the
|
|
30
|
+
* Web UI to bind one. Surfaced as `attach_waiting` on GET /web/environments
|
|
31
|
+
* so the Web UI can enumerate "waiting CLIs" to bind sessions to. Null =
|
|
32
|
+
* default create-mode behavior.
|
|
33
|
+
*/
|
|
34
|
+
sessionMode?: string | null
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export interface SessionRecord {
|
|
@@ -119,6 +127,7 @@ export function storeCreateEnvironment(req: {
|
|
|
119
127
|
ownerUserId?: string | null
|
|
120
128
|
claimToken?: string | null
|
|
121
129
|
claimExpiresAt?: Date | null
|
|
130
|
+
sessionMode?: string | null
|
|
122
131
|
}): EnvironmentRecord {
|
|
123
132
|
const id = `env_${randomUUID().replace(/-/g, '')}`
|
|
124
133
|
const now = new Date()
|
|
@@ -138,14 +147,15 @@ export function storeCreateEnvironment(req: {
|
|
|
138
147
|
lastPollAt: now,
|
|
139
148
|
createdAt: now,
|
|
140
149
|
updatedAt: now,
|
|
150
|
+
sessionMode: req.sessionMode ?? null,
|
|
141
151
|
}
|
|
142
152
|
environments.set(id, record)
|
|
143
153
|
try {
|
|
144
154
|
const db = getDb()
|
|
145
155
|
const secretHash = createHash('sha256').update(record.secret).digest('hex')
|
|
146
156
|
db.query(
|
|
147
|
-
`INSERT OR REPLACE INTO environments (id, secret_hash, machine_name, worker_type, status, last_poll_at, created_at, owner_user_id, claim_token, claim_expires_at)
|
|
148
|
-
VALUES ($id, $sh, $mn, $wt, $st, $lp, $ca, $ou, $ct, $ce)`,
|
|
157
|
+
`INSERT OR REPLACE INTO environments (id, secret_hash, machine_name, worker_type, status, last_poll_at, created_at, owner_user_id, claim_token, claim_expires_at, session_mode)
|
|
158
|
+
VALUES ($id, $sh, $mn, $wt, $st, $lp, $ca, $ou, $ct, $ce, $sm)`,
|
|
149
159
|
).run({
|
|
150
160
|
$id: id,
|
|
151
161
|
$sh: secretHash,
|
|
@@ -157,6 +167,7 @@ export function storeCreateEnvironment(req: {
|
|
|
157
167
|
$ou: req.ownerUserId ?? null,
|
|
158
168
|
$ct: req.claimToken ?? null,
|
|
159
169
|
$ce: req.claimExpiresAt?.toISOString() ?? null,
|
|
170
|
+
$sm: record.sessionMode ?? null,
|
|
160
171
|
})
|
|
161
172
|
} catch (err) {
|
|
162
173
|
console.error('[store] DB write failed in storeCreateEnvironment:', err)
|
|
@@ -181,6 +192,7 @@ export function storeUpdateEnvironment(
|
|
|
181
192
|
| 'machineName'
|
|
182
193
|
| 'maxSessions'
|
|
183
194
|
| 'bridgeId'
|
|
195
|
+
| 'sessionMode'
|
|
184
196
|
>
|
|
185
197
|
>,
|
|
186
198
|
): boolean {
|
|
@@ -204,6 +216,10 @@ export function storeUpdateEnvironment(
|
|
|
204
216
|
sets.push('machine_name = $machineName')
|
|
205
217
|
params.$machineName = patch.machineName
|
|
206
218
|
}
|
|
219
|
+
if (patch.sessionMode !== undefined) {
|
|
220
|
+
sets.push('session_mode = $sessionMode')
|
|
221
|
+
params.$sessionMode = patch.sessionMode
|
|
222
|
+
}
|
|
207
223
|
|
|
208
224
|
// bridgeId, maxSessions, capabilities, updatedAt are in-memory only —
|
|
209
225
|
// the environments table has no corresponding columns.
|
|
@@ -561,6 +577,43 @@ export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
|
|
|
561
577
|
return [...sessions.values()].filter(s => s.environmentId === envId)
|
|
562
578
|
}
|
|
563
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Re-point a session to a different environment (Session Attach).
|
|
582
|
+
*
|
|
583
|
+
* Only mutates environment_id + updated_at — session_owners (user/team) are
|
|
584
|
+
* deliberately untouched: attach transfers *driving* of the session, never
|
|
585
|
+
* ownership. The sessions.environment_id column predates this function;
|
|
586
|
+
* storeUpdateSession's patch type intentionally excludes environment_id
|
|
587
|
+
* because ownership-binding flows must never re-point a session as a side
|
|
588
|
+
* effect. Attach is the one flow with an explicit re-bind contract.
|
|
589
|
+
*/
|
|
590
|
+
export function storeRebindSessionEnvironment(
|
|
591
|
+
sessionId: string,
|
|
592
|
+
environmentId: string,
|
|
593
|
+
): boolean {
|
|
594
|
+
const rec = sessions.get(sessionId)
|
|
595
|
+
if (!rec) return false
|
|
596
|
+
rec.environmentId = environmentId
|
|
597
|
+
rec.updatedAt = new Date()
|
|
598
|
+
try {
|
|
599
|
+
const db = getDb()
|
|
600
|
+
db.query(
|
|
601
|
+
'UPDATE sessions SET environment_id = $envId, updated_at = $ua WHERE id = $id',
|
|
602
|
+
).run({
|
|
603
|
+
$envId: environmentId,
|
|
604
|
+
$ua: rec.updatedAt.toISOString(),
|
|
605
|
+
$id: sessionId,
|
|
606
|
+
})
|
|
607
|
+
} catch (err) {
|
|
608
|
+
console.error(
|
|
609
|
+
'[store] DB write failed in storeRebindSessionEnvironment:',
|
|
610
|
+
err,
|
|
611
|
+
)
|
|
612
|
+
throw err
|
|
613
|
+
}
|
|
614
|
+
return true
|
|
615
|
+
}
|
|
616
|
+
|
|
564
617
|
export function storeDeleteSession(id: string): boolean {
|
|
565
618
|
sessionWorkers.delete(id)
|
|
566
619
|
const deleted = sessions.delete(id)
|
|
@@ -941,6 +994,57 @@ export function storeMarkAcpAgentOnline(id: string): boolean {
|
|
|
941
994
|
return true
|
|
942
995
|
}
|
|
943
996
|
|
|
997
|
+
// ---------- Session Attach codes ----------
|
|
998
|
+
|
|
999
|
+
export interface AttachCodeRecord {
|
|
1000
|
+
code: string
|
|
1001
|
+
sessionId: string
|
|
1002
|
+
expiresAt: Date
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const attachCodes = new Map<string, AttachCodeRecord>()
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Issue a one-time, short-TTL attach code for a session (P3). The Web UI
|
|
1009
|
+
* session manager calls POST /web/sessions/:id/attach-code and hands the
|
|
1010
|
+
* code to a CLI operator (`/rc --attach atc_...`); the code is the attach
|
|
1011
|
+
* authorization — the CLI needs no manage rights of its own.
|
|
1012
|
+
*
|
|
1013
|
+
* In-memory only (no DB table): codes are single-use with a 15-minute TTL,
|
|
1014
|
+
* so losing them on a server restart is acceptable — the user just clicks
|
|
1015
|
+
* "copy attach code" again.
|
|
1016
|
+
*/
|
|
1017
|
+
export function storeIssueAttachCode(
|
|
1018
|
+
sessionId: string,
|
|
1019
|
+
ttlMs = 15 * 60 * 1000,
|
|
1020
|
+
): AttachCodeRecord {
|
|
1021
|
+
const code = `atc_${randomUUID().replace(/-/g, '')}`
|
|
1022
|
+
const record: AttachCodeRecord = {
|
|
1023
|
+
code,
|
|
1024
|
+
sessionId,
|
|
1025
|
+
expiresAt: new Date(Date.now() + ttlMs),
|
|
1026
|
+
}
|
|
1027
|
+
attachCodes.set(code, record)
|
|
1028
|
+
return record
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Consume an attach code: single-use — returns the record and deletes it.
|
|
1033
|
+
* Expired or unknown codes return null.
|
|
1034
|
+
*/
|
|
1035
|
+
export function storeConsumeAttachCode(code: string): AttachCodeRecord | null {
|
|
1036
|
+
const record = attachCodes.get(code)
|
|
1037
|
+
if (!record) return null
|
|
1038
|
+
attachCodes.delete(code)
|
|
1039
|
+
if (record.expiresAt.getTime() <= Date.now()) return null
|
|
1040
|
+
return record
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Test helper — drop all attach codes. */
|
|
1044
|
+
export function storeResetAttachCodes() {
|
|
1045
|
+
attachCodes.clear()
|
|
1046
|
+
}
|
|
1047
|
+
|
|
944
1048
|
// ---------- Reset (for tests) ----------
|
|
945
1049
|
|
|
946
1050
|
export function storeReset() {
|
|
@@ -950,6 +1054,7 @@ export function storeReset() {
|
|
|
950
1054
|
sessions.clear()
|
|
951
1055
|
workItems.clear()
|
|
952
1056
|
sessionWorkers.clear()
|
|
1057
|
+
storeResetAttachCodes()
|
|
953
1058
|
try {
|
|
954
1059
|
const db = getDb()
|
|
955
1060
|
// Delete in FK-safe order: child tables before parent tables
|
|
@@ -142,6 +142,8 @@ export interface EnvironmentResponse {
|
|
|
142
142
|
team_id: string | null
|
|
143
143
|
team_name: string | null
|
|
144
144
|
owner_user_id: string | null
|
|
145
|
+
/** Session Attach wait mode: registered to be assigned a session, none bound yet. */
|
|
146
|
+
attach_waiting?: boolean
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
export interface ServerInfoResponse {
|