@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.
@@ -6061,6 +6061,10 @@
6061
6061
  "name": "SALUZI_BRIDGE_ALLOW_INSECURE_HTTP",
6062
6062
  "category": "other"
6063
6063
  },
6064
+ {
6065
+ "name": "SALUZI_BRIDGE_ATTACH_TIMEOUT_MS",
6066
+ "category": "other"
6067
+ },
6064
6068
  {
6065
6069
  "name": "SALUZI_BRIDGE_BASE_URL",
6066
6070
  "category": "bridge",
@@ -6079,10 +6083,18 @@
6079
6083
  "category": "bridge",
6080
6084
  "description": "worker 连接 RCS 的 token"
6081
6085
  },
6086
+ {
6087
+ "name": "SALUZI_BRIDGE_SESSION_ID",
6088
+ "category": "other"
6089
+ },
6082
6090
  {
6083
6091
  "name": "SALUZI_BRIDGE_SESSION_INGRESS_URL",
6084
6092
  "category": "other"
6085
6093
  },
6094
+ {
6095
+ "name": "SALUZI_BRIDGE_SESSION_MODE",
6096
+ "category": "other"
6097
+ },
6086
6098
  {
6087
6099
  "name": "SALUZI_BRIDGE_TEAM_ID",
6088
6100
  "category": "other"
@@ -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.64",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,183 @@
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
+ } from '../store'
20
+ import { initDatabase, resetDbSingleton, getDb } from '../db/sqlite'
21
+ import { issueSessionToken } from '../auth/session'
22
+ import { createUserDirectly } from './helpers/fixtures'
23
+ import { createSession } from '../services/session'
24
+ import { storeBindSession } from '../store'
25
+
26
+ /**
27
+ * Session Attach P3 — one-time attach codes:
28
+ *
29
+ * - POST /web/sessions/:id/attach-code (session manager) mints a code.
30
+ * - POST /v1/sessions/attach-code/exchange redeems it WITHOUT manage
31
+ * rights (the code IS the authorization) and dispatches the attach.
32
+ * - Codes are single-use; unknown/expired codes are rejected.
33
+ */
34
+
35
+ function freshDb(): Database {
36
+ try {
37
+ resetDbSingleton()
38
+ } catch {}
39
+ const path = resolve(
40
+ tmpdir(),
41
+ `rcs-attach-code-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`,
42
+ )
43
+ return initDatabase(path)
44
+ }
45
+
46
+ function createUser(db: Database): { id: string; username: string } {
47
+ const username = `user_${randomUUID().slice(0, 6)}`
48
+ const user = createUserDirectly(db, { username, role: 'member' })
49
+ return { id: user.id, username: user.username }
50
+ }
51
+
52
+ function rctAuth(userId: string): string {
53
+ return `Bearer ${issueSessionToken(userId, getDb()).accessToken}`
54
+ }
55
+
56
+ const ADMIN_KEY = { Authorization: 'Bearer test-api-key' }
57
+ const JSON_HDRS = { 'Content-Type': 'application/json' }
58
+
59
+ function createApp() {
60
+ const app = new Hono()
61
+ app.route('/v1/sessions', v1Sessions)
62
+ app.route('/web', webSessions)
63
+ return app
64
+ }
65
+
66
+ describe('attach codes', () => {
67
+ let app: Hono
68
+ let db: Database
69
+ let owner: { id: string; username: string }
70
+ let envId: string
71
+ let sessionId: string
72
+
73
+ beforeEach(() => {
74
+ db = freshDb()
75
+ storeReset()
76
+ app = createApp()
77
+ owner = createUser(db)
78
+ const env = storeCreateEnvironment({
79
+ secret: 'test-api-key',
80
+ machineName: 'mac1',
81
+ ownerUserId: owner.id,
82
+ })
83
+ envId = env.id
84
+ const session = createSession({
85
+ environment_id: null,
86
+ title: 'Web session',
87
+ source: 'web',
88
+ })
89
+ sessionId = session.id
90
+ storeBindSession(sessionId, owner.id, 'user')
91
+ })
92
+
93
+ test('mint → exchange → attached, without manage rights on the redeemer', async () => {
94
+ const mint = await app.request(`/web/sessions/${sessionId}/attach-code`, {
95
+ method: 'POST',
96
+ headers: { Authorization: rctAuth(owner.id) },
97
+ })
98
+ expect(mint.status).toBe(201)
99
+ const { code, attach_command } = (await mint.json()) as {
100
+ code: string
101
+ attach_command: string
102
+ }
103
+ expect(code.startsWith('atc_')).toBe(true)
104
+ expect(attach_command).toContain(code)
105
+
106
+ // Redeem with a BARE admin API key (no user identity at all — the
107
+ // no-rights case the code flow exists for).
108
+ const exchange = await app.request('/v1/sessions/attach-code/exchange', {
109
+ method: 'POST',
110
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
111
+ body: JSON.stringify({ code, environment_id: envId }),
112
+ })
113
+ expect(exchange.status).toBe(200)
114
+ const body = (await exchange.json()) as {
115
+ status: string
116
+ session_id: string
117
+ }
118
+ expect(body.session_id).toBe(sessionId)
119
+ })
120
+
121
+ test('codes are single-use: second exchange → 403', async () => {
122
+ const { code } = (await (
123
+ await app.request(`/web/sessions/${sessionId}/attach-code`, {
124
+ method: 'POST',
125
+ headers: { Authorization: rctAuth(owner.id) },
126
+ })
127
+ ).json()) as { code: string }
128
+
129
+ const redeem = (codeValue: string) =>
130
+ app.request('/v1/sessions/attach-code/exchange', {
131
+ method: 'POST',
132
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
133
+ body: JSON.stringify({ code: codeValue, environment_id: envId }),
134
+ })
135
+
136
+ const first = await redeem(code)
137
+ expect(first.status).toBe(200)
138
+ const second = await redeem(code)
139
+ expect(second.status).toBe(403)
140
+ const body = (await second.json()) as { error: { type: string } }
141
+ expect(body.error.type).toBe('invalid_attach_code')
142
+ })
143
+
144
+ test('unknown and expired codes → 403 invalid_attach_code', async () => {
145
+ const res = await app.request('/v1/sessions/attach-code/exchange', {
146
+ method: 'POST',
147
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
148
+ body: JSON.stringify({
149
+ code: 'atc_nonexistent',
150
+ environment_id: envId,
151
+ }),
152
+ })
153
+ expect(res.status).toBe(403)
154
+ expect(((await res.json()) as { error: { type: string } }).error.type).toBe(
155
+ 'invalid_attach_code',
156
+ )
157
+
158
+ const expired = storeIssueAttachCode(sessionId, -1000)
159
+ const res2 = await app.request('/v1/sessions/attach-code/exchange', {
160
+ method: 'POST',
161
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
162
+ body: JSON.stringify({ code: expired.code, environment_id: envId }),
163
+ })
164
+ expect(res2.status).toBe(403)
165
+ })
166
+
167
+ test('storeConsumeAttachCode: returns null for unknown, record for known', () => {
168
+ expect(storeConsumeAttachCode('atc_missing')).toBeNull()
169
+ const record = storeIssueAttachCode(sessionId)
170
+ expect(storeConsumeAttachCode(record.code)?.sessionId).toBe(sessionId)
171
+ // Consumed — second call fails.
172
+ expect(storeConsumeAttachCode(record.code)).toBeNull()
173
+ })
174
+
175
+ test('missing code or environment_id → 400', async () => {
176
+ const res = await app.request('/v1/sessions/attach-code/exchange', {
177
+ method: 'POST',
178
+ headers: { ...JSON_HDRS, ...ADMIN_KEY },
179
+ body: JSON.stringify({ environment_id: envId }),
180
+ })
181
+ expect(res.status).toBe(400)
182
+ })
183
+ })
@@ -0,0 +1,213 @@
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
+ * - GET /web/environments surfaces attach_waiting for waiting workers.
34
+ */
35
+
36
+ function freshDb(): Database {
37
+ try {
38
+ resetDbSingleton()
39
+ } catch {}
40
+ const path = resolve(
41
+ tmpdir(),
42
+ `rcs-attach-wait-${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/environments', v1Environments)
63
+ app.route('/web', webEnvironments)
64
+ return app
65
+ }
66
+
67
+ describe('Session Attach wait mode', () => {
68
+ let app: Hono
69
+ let db: Database
70
+ let owner: { id: string; username: string }
71
+
72
+ beforeEach(() => {
73
+ db = freshDb()
74
+ storeReset()
75
+ app = createApp()
76
+ owner = createUser(db)
77
+ })
78
+
79
+ test('POST /bridge with session_mode=attach persists the mode', async () => {
80
+ const res = await app.request('/v1/environments/bridge', {
81
+ method: 'POST',
82
+ headers: { ...JSON_HDRS, ...ADMIN_KEY, 'X-User-Id': owner.username },
83
+ body: JSON.stringify({ machine_name: 'mac1', session_mode: 'attach' }),
84
+ })
85
+ expect(res.status).toBe(200)
86
+ const envId = ((await res.json()) as { environment_id: string })
87
+ .environment_id
88
+ expect(storeGetEnvironment(envId)?.sessionMode).toBe('attach')
89
+ })
90
+
91
+ test('POST /bridge without session_mode stays create-mode', async () => {
92
+ const res = await app.request('/v1/environments/bridge', {
93
+ method: 'POST',
94
+ headers: { ...JSON_HDRS, ...ADMIN_KEY, 'X-User-Id': owner.username },
95
+ body: JSON.stringify({ machine_name: 'mac1' }),
96
+ })
97
+ expect(res.status).toBe(200)
98
+ const envId = ((await res.json()) as { environment_id: string })
99
+ .environment_id
100
+ expect(storeGetEnvironment(envId)?.sessionMode ?? null).toBeNull()
101
+ })
102
+
103
+ test('assignment: unassigned env → assigned:false', async () => {
104
+ const env = storeCreateEnvironment({
105
+ secret: 'test-api-key',
106
+ machineName: 'mac1',
107
+ ownerUserId: owner.id,
108
+ sessionMode: 'attach',
109
+ })
110
+ // Age the heartbeat past the disconnect timeout, then verify the poll
111
+ // refreshes it (P0-2: without this the disconnect monitor kills the
112
+ // waiting env after 5 minutes and the CLI dead-waits its deadline).
113
+ const stale = new Date(Date.now() - 10 * 60 * 1000)
114
+ storeUpdateEnvironment(env.id, { lastPollAt: stale })
115
+ const before = storeGetEnvironment(env.id)?.lastPollAt?.getTime()
116
+
117
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
118
+ headers: { ...ADMIN_KEY },
119
+ })
120
+ expect(res.status).toBe(200)
121
+ expect(await res.json()).toEqual({ assigned: false })
122
+ const after = storeGetEnvironment(env.id)?.lastPollAt?.getTime()
123
+ expect(after).not.toBe(before)
124
+ expect(after ?? 0).toBeGreaterThan(stale.getTime())
125
+ })
126
+
127
+ test('assignment: session bound to the env (web attach) → assigned with session id', async () => {
128
+ const env = storeCreateEnvironment({
129
+ secret: 'test-api-key',
130
+ machineName: 'mac1',
131
+ ownerUserId: owner.id,
132
+ sessionMode: 'attach',
133
+ })
134
+ const session = createSession({
135
+ environment_id: env.id,
136
+ title: 'Team session',
137
+ source: 'web',
138
+ })
139
+ storeBindSession(session.id, owner.id, 'user')
140
+
141
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
142
+ headers: { ...ADMIN_KEY },
143
+ })
144
+ expect(res.status).toBe(200)
145
+ const body = (await res.json()) as {
146
+ assigned: boolean
147
+ session_id?: string
148
+ }
149
+ expect(body.assigned).toBe(true)
150
+ expect(body.session_id).toBe(session.id)
151
+ })
152
+
153
+ test('assignment: archived sessions do not satisfy the assignment', async () => {
154
+ const env = storeCreateEnvironment({
155
+ secret: 'test-api-key',
156
+ machineName: 'mac1',
157
+ ownerUserId: owner.id,
158
+ sessionMode: 'attach',
159
+ })
160
+ const session = createSession({
161
+ environment_id: env.id,
162
+ title: 'Archived session',
163
+ source: 'web',
164
+ })
165
+ archiveSession(session.id)
166
+
167
+ const res = await app.request(`/v1/environments/${env.id}/assignment`, {
168
+ headers: { ...ADMIN_KEY },
169
+ })
170
+ expect(res.status).toBe(200)
171
+ expect(await res.json()).toEqual({ assigned: false })
172
+ })
173
+
174
+ test('assignment: unknown env → 404', async () => {
175
+ const res = await app.request('/v1/environments/env_missing/assignment', {
176
+ headers: { ...ADMIN_KEY },
177
+ })
178
+ expect(res.status).toBe(404)
179
+ })
180
+
181
+ test('web environments exposes attach_waiting for waiting workers', async () => {
182
+ storeCreateEnvironment({
183
+ secret: 'test-api-key',
184
+ machineName: 'waiting-cli',
185
+ ownerUserId: owner.id,
186
+ sessionMode: 'attach',
187
+ })
188
+ // A create-mode env with a bound session — never attach_waiting.
189
+ const busyEnv = storeCreateEnvironment({
190
+ secret: 'test-api-key',
191
+ machineName: 'busy-cli',
192
+ ownerUserId: owner.id,
193
+ })
194
+ createSession({
195
+ environment_id: busyEnv.id,
196
+ title: 's',
197
+ source: 'web',
198
+ })
199
+
200
+ const res = await app.request('/web/environments', {
201
+ headers: { Authorization: rctAuth(owner.id) },
202
+ })
203
+ expect(res.status).toBe(200)
204
+ const envs = (await res.json()) as Array<{
205
+ machine_name: string
206
+ attach_waiting?: boolean
207
+ }>
208
+ const waiting = envs.find(e => e.machine_name === 'waiting-cli')
209
+ const busy = envs.find(e => e.machine_name === 'busy-cli')
210
+ expect(waiting?.attach_waiting).toBe(true)
211
+ expect(busy?.attach_waiting ?? false).toBe(false)
212
+ })
213
+ })