@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.
@@ -90,7 +90,8 @@ CREATE TABLE IF NOT EXISTS environments (
90
90
  created_at TEXT NOT NULL,
91
91
  owner_user_id TEXT,
92
92
  claim_token TEXT,
93
- claim_expires_at TEXT
93
+ claim_expires_at TEXT,
94
+ session_mode TEXT
94
95
  );
95
96
 
96
97
  CREATE TABLE IF NOT EXISTS sessions (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,195 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test'
2
+ import { mockConfigModule } from './helpers/mock-config'
3
+
4
+ // Mock config before any module imports
5
+ mock.module('../config', () => mockConfigModule())
6
+
7
+ import { Hono } from 'hono'
8
+ import type { Database } from 'bun:sqlite'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { tmpdir } from 'node:os'
11
+ import { resolve } from 'node:path'
12
+ import v1Sessions from '../routes/v1/sessions'
13
+ import webSessions from '../routes/web/sessions'
14
+ import {
15
+ storeReset,
16
+ storeCreateEnvironment,
17
+ storeIssueAttachCode,
18
+ storeConsumeAttachCode,
19
+ storeAttachCodesSize,
20
+ } from '../store'
21
+ import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
22
+ import { issueSessionToken } from '../auth/session'
23
+ import { createUserDirectly } from './helpers/fixtures'
24
+ import { createSession } from '../services/session'
25
+ import { storeBindSession } from '../store'
26
+
27
+ /**
28
+ * Session Attach P3 — one-time attach codes:
29
+ *
30
+ * - POST /web/sessions/:id/attach-code (session manager) mints a code.
31
+ * - POST /v1/sessions/attach-code/exchange redeems it WITHOUT manage
32
+ * rights (the code IS the authorization) and dispatches the attach.
33
+ * - Codes are single-use; unknown/expired codes are rejected.
34
+ */
35
+
36
+ function freshDb(): Database {
37
+ try {
38
+ resetDbSingleton()
39
+ } catch {}
40
+ const path = resolve(
41
+ tmpdir(),
42
+ `rcs-attach-code-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
43
+ )
44
+ return initDatabase(path)
45
+ }
46
+
47
+ function createUser(db: Database): { id: string; username: string } {
48
+ const username = `user_${randomUUID().slice(0, 6)}`
49
+ const user = createUserDirectly(db, { username, role: 'member' })
50
+ return { id: user.id, username: user.username }
51
+ }
52
+
53
+ function rctAuth(userId: string): string {
54
+ return `Bearer ${issueSessionToken(userId, getDb()).accessToken}`
55
+ }
56
+
57
+ const ADMIN_KEY = { Authorization: 'Bearer test-api-key' }
58
+ const JSON_HDRS = { 'Content-Type': 'application/json' }
59
+
60
+ function createApp() {
61
+ const app = new Hono()
62
+ app.route('/v1/sessions', v1Sessions)
63
+ app.route('/web', webSessions)
64
+ return app
65
+ }
66
+
67
+ describe('attach codes', () => {
68
+ let app: Hono
69
+ let db: Database
70
+ let owner: { id: string; username: string }
71
+ let envId: string
72
+ let sessionId: string
73
+
74
+ beforeEach(() => {
75
+ db = freshDb()
76
+ storeReset()
77
+ app = createApp()
78
+ owner = createUser(db)
79
+ const env = storeCreateEnvironment({
80
+ secret: 'test-api-key',
81
+ machineName: 'mac1',
82
+ ownerUserId: owner.id,
83
+ })
84
+ envId = env.id
85
+ const session = createSession({
86
+ environment_id: null,
87
+ title: 'Web session',
88
+ source: 'web',
89
+ })
90
+ sessionId = session.id
91
+ storeBindSession(sessionId, owner.id, 'user')
92
+ })
93
+
94
+ test('mint → exchange → attached, without manage rights on the redeemer', async () => {
95
+ const mint = await app.request(`/web/sessions/${sessionId}/attach-code`, {
96
+ method: 'POST',
97
+ headers: { Authorization: rctAuth(owner.id) },
98
+ })
99
+ expect(mint.status).toBe(201)
100
+ const { code, attach_command } = (await mint.json()) as {
101
+ code: string
102
+ attach_command: string
103
+ }
104
+ expect(code.startsWith('atc_')).toBe(true)
105
+ expect(attach_command).toContain(code)
106
+
107
+ // Redeem with a BARE admin API key (no user identity at all — the
108
+ // no-rights case the code flow exists for).
109
+ const exchange = await app.request('/v1/sessions/attach-code/exchange', {
110
+ method: 'POST',
111
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
112
+ body: JSON.stringify({ code, environment_id: envId }),
113
+ })
114
+ expect(exchange.status).toBe(200)
115
+ const body = (await exchange.json()) as {
116
+ status: string
117
+ session_id: string
118
+ }
119
+ expect(body.session_id).toBe(sessionId)
120
+ })
121
+
122
+ test('codes are single-use: second exchange → 403', async () => {
123
+ const { code } = (await (
124
+ await app.request(`/web/sessions/${sessionId}/attach-code`, {
125
+ method: 'POST',
126
+ headers: { Authorization: rctAuth(owner.id) },
127
+ })
128
+ ).json()) as { code: string }
129
+
130
+ const redeem = (codeValue: string) =>
131
+ app.request('/v1/sessions/attach-code/exchange', {
132
+ method: 'POST',
133
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
134
+ body: JSON.stringify({ code: codeValue, environment_id: envId }),
135
+ })
136
+
137
+ const first = await redeem(code)
138
+ expect(first.status).toBe(200)
139
+ const second = await redeem(code)
140
+ expect(second.status).toBe(403)
141
+ const body = (await second.json()) as { error: { type: string } }
142
+ expect(body.error.type).toBe('invalid_attach_code')
143
+ })
144
+
145
+ test('unknown and expired codes → 403 invalid_attach_code', async () => {
146
+ const res = await app.request('/v1/sessions/attach-code/exchange', {
147
+ method: 'POST',
148
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
149
+ body: JSON.stringify({
150
+ code: 'atc_nonexistent',
151
+ environment_id: envId,
152
+ }),
153
+ })
154
+ expect(res.status).toBe(403)
155
+ expect(((await res.json()) as { error: { type: string } }).error.type).toBe(
156
+ 'invalid_attach_code',
157
+ )
158
+
159
+ const expired = storeIssueAttachCode(sessionId, -1000)
160
+ const res2 = await app.request('/v1/sessions/attach-code/exchange', {
161
+ method: 'POST',
162
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
163
+ body: JSON.stringify({ code: expired.code, environment_id: envId }),
164
+ })
165
+ expect(res2.status).toBe(403)
166
+ })
167
+
168
+ test('storeConsumeAttachCode: returns null for unknown, record for known', () => {
169
+ expect(storeConsumeAttachCode('atc_missing')).toBeNull()
170
+ const record = storeIssueAttachCode(sessionId)
171
+ expect(storeConsumeAttachCode(record.code)?.sessionId).toBe(sessionId)
172
+ // Consumed — second call fails.
173
+ expect(storeConsumeAttachCode(record.code)).toBeNull()
174
+ })
175
+
176
+ test('unconsumed expired codes are swept on the next issue (memory hygiene)', () => {
177
+ expect(storeAttachCodesSize()).toBe(0)
178
+ // Expired immediately and never presented — consume() would never see it.
179
+ storeIssueAttachCode(sessionId, -1000)
180
+ expect(storeAttachCodesSize()).toBe(1)
181
+ // The next issue sweeps it instead of letting it accumulate forever.
182
+ const live = storeIssueAttachCode(sessionId)
183
+ expect(storeAttachCodesSize()).toBe(1)
184
+ expect(storeConsumeAttachCode(live.code)?.sessionId).toBe(sessionId)
185
+ })
186
+
187
+ test('missing code or environment_id → 400', async () => {
188
+ const res = await app.request('/v1/sessions/attach-code/exchange', {
189
+ method: 'POST',
190
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
191
+ body: JSON.stringify({ environment_id: envId }),
192
+ })
193
+ expect(res.status).toBe(400)
194
+ })
195
+ })
@@ -0,0 +1,270 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test'
2
+ import { mockConfigModule } from './helpers/mock-config'
3
+
4
+ // Mock config before any module imports
5
+ mock.module('../config', () => mockConfigModule())
6
+
7
+ import { Hono } from 'hono'
8
+ import type { Database } from 'bun:sqlite'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { tmpdir } from 'node:os'
11
+ import { resolve } from 'node:path'
12
+ import v1Environments from '../routes/v1/environments'
13
+ import webEnvironments from '../routes/web/environments'
14
+ import {
15
+ storeReset,
16
+ storeCreateEnvironment,
17
+ storeGetEnvironment,
18
+ storeUpdateEnvironment,
19
+ storeListActiveEnvironmentsWithTeam,
20
+ } from '../store'
21
+ import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
22
+ import { issueSessionToken } from '../auth/session'
23
+ import { createUserDirectly } from './helpers/fixtures'
24
+ import { createSession, archiveSession } from '../services/session'
25
+ import { storeBindSession } from '../store'
26
+
27
+ /**
28
+ * Session Attach P2 — wait mode:
29
+ *
30
+ * - POST /v1/environments/bridge persists session_mode:'attach'.
31
+ * - GET /v1/environments/:id/assignment returns the first open non-ACP
32
+ * session on the env (assigned:false until the Web UI binds one).
33
+ * Gated on env ownership (admin bypasses; unowned envs stay open).
34
+ * - GET /web/environments surfaces attach_waiting for waiting workers.
35
+ */
36
+
37
+ function freshDb(): Database {
38
+ try {
39
+ resetDbSingleton()
40
+ } catch {}
41
+ const path = resolve(
42
+ tmpdir(),
43
+ `rcs-attach-wait-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
44
+ )
45
+ return initDatabase(path)
46
+ }
47
+
48
+ function createUser(db: Database): { id: string; username: string } {
49
+ const username = `user_${randomUUID().slice(0, 6)}`
50
+ const user = createUserDirectly(db, { username, role: 'member' })
51
+ return { id: user.id, username: user.username }
52
+ }
53
+
54
+ function rctAuth(userId: string): string {
55
+ return `Bearer ${issueSessionToken(userId, getDb()).accessToken}`
56
+ }
57
+
58
+ const ADMIN_KEY = { Authorization: 'Bearer test-api-key' }
59
+ const JSON_HDRS = { 'Content-Type': 'application/json' }
60
+
61
+ function createApp() {
62
+ const app = new Hono()
63
+ app.route('/v1/environments', v1Environments)
64
+ app.route('/web', webEnvironments)
65
+ return app
66
+ }
67
+
68
+ describe('Session Attach wait mode', () => {
69
+ let app: Hono
70
+ let db: Database
71
+ let owner: { id: string; username: string }
72
+
73
+ beforeEach(() => {
74
+ db = freshDb()
75
+ storeReset()
76
+ app = createApp()
77
+ owner = createUser(db)
78
+ })
79
+
80
+ test('POST /bridge with session_mode=attach persists the mode', async () => {
81
+ const res = await app.request('/v1/environments/bridge', {
82
+ method: 'POST',
83
+ headers: { ...JSON_HDRS, ...ADMIN_KEY, 'X-User-Id': owner.username },
84
+ body: JSON.stringify({ machine_name: 'mac1', session_mode: 'attach' }),
85
+ })
86
+ expect(res.status).toBe(200)
87
+ const envId = ((await res.json()) as { environment_id: string })
88
+ .environment_id
89
+ expect(storeGetEnvironment(envId)?.sessionMode).toBe('attach')
90
+ })
91
+
92
+ test('POST /bridge without session_mode stays create-mode', async () => {
93
+ const res = await app.request('/v1/environments/bridge', {
94
+ method: 'POST',
95
+ headers: { ...JSON_HDRS, ...ADMIN_KEY, 'X-User-Id': owner.username },
96
+ body: JSON.stringify({ machine_name: 'mac1' }),
97
+ })
98
+ expect(res.status).toBe(200)
99
+ const envId = ((await res.json()) as { environment_id: string })
100
+ .environment_id
101
+ expect(storeGetEnvironment(envId)?.sessionMode ?? null).toBeNull()
102
+ })
103
+
104
+ test('assignment: unassigned env → assigned:false', async () => {
105
+ const env = storeCreateEnvironment({
106
+ secret: 'test-api-key',
107
+ machineName: 'mac1',
108
+ ownerUserId: owner.id,
109
+ sessionMode: 'attach',
110
+ })
111
+ // Age the heartbeat past the disconnect timeout, then verify the poll
112
+ // refreshes it (P0-2: without this the disconnect monitor kills the
113
+ // waiting env after 5 minutes and the CLI dead-waits its deadline).
114
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
115
+ storeUpdateEnvironment(env.id, { lastPollAt: stale })
116
+ const before = storeGetEnvironment(env.id)?.lastPollAt?.getTime()
117
+
118
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
119
+ headers: { ...ADMIN_KEY },
120
+ })
121
+ expect(res.status).toBe(200)
122
+ expect(await res.json()).toEqual({ assigned: false })
123
+ const after = storeGetEnvironment(env.id)?.lastPollAt?.getTime()
124
+ expect(after).not.toBe(before)
125
+ expect(after ?? 0).toBeGreaterThan(stale.getTime())
126
+ })
127
+
128
+ test('assignment: owned env + owner rct token → 200 (worker polls as itself)', async () => {
129
+ const env = storeCreateEnvironment({
130
+ secret: 'test-api-key',
131
+ machineName: 'mac1',
132
+ ownerUserId: owner.id,
133
+ sessionMode: 'attach',
134
+ })
135
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
136
+ headers: { Authorization: rctAuth(owner.id) },
137
+ })
138
+ expect(res.status).toBe(200)
139
+ expect(await res.json()).toEqual({ assigned: false })
140
+ })
141
+
142
+ test('assignment: owned env + stranger rct token → 403, heartbeat NOT refreshed', async () => {
143
+ // Assignment discloses the env's bound session AND is its liveness
144
+ // signal — neither may be driven by a third party.
145
+ const env = storeCreateEnvironment({
146
+ secret: 'test-api-key',
147
+ machineName: 'mac1',
148
+ ownerUserId: owner.id,
149
+ sessionMode: 'attach',
150
+ })
151
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
152
+ storeUpdateEnvironment(env.id, { lastPollAt: stale })
153
+
154
+ const stranger = createUser(db)
155
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
156
+ headers: { Authorization: rctAuth(stranger.id) },
157
+ })
158
+ expect(res.status).toBe(403)
159
+ const body = (await res.json()) as { error: { type: string } }
160
+ expect(body.error.type).toBe('forbidden')
161
+ // updatePollTime runs only AFTER the gate — a 403 must not keep a dead
162
+ // env looking alive.
163
+ expect(storeGetEnvironment(env.id)?.lastPollAt?.getTime()).toBe(
164
+ stale.getTime(),
165
+ )
166
+ })
167
+
168
+ test('assignment: unowned env (claim pending) + any auth → 200 assigned:false', async () => {
169
+ // Two-phase wait (claim → attach): while unowned there is nothing to
170
+ // disclose, so the poll stays open to any authenticated caller.
171
+ const env = storeCreateEnvironment({
172
+ secret: 'test-api-key',
173
+ machineName: 'unclaimed',
174
+ sessionMode: 'attach',
175
+ })
176
+ const stranger = createUser(db)
177
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
178
+ headers: { Authorization: rctAuth(stranger.id) },
179
+ })
180
+ expect(res.status).toBe(200)
181
+ expect(await res.json()).toEqual({ assigned: false })
182
+ })
183
+
184
+ test('assignment: session bound to the env (web attach) → assigned with session id', async () => {
185
+ const env = storeCreateEnvironment({
186
+ secret: 'test-api-key',
187
+ machineName: 'mac1',
188
+ ownerUserId: owner.id,
189
+ sessionMode: 'attach',
190
+ })
191
+ const session = createSession({
192
+ environment_id: env.id,
193
+ title: 'Team session',
194
+ source: 'web',
195
+ })
196
+ storeBindSession(session.id, owner.id, 'user')
197
+
198
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
199
+ headers: { ...ADMIN_KEY },
200
+ })
201
+ expect(res.status).toBe(200)
202
+ const body = (await res.json()) as {
203
+ assigned: boolean
204
+ session_id?: string
205
+ }
206
+ expect(body.assigned).toBe(true)
207
+ expect(body.session_id).toBe(session.id)
208
+ })
209
+
210
+ test('assignment: archived sessions do not satisfy the assignment', async () => {
211
+ const env = storeCreateEnvironment({
212
+ secret: 'test-api-key',
213
+ machineName: 'mac1',
214
+ ownerUserId: owner.id,
215
+ sessionMode: 'attach',
216
+ })
217
+ const session = createSession({
218
+ environment_id: env.id,
219
+ title: 'Archived session',
220
+ source: 'web',
221
+ })
222
+ archiveSession(session.id)
223
+
224
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
225
+ headers: { ...ADMIN_KEY },
226
+ })
227
+ expect(res.status).toBe(200)
228
+ expect(await res.json()).toEqual({ assigned: false })
229
+ })
230
+
231
+ test('assignment: unknown env → 404', async () => {
232
+ const res = await app.request('/v1/environments/env_missing/assignment', {
233
+ headers: { ...ADMIN_KEY },
234
+ })
235
+ expect(res.status).toBe(404)
236
+ })
237
+
238
+ test('web environments exposes attach_waiting for waiting workers', async () => {
239
+ storeCreateEnvironment({
240
+ secret: 'test-api-key',
241
+ machineName: 'waiting-cli',
242
+ ownerUserId: owner.id,
243
+ sessionMode: 'attach',
244
+ })
245
+ // A create-mode env with a bound session — never attach_waiting.
246
+ const busyEnv = storeCreateEnvironment({
247
+ secret: 'test-api-key',
248
+ machineName: 'busy-cli',
249
+ ownerUserId: owner.id,
250
+ })
251
+ createSession({
252
+ environment_id: busyEnv.id,
253
+ title: 's',
254
+ source: 'web',
255
+ })
256
+
257
+ const res = await app.request('/web/environments', {
258
+ headers: { Authorization: rctAuth(owner.id) },
259
+ })
260
+ expect(res.status).toBe(200)
261
+ const envs = (await res.json()) as Array<{
262
+ machine_name: string
263
+ attach_waiting?: boolean
264
+ }>
265
+ const waiting = envs.find(e => e.machine_name === 'waiting-cli')
266
+ const busy = envs.find(e => e.machine_name === 'busy-cli')
267
+ expect(waiting?.attach_waiting).toBe(true)
268
+ expect(busy?.attach_waiting ?? false).toBe(false)
269
+ })
270
+ })