@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
|
@@ -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"
|
package/dist/rcs-schema.sql
CHANGED
package/package.json
CHANGED
|
@@ -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
|
+
})
|