yzcode-cli 1.0.1 → 1.0.3
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/assistant/sessionHistory.ts +87 -0
- package/bootstrap/state.ts +1769 -0
- package/bridge/bridgeApi.ts +539 -0
- package/bridge/bridgeConfig.ts +48 -0
- package/bridge/bridgeDebug.ts +135 -0
- package/bridge/bridgeEnabled.ts +202 -0
- package/bridge/bridgeMain.ts +2999 -0
- package/bridge/bridgeMessaging.ts +461 -0
- package/bridge/bridgePermissionCallbacks.ts +43 -0
- package/bridge/bridgePointer.ts +210 -0
- package/bridge/bridgeStatusUtil.ts +163 -0
- package/bridge/bridgeUI.ts +530 -0
- package/bridge/capacityWake.ts +56 -0
- package/bridge/codeSessionApi.ts +168 -0
- package/bridge/createSession.ts +384 -0
- package/bridge/debugUtils.ts +141 -0
- package/bridge/envLessBridgeConfig.ts +165 -0
- package/bridge/flushGate.ts +71 -0
- package/bridge/inboundAttachments.ts +175 -0
- package/bridge/inboundMessages.ts +80 -0
- package/bridge/initReplBridge.ts +569 -0
- package/bridge/jwtUtils.ts +256 -0
- package/bridge/pollConfig.ts +110 -0
- package/bridge/pollConfigDefaults.ts +82 -0
- package/bridge/remoteBridgeCore.ts +1008 -0
- package/bridge/replBridge.ts +2406 -0
- package/bridge/replBridgeHandle.ts +36 -0
- package/bridge/replBridgeTransport.ts +370 -0
- package/bridge/sessionIdCompat.ts +57 -0
- package/bridge/sessionRunner.ts +550 -0
- package/bridge/trustedDevice.ts +210 -0
- package/bridge/types.ts +262 -0
- package/bridge/workSecret.ts +127 -0
- package/buddy/CompanionSprite.tsx +371 -0
- package/buddy/companion.ts +133 -0
- package/buddy/prompt.ts +36 -0
- package/buddy/sprites.ts +514 -0
- package/buddy/types.ts +148 -0
- package/buddy/useBuddyNotification.tsx +98 -0
- package/coordinator/coordinatorMode.ts +369 -0
- package/memdir/findRelevantMemories.ts +141 -0
- package/memdir/memdir.ts +507 -0
- package/memdir/memoryAge.ts +53 -0
- package/memdir/memoryScan.ts +94 -0
- package/memdir/memoryTypes.ts +271 -0
- package/memdir/paths.ts +278 -0
- package/memdir/teamMemPaths.ts +292 -0
- package/memdir/teamMemPrompts.ts +100 -0
- package/migrations/migrateAutoUpdatesToSettings.ts +61 -0
- package/migrations/migrateBypassPermissionsAcceptedToSettings.ts +40 -0
- package/migrations/migrateEnableAllProjectMcpServersToSettings.ts +118 -0
- package/migrations/migrateFennecToOpus.ts +45 -0
- package/migrations/migrateLegacyOpusToCurrent.ts +57 -0
- package/migrations/migrateOpusToOpus1m.ts +43 -0
- package/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts +22 -0
- package/migrations/migrateSonnet1mToSonnet45.ts +48 -0
- package/migrations/migrateSonnet45ToSonnet46.ts +67 -0
- package/migrations/resetAutoModeOptInForDefaultOffer.ts +51 -0
- package/migrations/resetProToOpusDefault.ts +51 -0
- package/native-ts/color-diff/index.ts +999 -0
- package/native-ts/file-index/index.ts +370 -0
- package/native-ts/yoga-layout/enums.ts +134 -0
- package/native-ts/yoga-layout/index.ts +2578 -0
- package/outputStyles/loadOutputStylesDir.ts +98 -0
- package/package.json +22 -5
- package/plugins/builtinPlugins.ts +159 -0
- package/plugins/bundled/index.ts +23 -0
- package/schemas/hooks.ts +222 -0
- package/screens/Doctor.tsx +575 -0
- package/screens/REPL.tsx +5006 -0
- package/screens/ResumeConversation.tsx +399 -0
- package/server/createDirectConnectSession.ts +88 -0
- package/server/directConnectManager.ts +213 -0
- package/server/types.ts +57 -0
- package/skills/bundled/batch.ts +124 -0
- package/skills/bundled/claudeApi.ts +196 -0
- package/skills/bundled/claudeApiContent.ts +75 -0
- package/skills/bundled/claudeInChrome.ts +34 -0
- package/skills/bundled/debug.ts +103 -0
- package/skills/bundled/index.ts +79 -0
- package/skills/bundled/keybindings.ts +339 -0
- package/skills/bundled/loop.ts +92 -0
- package/skills/bundled/loremIpsum.ts +282 -0
- package/skills/bundled/remember.ts +82 -0
- package/skills/bundled/scheduleRemoteAgents.ts +447 -0
- package/skills/bundled/simplify.ts +69 -0
- package/skills/bundled/skillify.ts +197 -0
- package/skills/bundled/stuck.ts +79 -0
- package/skills/bundled/updateConfig.ts +475 -0
- package/skills/bundled/verify/SKILL.md +3 -0
- package/skills/bundled/verify/examples/cli.md +3 -0
- package/skills/bundled/verify/examples/server.md +3 -0
- package/skills/bundled/verify.ts +30 -0
- package/skills/bundled/verifyContent.ts +13 -0
- package/skills/bundledSkills.ts +220 -0
- package/skills/loadSkillsDir.ts +1086 -0
- package/skills/mcpSkillBuilders.ts +44 -0
- package/tasks/DreamTask/DreamTask.ts +157 -0
- package/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +126 -0
- package/tasks/InProcessTeammateTask/types.ts +121 -0
- package/tasks/LocalAgentTask/LocalAgentTask.tsx +683 -0
- package/tasks/LocalMainSessionTask.ts +479 -0
- package/tasks/LocalShellTask/LocalShellTask.tsx +523 -0
- package/tasks/LocalShellTask/guards.ts +41 -0
- package/tasks/LocalShellTask/killShellTasks.ts +76 -0
- package/tasks/RemoteAgentTask/RemoteAgentTask.tsx +856 -0
- package/tasks/pillLabel.ts +82 -0
- package/tasks/stopTask.ts +100 -0
- package/tasks/types.ts +46 -0
- package/upstreamproxy/relay.ts +455 -0
- package/upstreamproxy/upstreamproxy.ts +285 -0
- package/vim/motions.ts +82 -0
- package/vim/operators.ts +556 -0
- package/vim/textObjects.ts +186 -0
- package/vim/transitions.ts +490 -0
- package/vim/types.ts +199 -0
- package/voice/voiceModeEnabled.ts +54 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
|
|
3
|
+
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
|
4
|
+
import {
|
|
5
|
+
BRIDGE_LOGIN_INSTRUCTION,
|
|
6
|
+
type BridgeApiClient,
|
|
7
|
+
type BridgeConfig,
|
|
8
|
+
type PermissionResponseEvent,
|
|
9
|
+
type WorkResponse,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
type BridgeApiDeps = {
|
|
13
|
+
baseUrl: string
|
|
14
|
+
getAccessToken: () => string | undefined
|
|
15
|
+
runnerVersion: string
|
|
16
|
+
onDebug?: (msg: string) => void
|
|
17
|
+
/**
|
|
18
|
+
* Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
|
|
19
|
+
* in which case the request is retried once. Injected because
|
|
20
|
+
* handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
|
|
21
|
+
* file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
|
|
22
|
+
* (~1300 modules). Daemon callers using env-var tokens omit this — their
|
|
23
|
+
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
|
|
24
|
+
*/
|
|
25
|
+
onAuth401?: (staleAccessToken: string) => Promise<boolean>
|
|
26
|
+
/**
|
|
27
|
+
* Returns the trusted device token to send as X-Trusted-Device-Token on
|
|
28
|
+
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
|
|
29
|
+
* server (CCR v2); when the server's enforcement flag is on,
|
|
30
|
+
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
|
|
31
|
+
* Optional — when absent or returning undefined, the header is omitted
|
|
32
|
+
* and the server falls through to its flag-off/no-op path. The CLI-side
|
|
33
|
+
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
|
|
34
|
+
*/
|
|
35
|
+
getTrustedDeviceToken?: () => string | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BETA_HEADER = 'environments-2025-11-01'
|
|
39
|
+
|
|
40
|
+
/** Allowlist pattern for server-provided IDs used in URL path segments. */
|
|
41
|
+
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate that a server-provided ID is safe to interpolate into a URL path.
|
|
45
|
+
* Prevents path traversal (e.g. `../../admin`) and injection via IDs that
|
|
46
|
+
* contain slashes, dots, or other special characters.
|
|
47
|
+
*/
|
|
48
|
+
export function validateBridgeId(id: string, label: string): string {
|
|
49
|
+
if (!id || !SAFE_ID_PATTERN.test(id)) {
|
|
50
|
+
throw new Error(`Invalid ${label}: contains unsafe characters`)
|
|
51
|
+
}
|
|
52
|
+
return id
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fatal bridge errors that should not be retried (e.g. auth failures). */
|
|
56
|
+
export class BridgeFatalError extends Error {
|
|
57
|
+
readonly status: number
|
|
58
|
+
/** Server-provided error type, e.g. "environment_expired". */
|
|
59
|
+
readonly errorType: string | undefined
|
|
60
|
+
constructor(message: string, status: number, errorType?: string) {
|
|
61
|
+
super(message)
|
|
62
|
+
this.name = 'BridgeFatalError'
|
|
63
|
+
this.status = status
|
|
64
|
+
this.errorType = errorType
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|
69
|
+
function debug(msg: string): void {
|
|
70
|
+
deps.onDebug?.(msg)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let consecutiveEmptyPolls = 0
|
|
74
|
+
const EMPTY_POLL_LOG_INTERVAL = 100
|
|
75
|
+
|
|
76
|
+
function getHeaders(accessToken: string): Record<string, string> {
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
Authorization: `Bearer ${accessToken}`,
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'anthropic-version': '2023-06-01',
|
|
81
|
+
'anthropic-beta': BETA_HEADER,
|
|
82
|
+
'x-environment-runner-version': deps.runnerVersion,
|
|
83
|
+
}
|
|
84
|
+
const deviceToken = deps.getTrustedDeviceToken?.()
|
|
85
|
+
if (deviceToken) {
|
|
86
|
+
headers['X-Trusted-Device-Token'] = deviceToken
|
|
87
|
+
}
|
|
88
|
+
return headers
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveAuth(): string {
|
|
92
|
+
const accessToken = deps.getAccessToken()
|
|
93
|
+
if (!accessToken) {
|
|
94
|
+
throw new Error(BRIDGE_LOGIN_INSTRUCTION)
|
|
95
|
+
}
|
|
96
|
+
return accessToken
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Execute an OAuth-authenticated request with a single retry on 401.
|
|
101
|
+
* On 401, attempts token refresh via handleOAuth401Error (same pattern as
|
|
102
|
+
* withRetry.ts for v1/messages). If refresh succeeds, retries the request
|
|
103
|
+
* once with the new token. If refresh fails or the retry also returns 401,
|
|
104
|
+
* the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
|
|
105
|
+
*/
|
|
106
|
+
async function withOAuthRetry<T>(
|
|
107
|
+
fn: (accessToken: string) => Promise<{ status: number; data: T }>,
|
|
108
|
+
context: string,
|
|
109
|
+
): Promise<{ status: number; data: T }> {
|
|
110
|
+
const accessToken = resolveAuth()
|
|
111
|
+
const response = await fn(accessToken)
|
|
112
|
+
|
|
113
|
+
if (response.status !== 401) {
|
|
114
|
+
return response
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!deps.onAuth401) {
|
|
118
|
+
debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
|
|
119
|
+
return response
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Attempt token refresh — matches the pattern in withRetry.ts
|
|
123
|
+
debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
|
|
124
|
+
const refreshed = await deps.onAuth401(accessToken)
|
|
125
|
+
if (refreshed) {
|
|
126
|
+
debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
|
|
127
|
+
const newToken = resolveAuth()
|
|
128
|
+
const retryResponse = await fn(newToken)
|
|
129
|
+
if (retryResponse.status !== 401) {
|
|
130
|
+
return retryResponse
|
|
131
|
+
}
|
|
132
|
+
debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
|
|
133
|
+
} else {
|
|
134
|
+
debug(`[bridge:api] ${context}: Token refresh failed`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Refresh failed — return 401 for handleErrorStatus to throw
|
|
138
|
+
return response
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
async registerBridgeEnvironment(
|
|
143
|
+
config: BridgeConfig,
|
|
144
|
+
): Promise<{ environment_id: string; environment_secret: string }> {
|
|
145
|
+
debug(
|
|
146
|
+
`[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const response = await withOAuthRetry(
|
|
150
|
+
(token: string) =>
|
|
151
|
+
axios.post<{
|
|
152
|
+
environment_id: string
|
|
153
|
+
environment_secret: string
|
|
154
|
+
}>(
|
|
155
|
+
`${deps.baseUrl}/v1/environments/bridge`,
|
|
156
|
+
{
|
|
157
|
+
machine_name: config.machineName,
|
|
158
|
+
directory: config.dir,
|
|
159
|
+
branch: config.branch,
|
|
160
|
+
git_repo_url: config.gitRepoUrl,
|
|
161
|
+
// Advertise session capacity so claude.ai/code can show
|
|
162
|
+
// "2/4 sessions" badges and only block the picker when
|
|
163
|
+
// actually at capacity. Backends that don't yet accept
|
|
164
|
+
// this field will silently ignore it.
|
|
165
|
+
max_sessions: config.maxSessions,
|
|
166
|
+
// worker_type lets claude.ai filter environments by origin
|
|
167
|
+
// (e.g. assistant picker only shows assistant-mode workers).
|
|
168
|
+
// Desktop cowork app sends "cowork"; we send a distinct value.
|
|
169
|
+
metadata: { worker_type: config.workerType },
|
|
170
|
+
// Idempotent re-registration: if we have a backend-issued
|
|
171
|
+
// environment_id from a prior session (--session-id resume),
|
|
172
|
+
// send it back so the backend reattaches instead of creating
|
|
173
|
+
// a new env. The backend may still hand back a fresh ID if
|
|
174
|
+
// the old one expired — callers must compare the response.
|
|
175
|
+
...(config.reuseEnvironmentId && {
|
|
176
|
+
environment_id: config.reuseEnvironmentId,
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
headers: getHeaders(token),
|
|
181
|
+
timeout: 15_000,
|
|
182
|
+
validateStatus: status => status < 500,
|
|
183
|
+
},
|
|
184
|
+
),
|
|
185
|
+
'Registration',
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
handleErrorStatus(response.status, response.data, 'Registration')
|
|
189
|
+
debug(
|
|
190
|
+
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
|
191
|
+
)
|
|
192
|
+
debug(
|
|
193
|
+
`[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
|
194
|
+
)
|
|
195
|
+
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
|
196
|
+
return response.data
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async pollForWork(
|
|
200
|
+
environmentId: string,
|
|
201
|
+
environmentSecret: string,
|
|
202
|
+
signal?: AbortSignal,
|
|
203
|
+
reclaimOlderThanMs?: number,
|
|
204
|
+
): Promise<WorkResponse | null> {
|
|
205
|
+
validateBridgeId(environmentId, 'environmentId')
|
|
206
|
+
|
|
207
|
+
// Save and reset so errors break the "consecutive empty" streak.
|
|
208
|
+
// Restored below when the response is truly empty.
|
|
209
|
+
const prevEmptyPolls = consecutiveEmptyPolls
|
|
210
|
+
consecutiveEmptyPolls = 0
|
|
211
|
+
|
|
212
|
+
const response = await axios.get<WorkResponse | null>(
|
|
213
|
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
|
|
214
|
+
{
|
|
215
|
+
headers: getHeaders(environmentSecret),
|
|
216
|
+
params:
|
|
217
|
+
reclaimOlderThanMs !== undefined
|
|
218
|
+
? { reclaim_older_than_ms: reclaimOlderThanMs }
|
|
219
|
+
: undefined,
|
|
220
|
+
timeout: 10_000,
|
|
221
|
+
signal,
|
|
222
|
+
validateStatus: status => status < 500,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
handleErrorStatus(response.status, response.data, 'Poll')
|
|
227
|
+
|
|
228
|
+
// Empty body or null = no work available
|
|
229
|
+
if (!response.data) {
|
|
230
|
+
consecutiveEmptyPolls = prevEmptyPolls + 1
|
|
231
|
+
if (
|
|
232
|
+
consecutiveEmptyPolls === 1 ||
|
|
233
|
+
consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
|
|
234
|
+
) {
|
|
235
|
+
debug(
|
|
236
|
+
`[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
debug(
|
|
243
|
+
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
|
|
244
|
+
)
|
|
245
|
+
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
|
246
|
+
return response.data
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async acknowledgeWork(
|
|
250
|
+
environmentId: string,
|
|
251
|
+
workId: string,
|
|
252
|
+
sessionToken: string,
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
validateBridgeId(environmentId, 'environmentId')
|
|
255
|
+
validateBridgeId(workId, 'workId')
|
|
256
|
+
|
|
257
|
+
debug(`[bridge:api] POST .../work/${workId}/ack`)
|
|
258
|
+
|
|
259
|
+
const response = await axios.post(
|
|
260
|
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
|
|
261
|
+
{},
|
|
262
|
+
{
|
|
263
|
+
headers: getHeaders(sessionToken),
|
|
264
|
+
timeout: 10_000,
|
|
265
|
+
validateStatus: s => s < 500,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
handleErrorStatus(response.status, response.data, 'Acknowledge')
|
|
270
|
+
debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
async stopWork(
|
|
274
|
+
environmentId: string,
|
|
275
|
+
workId: string,
|
|
276
|
+
force: boolean,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
validateBridgeId(environmentId, 'environmentId')
|
|
279
|
+
validateBridgeId(workId, 'workId')
|
|
280
|
+
|
|
281
|
+
debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
|
|
282
|
+
|
|
283
|
+
const response = await withOAuthRetry(
|
|
284
|
+
(token: string) =>
|
|
285
|
+
axios.post(
|
|
286
|
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
|
|
287
|
+
{ force },
|
|
288
|
+
{
|
|
289
|
+
headers: getHeaders(token),
|
|
290
|
+
timeout: 10_000,
|
|
291
|
+
validateStatus: s => s < 500,
|
|
292
|
+
},
|
|
293
|
+
),
|
|
294
|
+
'StopWork',
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
handleErrorStatus(response.status, response.data, 'StopWork')
|
|
298
|
+
debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
async deregisterEnvironment(environmentId: string): Promise<void> {
|
|
302
|
+
validateBridgeId(environmentId, 'environmentId')
|
|
303
|
+
|
|
304
|
+
debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
|
|
305
|
+
|
|
306
|
+
const response = await withOAuthRetry(
|
|
307
|
+
(token: string) =>
|
|
308
|
+
axios.delete(
|
|
309
|
+
`${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
|
|
310
|
+
{
|
|
311
|
+
headers: getHeaders(token),
|
|
312
|
+
timeout: 10_000,
|
|
313
|
+
validateStatus: s => s < 500,
|
|
314
|
+
},
|
|
315
|
+
),
|
|
316
|
+
'Deregister',
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
handleErrorStatus(response.status, response.data, 'Deregister')
|
|
320
|
+
debug(
|
|
321
|
+
`[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
|
|
322
|
+
)
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
async archiveSession(sessionId: string): Promise<void> {
|
|
326
|
+
validateBridgeId(sessionId, 'sessionId')
|
|
327
|
+
|
|
328
|
+
debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
|
|
329
|
+
|
|
330
|
+
const response = await withOAuthRetry(
|
|
331
|
+
(token: string) =>
|
|
332
|
+
axios.post(
|
|
333
|
+
`${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
|
|
334
|
+
{},
|
|
335
|
+
{
|
|
336
|
+
headers: getHeaders(token),
|
|
337
|
+
timeout: 10_000,
|
|
338
|
+
validateStatus: s => s < 500,
|
|
339
|
+
},
|
|
340
|
+
),
|
|
341
|
+
'ArchiveSession',
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
// 409 = already archived (idempotent, not an error)
|
|
345
|
+
if (response.status === 409) {
|
|
346
|
+
debug(
|
|
347
|
+
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
|
|
348
|
+
)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
handleErrorStatus(response.status, response.data, 'ArchiveSession')
|
|
353
|
+
debug(
|
|
354
|
+
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
|
|
355
|
+
)
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
async reconnectSession(
|
|
359
|
+
environmentId: string,
|
|
360
|
+
sessionId: string,
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
validateBridgeId(environmentId, 'environmentId')
|
|
363
|
+
validateBridgeId(sessionId, 'sessionId')
|
|
364
|
+
|
|
365
|
+
debug(
|
|
366
|
+
`[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const response = await withOAuthRetry(
|
|
370
|
+
(token: string) =>
|
|
371
|
+
axios.post(
|
|
372
|
+
`${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
|
|
373
|
+
{ session_id: sessionId },
|
|
374
|
+
{
|
|
375
|
+
headers: getHeaders(token),
|
|
376
|
+
timeout: 10_000,
|
|
377
|
+
validateStatus: s => s < 500,
|
|
378
|
+
},
|
|
379
|
+
),
|
|
380
|
+
'ReconnectSession',
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
handleErrorStatus(response.status, response.data, 'ReconnectSession')
|
|
384
|
+
debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
async heartbeatWork(
|
|
388
|
+
environmentId: string,
|
|
389
|
+
workId: string,
|
|
390
|
+
sessionToken: string,
|
|
391
|
+
): Promise<{ lease_extended: boolean; state: string }> {
|
|
392
|
+
validateBridgeId(environmentId, 'environmentId')
|
|
393
|
+
validateBridgeId(workId, 'workId')
|
|
394
|
+
|
|
395
|
+
debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
|
|
396
|
+
|
|
397
|
+
const response = await axios.post<{
|
|
398
|
+
lease_extended: boolean
|
|
399
|
+
state: string
|
|
400
|
+
last_heartbeat: string
|
|
401
|
+
ttl_seconds: number
|
|
402
|
+
}>(
|
|
403
|
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
|
|
404
|
+
{},
|
|
405
|
+
{
|
|
406
|
+
headers: getHeaders(sessionToken),
|
|
407
|
+
timeout: 10_000,
|
|
408
|
+
validateStatus: s => s < 500,
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
handleErrorStatus(response.status, response.data, 'Heartbeat')
|
|
413
|
+
debug(
|
|
414
|
+
`[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
|
|
415
|
+
)
|
|
416
|
+
return response.data
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
async sendPermissionResponseEvent(
|
|
420
|
+
sessionId: string,
|
|
421
|
+
event: PermissionResponseEvent,
|
|
422
|
+
sessionToken: string,
|
|
423
|
+
): Promise<void> {
|
|
424
|
+
validateBridgeId(sessionId, 'sessionId')
|
|
425
|
+
|
|
426
|
+
debug(
|
|
427
|
+
`[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const response = await axios.post(
|
|
431
|
+
`${deps.baseUrl}/v1/sessions/${sessionId}/events`,
|
|
432
|
+
{ events: [event] },
|
|
433
|
+
{
|
|
434
|
+
headers: getHeaders(sessionToken),
|
|
435
|
+
timeout: 10_000,
|
|
436
|
+
validateStatus: s => s < 500,
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
handleErrorStatus(
|
|
441
|
+
response.status,
|
|
442
|
+
response.data,
|
|
443
|
+
'SendPermissionResponseEvent',
|
|
444
|
+
)
|
|
445
|
+
debug(
|
|
446
|
+
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
|
|
447
|
+
)
|
|
448
|
+
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
|
|
449
|
+
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function handleErrorStatus(
|
|
455
|
+
status: number,
|
|
456
|
+
data: unknown,
|
|
457
|
+
context: string,
|
|
458
|
+
): void {
|
|
459
|
+
if (status === 200 || status === 204) {
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
const detail = extractErrorDetail(data)
|
|
463
|
+
const errorType = extractErrorTypeFromData(data)
|
|
464
|
+
switch (status) {
|
|
465
|
+
case 401:
|
|
466
|
+
throw new BridgeFatalError(
|
|
467
|
+
`${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
|
|
468
|
+
401,
|
|
469
|
+
errorType,
|
|
470
|
+
)
|
|
471
|
+
case 403:
|
|
472
|
+
throw new BridgeFatalError(
|
|
473
|
+
isExpiredErrorType(errorType)
|
|
474
|
+
? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
|
|
475
|
+
: `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
|
|
476
|
+
403,
|
|
477
|
+
errorType,
|
|
478
|
+
)
|
|
479
|
+
case 404:
|
|
480
|
+
throw new BridgeFatalError(
|
|
481
|
+
detail ??
|
|
482
|
+
`${context}: Not found (404). Remote Control may not be available for this organization.`,
|
|
483
|
+
404,
|
|
484
|
+
errorType,
|
|
485
|
+
)
|
|
486
|
+
case 410:
|
|
487
|
+
throw new BridgeFatalError(
|
|
488
|
+
detail ??
|
|
489
|
+
'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
|
|
490
|
+
410,
|
|
491
|
+
errorType ?? 'environment_expired',
|
|
492
|
+
)
|
|
493
|
+
case 429:
|
|
494
|
+
throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
|
|
495
|
+
default:
|
|
496
|
+
throw new Error(
|
|
497
|
+
`${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Check whether an error type string indicates a session/environment expiry. */
|
|
503
|
+
export function isExpiredErrorType(errorType: string | undefined): boolean {
|
|
504
|
+
if (!errorType) {
|
|
505
|
+
return false
|
|
506
|
+
}
|
|
507
|
+
return errorType.includes('expired') || errorType.includes('lifetime')
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Check whether a BridgeFatalError is a suppressible 403 permission error.
|
|
512
|
+
* These are 403 errors for scopes like 'external_poll_sessions' or operations
|
|
513
|
+
* like StopWork that fail because the user's role lacks 'environments:manage'.
|
|
514
|
+
* They don't affect core functionality and shouldn't be shown to users.
|
|
515
|
+
*/
|
|
516
|
+
export function isSuppressible403(err: BridgeFatalError): boolean {
|
|
517
|
+
if (err.status !== 403) {
|
|
518
|
+
return false
|
|
519
|
+
}
|
|
520
|
+
return (
|
|
521
|
+
err.message.includes('external_poll_sessions') ||
|
|
522
|
+
err.message.includes('environments:manage')
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function extractErrorTypeFromData(data: unknown): string | undefined {
|
|
527
|
+
if (data && typeof data === 'object') {
|
|
528
|
+
if (
|
|
529
|
+
'error' in data &&
|
|
530
|
+
data.error &&
|
|
531
|
+
typeof data.error === 'object' &&
|
|
532
|
+
'type' in data.error &&
|
|
533
|
+
typeof data.error.type === 'string'
|
|
534
|
+
) {
|
|
535
|
+
return data.error.type
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return undefined
|
|
539
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared bridge auth/URL resolution. Consolidates the ant-only
|
|
3
|
+
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
|
|
4
|
+
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
|
|
5
|
+
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
|
|
6
|
+
* /remote-control.
|
|
7
|
+
*
|
|
8
|
+
* Two layers: *Override() returns the ant-only env var (or undefined);
|
|
9
|
+
* the non-Override versions fall through to the real OAuth store/config.
|
|
10
|
+
* Callers that compose with a different auth source (e.g. daemon workers
|
|
11
|
+
* using IPC auth) use the Override getters directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getOauthConfig } from '../constants/oauth.js'
|
|
15
|
+
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
|
16
|
+
|
|
17
|
+
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
|
18
|
+
export function getBridgeTokenOverride(): string | undefined {
|
|
19
|
+
return (
|
|
20
|
+
(process.env.USER_TYPE === 'ant' &&
|
|
21
|
+
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
|
|
22
|
+
undefined
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
|
27
|
+
export function getBridgeBaseUrlOverride(): string | undefined {
|
|
28
|
+
return (
|
|
29
|
+
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
|
|
30
|
+
undefined
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Access token for bridge API calls: dev override first, then the OAuth
|
|
36
|
+
* keychain. Undefined means "not logged in".
|
|
37
|
+
*/
|
|
38
|
+
export function getBridgeAccessToken(): string | undefined {
|
|
39
|
+
return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Base URL for bridge API calls: dev override first, then the production
|
|
44
|
+
* OAuth config. Always returns a URL.
|
|
45
|
+
*/
|
|
46
|
+
export function getBridgeBaseUrl(): string {
|
|
47
|
+
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
|
|
48
|
+
}
|