@hypercli.com/sdk 0.9.1
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/README.md +629 -0
- package/dist/acp-driver.d.ts +72 -0
- package/dist/acp-driver.d.ts.map +1 -0
- package/dist/acp-driver.js +147 -0
- package/dist/acp-driver.js.map +1 -0
- package/dist/acp-pool.d.ts +70 -0
- package/dist/acp-pool.d.ts.map +1 -0
- package/dist/acp-pool.js +181 -0
- package/dist/acp-pool.js.map +1 -0
- package/dist/acp.d.ts +179 -0
- package/dist/acp.d.ts.map +1 -0
- package/dist/acp.js +487 -0
- package/dist/acp.js.map +1 -0
- package/dist/agent-persona.d.ts +68 -0
- package/dist/agent-persona.d.ts.map +1 -0
- package/dist/agent-persona.js +78 -0
- package/dist/agent-persona.js.map +1 -0
- package/dist/agent-slots.d.ts +18 -0
- package/dist/agent-slots.d.ts.map +1 -0
- package/dist/agent-slots.js +24 -0
- package/dist/agent-slots.js.map +1 -0
- package/dist/agent-urls.d.ts +47 -0
- package/dist/agent-urls.d.ts.map +1 -0
- package/dist/agent-urls.js +178 -0
- package/dist/agent-urls.js.map +1 -0
- package/dist/agent.d.ts +482 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +926 -0
- package/dist/agent.js.map +1 -0
- package/dist/agents.d.ts +1452 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +4022 -0
- package/dist/agents.js.map +1 -0
- package/dist/billing.d.ts +72 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +93 -0
- package/dist/billing.js.map +1 -0
- package/dist/browser-jobs.d.ts +24 -0
- package/dist/browser-jobs.d.ts.map +1 -0
- package/dist/browser-jobs.js +209 -0
- package/dist/browser-jobs.js.map +1 -0
- package/dist/browser.d.ts +41 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +57 -0
- package/dist/browser.js.map +1 -0
- package/dist/buzz-activity.d.ts +169 -0
- package/dist/buzz-activity.d.ts.map +1 -0
- package/dist/buzz-activity.js +844 -0
- package/dist/buzz-activity.js.map +1 -0
- package/dist/channels.d.ts +99 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +27 -0
- package/dist/channels.js.map +1 -0
- package/dist/chat-integrations.d.ts +44 -0
- package/dist/chat-integrations.d.ts.map +1 -0
- package/dist/chat-integrations.js +138 -0
- package/dist/chat-integrations.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +144 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +192 -0
- package/dist/config.js.map +1 -0
- package/dist/connectors.d.ts +80 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +2 -0
- package/dist/connectors.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +60 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +128 -0
- package/dist/files.js.map +1 -0
- package/dist/http.d.ts +56 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +362 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/instances.d.ts +94 -0
- package/dist/instances.d.ts.map +1 -0
- package/dist/instances.js +197 -0
- package/dist/instances.js.map +1 -0
- package/dist/integrations.d.ts +111 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrations.js +206 -0
- package/dist/integrations.js.map +1 -0
- package/dist/job/base.d.ts +86 -0
- package/dist/job/base.d.ts.map +1 -0
- package/dist/job/base.js +206 -0
- package/dist/job/base.js.map +1 -0
- package/dist/job/comfyui.d.ts +259 -0
- package/dist/job/comfyui.d.ts.map +1 -0
- package/dist/job/comfyui.js +1563 -0
- package/dist/job/comfyui.js.map +1 -0
- package/dist/job/gradio.d.ts +48 -0
- package/dist/job/gradio.d.ts.map +1 -0
- package/dist/job/gradio.js +90 -0
- package/dist/job/gradio.js.map +1 -0
- package/dist/job-codecs.d.ts +66 -0
- package/dist/job-codecs.d.ts.map +1 -0
- package/dist/job-codecs.js +132 -0
- package/dist/job-codecs.js.map +1 -0
- package/dist/jobs.d.ts +148 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +480 -0
- package/dist/jobs.js.map +1 -0
- package/dist/keys.d.ts +99 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +145 -0
- package/dist/keys.js.map +1 -0
- package/dist/logs.d.ts +54 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +256 -0
- package/dist/logs.js.map +1 -0
- package/dist/managed-context.d.ts +20 -0
- package/dist/managed-context.d.ts.map +1 -0
- package/dist/managed-context.js +95 -0
- package/dist/managed-context.js.map +1 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +8 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +87 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +79 -0
- package/dist/memory.js.map +1 -0
- package/dist/models.d.ts +15 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +19 -0
- package/dist/models.js.map +1 -0
- package/dist/openclaw-control-ui-origin.d.ts +23 -0
- package/dist/openclaw-control-ui-origin.d.ts.map +1 -0
- package/dist/openclaw-control-ui-origin.js +53 -0
- package/dist/openclaw-control-ui-origin.js.map +1 -0
- package/dist/renders.d.ts +187 -0
- package/dist/renders.d.ts.map +1 -0
- package/dist/renders.js +360 -0
- package/dist/renders.js.map +1 -0
- package/dist/routines.d.ts +59 -0
- package/dist/routines.d.ts.map +1 -0
- package/dist/routines.js +135 -0
- package/dist/routines.js.map +1 -0
- package/dist/runners.d.ts +61 -0
- package/dist/runners.d.ts.map +1 -0
- package/dist/runners.js +127 -0
- package/dist/runners.js.map +1 -0
- package/dist/sessions.d.ts +280 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +292 -0
- package/dist/sessions.js.map +1 -0
- package/dist/skills.d.ts +170 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +2 -0
- package/dist/skills.js.map +1 -0
- package/dist/user.d.ts +78 -0
- package/dist/user.d.ts.map +1 -0
- package/dist/user.js +143 -0
- package/dist/user.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/voice-session.d.ts +66 -0
- package/dist/voice-session.d.ts.map +1 -0
- package/dist/voice-session.js +366 -0
- package/dist/voice-session.js.map +1 -0
- package/dist/voice-transcription-session.d.ts +64 -0
- package/dist/voice-transcription-session.d.ts.map +1 -0
- package/dist/voice-transcription-session.js +228 -0
- package/dist/voice-transcription-session.js.map +1 -0
- package/dist/voice.d.ts +62 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +134 -0
- package/dist/voice.js.map +1 -0
- package/dist/workspace-tomd.d.ts +61 -0
- package/dist/workspace-tomd.d.ts.map +1 -0
- package/dist/workspace-tomd.js +177 -0
- package/dist/workspace-tomd.js.map +1 -0
- package/dist/workspaces.d.ts +255 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +653 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +115 -0
- package/runtime/config-require.browser.js +4 -0
- package/runtime/config-require.d.ts +2 -0
- package/runtime/config-require.node.js +8 -0
package/dist/agents.js
ADDED
|
@@ -0,0 +1,4022 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HyperClaw agents API - typed agent lifecycle, files, and exec. Agent chat
|
|
3
|
+
* and sessions are driven over ACP (see acp.ts) through the backend /ws
|
|
4
|
+
* bridge; the SDK no longer talks to runtime-internal gateway processes.
|
|
5
|
+
*/
|
|
6
|
+
import { randomFillSync } from 'node:crypto';
|
|
7
|
+
import NodeWebSocket from 'ws';
|
|
8
|
+
import { sessionMessageMetaFromWire } from './sessions.js';
|
|
9
|
+
import { DEFAULT_PERSONA_SOUL_PATH, DEFAULT_PERSONA_USER_PATH, } from './agent-persona.js';
|
|
10
|
+
// undici's native WebSocket (Node 18+) drops a permessage-deflate frame when
|
|
11
|
+
// the peer's close frame and FIN land in the same TCP chunk, surfacing as a
|
|
12
|
+
// 1006 with the final message lost. In Node always use the `ws` package;
|
|
13
|
+
// everywhere else use the native implementation. Resolved per connection (not
|
|
14
|
+
// module-load), and an overridden globalThis.WebSocket (e.g. a test stub) is
|
|
15
|
+
// honored in every runtime.
|
|
16
|
+
function preferredWebSocket() {
|
|
17
|
+
const global = globalThis.WebSocket;
|
|
18
|
+
if (typeof process !== 'undefined' && typeof process.versions?.node === 'string' && global !== undefined) {
|
|
19
|
+
// Node's built-in WebSocket is `class _WebSocket extends EventTarget`
|
|
20
|
+
// (undici); anything else is a deliberate override.
|
|
21
|
+
if (Function.prototype.toString.call(global).startsWith('class _WebSocket')) {
|
|
22
|
+
return NodeWebSocket;
|
|
23
|
+
}
|
|
24
|
+
return global;
|
|
25
|
+
}
|
|
26
|
+
return global ?? NodeWebSocket;
|
|
27
|
+
}
|
|
28
|
+
export { preferredWebSocket };
|
|
29
|
+
import { agentSlotFromDict, parseAgentSlotSize, } from './agent-slots.js';
|
|
30
|
+
export { agentSlotFromDict, parseAgentSlotSize, } from './agent-slots.js';
|
|
31
|
+
import { defaultAcpProxyWsUrl, defaultAgentsWsUrl, defaultHyperAcpWsUrl, resolveAgentsApiBase, } from './agent-urls.js';
|
|
32
|
+
import { getAgentsApiBaseUrl } from './config.js';
|
|
33
|
+
export { parseControlUiAllowedOrigins, } from './openclaw-control-ui-origin.js';
|
|
34
|
+
import { subscribeBuzzActivity, subscribeBuzzActivityRoute, } from './buzz-activity.js';
|
|
35
|
+
import { CodingAgentAcpClient, } from './acp.js';
|
|
36
|
+
import { CodingAgentAcpPool } from './acp-pool.js';
|
|
37
|
+
import { AcpTurnDriver } from './acp-driver.js';
|
|
38
|
+
export { CodingAgentAcpClient, CodingAgentAcpConnectionError, CodingAgentAcpRequestError, CodingAgentAcpAttachmentError, CodingAgentAcpUnavailableError, ACP_RECONNECT_DELAYS_MS, } from './acp.js';
|
|
39
|
+
export { AcpTurnDriver, } from './acp-driver.js';
|
|
40
|
+
export { CodingAgentAcpPool } from './acp-pool.js';
|
|
41
|
+
// Activity-transport error classes, re-exported here so consumers can classify
|
|
42
|
+
// failures without pulling the SDK root entry into their bundle.
|
|
43
|
+
export { BuzzActivityGapError, BuzzActivityRouteUnavailableError, } from './buzz-activity.js';
|
|
44
|
+
import { APIError } from './errors.js';
|
|
45
|
+
import { HTTPClient } from './http.js';
|
|
46
|
+
import { normalizeSlackRelayBaseUrl } from './channels.js';
|
|
47
|
+
const DEPLOYMENTS_API_PREFIX = '/deployments';
|
|
48
|
+
export const DEFAULT_OPENCLAW_IMAGE = 'ghcr.io/hypercli/hypercli-openclaw:prod';
|
|
49
|
+
export const DEFAULT_HERMES_AGENT_IMAGE = 'ghcr.io/hypercli/hypercli-hermes:latest';
|
|
50
|
+
export const DEFAULT_OPENCODE_IMAGE = 'ghcr.io/hypercli/hypercli-opencode:latest';
|
|
51
|
+
export const DEFAULT_CODEX_IMAGE = 'ghcr.io/hypercli/hypercli-codex:latest';
|
|
52
|
+
export const DEFAULT_CLAUDE_CODE_IMAGE = 'ghcr.io/hypercli/hypercli-claude:latest';
|
|
53
|
+
export const DEFAULT_GOOSE_IMAGE = 'ghcr.io/hypercli/hypercli-goose:latest';
|
|
54
|
+
export const DEFAULT_KIMI_CODE_IMAGE = 'ghcr.io/hypercli/hypercli-kimi-code:latest';
|
|
55
|
+
export const DEFAULT_PI_IMAGE = 'ghcr.io/hypercli/hypercli-pi:latest';
|
|
56
|
+
export const DEFAULT_BUZZ_AGENT_IMAGE = 'ghcr.io/hypercli/hypercli-buzz-agent:latest';
|
|
57
|
+
export const DEFAULT_BUZZ_OPENCODE_IMAGE = DEFAULT_OPENCODE_IMAGE;
|
|
58
|
+
export const DEFAULT_BUZZ_CODEX_IMAGE = DEFAULT_CODEX_IMAGE;
|
|
59
|
+
export const DEFAULT_BUZZ_CLAUDE_CODE_IMAGE = DEFAULT_CLAUDE_CODE_IMAGE;
|
|
60
|
+
export const DEFAULT_BUZZ_GOOSE_IMAGE = DEFAULT_GOOSE_IMAGE;
|
|
61
|
+
export const DEFAULT_BUZZ_KIMI_CODE_IMAGE = DEFAULT_KIMI_CODE_IMAGE;
|
|
62
|
+
export const DEFAULT_AGENT_RUNTIME_SCOPES = Object.freeze([
|
|
63
|
+
'agents:none',
|
|
64
|
+
'files:*',
|
|
65
|
+
'flows:*',
|
|
66
|
+
'models:*',
|
|
67
|
+
'voice:*',
|
|
68
|
+
'web:*',
|
|
69
|
+
'workspaces:*',
|
|
70
|
+
]);
|
|
71
|
+
export const DEFAULT_CODING_AGENT_SYNC_ROOT = '/home/node';
|
|
72
|
+
// Runner-docker bind-mount cap; matches the Backend wire model
|
|
73
|
+
// (AssignmentDockerOptions.volumes max_length).
|
|
74
|
+
export const MAX_DOCKER_VOLUMES = 64;
|
|
75
|
+
// Compatibility export: runtime configuration roots are selected on the host.
|
|
76
|
+
export const DEFAULT_PI_ENV = Object.freeze({});
|
|
77
|
+
export const DEFAULT_CODING_AGENT_IMAGES = {
|
|
78
|
+
'buzz-agent': DEFAULT_BUZZ_AGENT_IMAGE,
|
|
79
|
+
opencode: DEFAULT_OPENCODE_IMAGE,
|
|
80
|
+
codex: DEFAULT_CODEX_IMAGE,
|
|
81
|
+
'claude-code': DEFAULT_CLAUDE_CODE_IMAGE,
|
|
82
|
+
goose: DEFAULT_GOOSE_IMAGE,
|
|
83
|
+
'kimi-code': DEFAULT_KIMI_CODE_IMAGE,
|
|
84
|
+
pi: DEFAULT_PI_IMAGE,
|
|
85
|
+
};
|
|
86
|
+
export const DEFAULT_CODING_AGENT_SYNC_INCLUDES = {
|
|
87
|
+
'buzz-agent': null,
|
|
88
|
+
opencode: [
|
|
89
|
+
DEFAULT_PERSONA_USER_PATH, DEFAULT_PERSONA_SOUL_PATH,
|
|
90
|
+
'.config/opencode',
|
|
91
|
+
'.local/share/opencode',
|
|
92
|
+
'.local/state/opencode',
|
|
93
|
+
'.cache/opencode',
|
|
94
|
+
],
|
|
95
|
+
codex: ['.codex', DEFAULT_PERSONA_USER_PATH, DEFAULT_PERSONA_SOUL_PATH],
|
|
96
|
+
'claude-code': ['.claude', '.claude.json', DEFAULT_PERSONA_USER_PATH, DEFAULT_PERSONA_SOUL_PATH],
|
|
97
|
+
goose: ['.goose', DEFAULT_PERSONA_USER_PATH, DEFAULT_PERSONA_SOUL_PATH],
|
|
98
|
+
'kimi-code': ['.kimi-code', DEFAULT_PERSONA_USER_PATH, DEFAULT_PERSONA_SOUL_PATH],
|
|
99
|
+
// Native agent state and pi-acp metadata (~/.pi/pi-acp).
|
|
100
|
+
pi: ['.pi', DEFAULT_PERSONA_USER_PATH, DEFAULT_PERSONA_SOUL_PATH],
|
|
101
|
+
};
|
|
102
|
+
export const DEFAULT_BUZZ_CODING_AGENT_IMAGES = {
|
|
103
|
+
'buzz-agent': DEFAULT_BUZZ_AGENT_IMAGE,
|
|
104
|
+
opencode: DEFAULT_BUZZ_OPENCODE_IMAGE,
|
|
105
|
+
codex: DEFAULT_BUZZ_CODEX_IMAGE,
|
|
106
|
+
'claude-code': DEFAULT_BUZZ_CLAUDE_CODE_IMAGE,
|
|
107
|
+
goose: DEFAULT_BUZZ_GOOSE_IMAGE,
|
|
108
|
+
'kimi-code': DEFAULT_BUZZ_KIMI_CODE_IMAGE,
|
|
109
|
+
pi: DEFAULT_PI_IMAGE,
|
|
110
|
+
};
|
|
111
|
+
const BUZZ_RUNTIME_COMMANDS = {
|
|
112
|
+
'buzz-agent': {
|
|
113
|
+
command: '/usr/local/bin/buzz-agent',
|
|
114
|
+
args: [],
|
|
115
|
+
},
|
|
116
|
+
opencode: {
|
|
117
|
+
command: '/opt/hypercli/bin/opencode',
|
|
118
|
+
args: ['acp'],
|
|
119
|
+
},
|
|
120
|
+
codex: {
|
|
121
|
+
command: '/opt/hypercli/bin/codex-acp',
|
|
122
|
+
args: [],
|
|
123
|
+
},
|
|
124
|
+
'claude-code': {
|
|
125
|
+
command: '/opt/hypercli/bin/claude-agent-acp',
|
|
126
|
+
args: [],
|
|
127
|
+
},
|
|
128
|
+
goose: {
|
|
129
|
+
command: '/usr/local/bin/goose',
|
|
130
|
+
args: ['acp'],
|
|
131
|
+
},
|
|
132
|
+
'kimi-code': {
|
|
133
|
+
command: '/opt/hypercli/bin/kimi',
|
|
134
|
+
args: ['acp'],
|
|
135
|
+
},
|
|
136
|
+
pi: {
|
|
137
|
+
command: '/opt/hypercli/bin/pi-acp',
|
|
138
|
+
args: [],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
export const DEFAULT_BUZZ_RUST_LOG = 'hyper_acp=info,buzz_acp=info,pool::prompt=info,acp::stream=off';
|
|
142
|
+
// Sync direction: this set mirrors the provider's AUTHORITATIVE_ENV_KEYS
|
|
143
|
+
// (acp/buzz-backend-provider) — the boundary enforcer — and rs-sdk
|
|
144
|
+
// BUZZ_RESERVED_ENV; a provider-side parity test asserts the three key sets
|
|
145
|
+
// are identical. Keys the provider deliberately passes through as caller
|
|
146
|
+
// policy (BUZZ_ACP_MODEL
|
|
147
|
+
// — typed buzz config mints overwrite caller
|
|
148
|
+
// values) are excluded on purpose: they remain caller-settable launch policy
|
|
149
|
+
// at the boundary.
|
|
150
|
+
const BUZZ_RESERVED_ENV_KEYS = new Set([
|
|
151
|
+
'HYPER_AGENTS_API_KEY',
|
|
152
|
+
'BUZZ_PRIVATE_KEY',
|
|
153
|
+
'NOSTR_PRIVATE_KEY',
|
|
154
|
+
'BUZZ_AUTH_TAG',
|
|
155
|
+
'BUZZ_API_TOKEN',
|
|
156
|
+
'BUZZ_ACP_PRIVATE_KEY',
|
|
157
|
+
'BUZZ_ACP_API_TOKEN',
|
|
158
|
+
'BUZZ_RELAY_URL',
|
|
159
|
+
'BUZZ_ACP_AGENT_OWNER',
|
|
160
|
+
'BUZZ_ACP_AGENT_COMMAND',
|
|
161
|
+
'BUZZ_ACP_AGENT_ARGS',
|
|
162
|
+
'BUZZ_ACP_MCP_COMMAND',
|
|
163
|
+
'BUZZ_ACP_MULTIPLE_EVENT_HANDLING',
|
|
164
|
+
'BUZZ_ACP_RELAY_OBSERVER',
|
|
165
|
+
'BUZZ_ACP_RESPOND_TO',
|
|
166
|
+
'BUZZ_ACP_RESPOND_TO_ALLOWLIST',
|
|
167
|
+
'BUZZ_ACP_SETUP_PAYLOAD',
|
|
168
|
+
'BUZZ_MANAGED_AGENT',
|
|
169
|
+
'BUZZ_MANAGED_AGENT_START_NONCE',
|
|
170
|
+
// No longer minted anywhere (dead: nothing reads them); kept listed so
|
|
171
|
+
// caller-supplied values are stripped.
|
|
172
|
+
'BUZZ_ACP_DISPLAY_NAME',
|
|
173
|
+
'BUZZ_ACP_TEXT_MENTIONS',
|
|
174
|
+
'BUZZ_ACP_REQUIRE_REPLY',
|
|
175
|
+
'BUZZ_AGENT_REQUIRE_REPLY',
|
|
176
|
+
'BUZZ_ACP_SYSTEM_PROMPT',
|
|
177
|
+
'BUZZ_ACP_SYSTEM_PROMPT_FILE',
|
|
178
|
+
'HYPER_ACP_WS_LISTEN',
|
|
179
|
+
'HYPER_ACP_LOG',
|
|
180
|
+
'HYPER_ACP_WS_TOKEN',
|
|
181
|
+
'HYPER_ACP_WS_URL',
|
|
182
|
+
'HYPER_ACP_AGENT_COMMAND',
|
|
183
|
+
'HYPER_ACP_AGENT_ARGS',
|
|
184
|
+
'HYPER_ACP_PERMISSIONS',
|
|
185
|
+
'HYPER_ACP_PERMISSION_MODE',
|
|
186
|
+
'HYPER_ACP_AUTO_APPROVE_PERMISSION',
|
|
187
|
+
'HYPER_ACP_TRACE_DB',
|
|
188
|
+
'HYPER_ACP_CORS_ORIGIN',
|
|
189
|
+
'CLAUDE_CODE_EXECUTABLE',
|
|
190
|
+
'HYPER_WORKSPACES_BOOT_SYNC',
|
|
191
|
+
'HYPER_WORKSPACES_SYNC_READY_ONLY',
|
|
192
|
+
'HYPER_WORKSPACES_SYNC_WORKSPACE',
|
|
193
|
+
]);
|
|
194
|
+
export const OPENCLAW_MEMORY_SEARCH_ENV_DEFAULTS = {
|
|
195
|
+
OPENCLAW_MEMORY_SEARCH_ENABLED: '1',
|
|
196
|
+
OPENCLAW_MEMORY_SEARCH_SYNC_ON_SESSION_START: '0',
|
|
197
|
+
OPENCLAW_MEMORY_SEARCH_SYNC_ON_SEARCH: '0',
|
|
198
|
+
OPENCLAW_MEMORY_SEARCH_SYNC_WATCH: '0',
|
|
199
|
+
OPENCLAW_MEMORY_SEARCH_SYNC_WATCH_DEBOUNCE_MS: '30000',
|
|
200
|
+
OPENCLAW_MEMORY_SEARCH_SYNC_INTERVAL_MINUTES: '0',
|
|
201
|
+
};
|
|
202
|
+
export const WORKSPACES_SYNC_ENV_DEFAULTS = {
|
|
203
|
+
HYPER_WORKSPACES_BOOT_SYNC: '1',
|
|
204
|
+
HYPER_WORKSPACES_DIR: '/home/node/shared',
|
|
205
|
+
HYPER_WORKSPACES_SYNC_READY_ONLY: '1',
|
|
206
|
+
};
|
|
207
|
+
export const OPENCLAW_CRON_ENV_DEFAULTS = {
|
|
208
|
+
OPENCLAW_CRON_ENABLED: '1',
|
|
209
|
+
};
|
|
210
|
+
export const HERMES_CRON_ENV_DEFAULTS = {
|
|
211
|
+
HERMES_CRON_ENABLED: '1',
|
|
212
|
+
};
|
|
213
|
+
const DEFAULT_OPENCLAW_SYNC_EXCLUDE = [
|
|
214
|
+
'shared/**',
|
|
215
|
+
'.openclaw/npm/**/node_modules/**',
|
|
216
|
+
'.openclaw/agents/**/agent/*.sqlite.memory-reindex-*',
|
|
217
|
+
'.openclaw/agents/**/agent/*.sqlite.reindex-lock.sqlite*',
|
|
218
|
+
'.openclaw/browser/**/Code Cache/**',
|
|
219
|
+
'.openclaw/browser/**/GPUCache/**',
|
|
220
|
+
'.openclaw/browser/**/ShaderCache/**',
|
|
221
|
+
'.openclaw/browser/**/GrShaderCache/**',
|
|
222
|
+
'.openclaw/browser/**/optimization_guide_model_store/**',
|
|
223
|
+
];
|
|
224
|
+
const LAUNCH_CONFIG_KEYS = new Set([
|
|
225
|
+
'image',
|
|
226
|
+
'env',
|
|
227
|
+
'secrets',
|
|
228
|
+
'routes',
|
|
229
|
+
'command',
|
|
230
|
+
'entrypoint',
|
|
231
|
+
'sync_root',
|
|
232
|
+
'sync_include',
|
|
233
|
+
'sync_exclude',
|
|
234
|
+
'sync_uid',
|
|
235
|
+
'sync_gid',
|
|
236
|
+
'registry_url',
|
|
237
|
+
'registry_auth',
|
|
238
|
+
'restart',
|
|
239
|
+
'runtime_scopes',
|
|
240
|
+
'executor',
|
|
241
|
+
'cwd',
|
|
242
|
+
'docker',
|
|
243
|
+
]);
|
|
244
|
+
const DEFAULT_OPENCLAW_SYNC_ROOT = '/home/node';
|
|
245
|
+
export const DEFAULT_HERMES_AGENT_SYNC_ROOT = '/home/hermes';
|
|
246
|
+
export const DEFAULT_HERMES_AGENT_SYNC_EXCLUDE = ['shared/**'];
|
|
247
|
+
export const DEFAULT_HERMES_AGENT_SYNC_UID = 10000;
|
|
248
|
+
export const DEFAULT_HERMES_AGENT_SYNC_GID = 10000;
|
|
249
|
+
export const AGENT_FILE_MAX_BYTES = 250 * 1024 * 1024;
|
|
250
|
+
// Reef file writes traverse the Cloudflare-proxied agent hostname
|
|
251
|
+
// (https://<agent>.hypercli.app/_reef/...), whose edge rejects request bodies
|
|
252
|
+
// above 100 MB. Enforced client-side so oversized writes fail fast with a
|
|
253
|
+
// clear error instead of an opaque edge `413 Payload Too Large`.
|
|
254
|
+
export const AGENT_FILE_WRITE_MAX_BYTES = 100 * 1024 * 1024;
|
|
255
|
+
export const AGENT_FILE_TRANSFER_CHUNK_BYTES = 64 * 1024;
|
|
256
|
+
export const AGENT_FILE_OPERATION_TIMEOUT_MS = 300_000;
|
|
257
|
+
/** Buffered exec stdin cap, mirrored by the backend exec protocol. */
|
|
258
|
+
export const AGENT_EXEC_STDIN_MAX_BYTES = 262_144;
|
|
259
|
+
function shellAbortError() {
|
|
260
|
+
const error = new Error('Shell connection cancelled');
|
|
261
|
+
error.name = 'AbortError';
|
|
262
|
+
return error;
|
|
263
|
+
}
|
|
264
|
+
function runShellOperation(operation, signal, timeoutMs, timeoutMessage) {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
let settled = false;
|
|
268
|
+
const cleanup = () => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
signal?.removeEventListener('abort', abortOperation);
|
|
271
|
+
};
|
|
272
|
+
const finish = (error, value) => {
|
|
273
|
+
if (settled)
|
|
274
|
+
return;
|
|
275
|
+
settled = true;
|
|
276
|
+
cleanup();
|
|
277
|
+
if (error)
|
|
278
|
+
reject(error);
|
|
279
|
+
else
|
|
280
|
+
resolve(value);
|
|
281
|
+
};
|
|
282
|
+
const abortOperation = () => {
|
|
283
|
+
controller.abort(signal?.reason);
|
|
284
|
+
finish(shellAbortError());
|
|
285
|
+
};
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
controller.abort();
|
|
288
|
+
finish(new Error(timeoutMessage));
|
|
289
|
+
}, timeoutMs);
|
|
290
|
+
if (signal?.aborted) {
|
|
291
|
+
abortOperation();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
signal?.addEventListener('abort', abortOperation, { once: true });
|
|
295
|
+
void operation(controller.signal).then((value) => finish(undefined, value), (error) => {
|
|
296
|
+
if (error instanceof Error && error.name === 'AbortError' && !signal?.aborted) {
|
|
297
|
+
finish(new Error(timeoutMessage));
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
finish(error);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
export class AgentCapturedLogsError extends APIError {
|
|
306
|
+
snapshot;
|
|
307
|
+
constructor(error, snapshot) {
|
|
308
|
+
super(error.statusCode, error.detail, error.method, error.url, error.responseText, error.retryAfterSeconds);
|
|
309
|
+
this.snapshot = snapshot;
|
|
310
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
311
|
+
this.name = 'AgentCapturedLogsError';
|
|
312
|
+
this.cause = error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function isCapturedLogsSnapshot(value) {
|
|
316
|
+
if (!value || typeof value !== 'object')
|
|
317
|
+
return false;
|
|
318
|
+
const snapshot = value;
|
|
319
|
+
return snapshot.transport === 'runner' && typeof snapshot.agent_id === 'string'
|
|
320
|
+
&& (typeof snapshot.logs === 'string' || snapshot.logs === null)
|
|
321
|
+
&& (snapshot.unavailable === null || typeof snapshot.unavailable === 'string')
|
|
322
|
+
&& Array.isArray(snapshot.captures);
|
|
323
|
+
}
|
|
324
|
+
function agentLogQuery(options) {
|
|
325
|
+
return {
|
|
326
|
+
...(options.source !== undefined ? { source: options.source } : {}),
|
|
327
|
+
...(options.generation !== undefined ? { generation: options.generation } : {}),
|
|
328
|
+
...(options.maxBytes !== undefined ? { max_bytes: options.maxBytes } : {}),
|
|
329
|
+
...(options.maxLines !== undefined ? { max_lines: options.maxLines } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
export function parseAgentLogFrame(raw) {
|
|
333
|
+
let payload;
|
|
334
|
+
try {
|
|
335
|
+
payload = JSON.parse(raw);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return { kind: 'log', line: raw };
|
|
339
|
+
}
|
|
340
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
341
|
+
return { kind: 'log', line: raw };
|
|
342
|
+
}
|
|
343
|
+
const frame = payload;
|
|
344
|
+
if (typeof frame.event !== 'string')
|
|
345
|
+
return { kind: 'log', line: raw };
|
|
346
|
+
switch (frame.event) {
|
|
347
|
+
case 'captured_logs':
|
|
348
|
+
return isCapturedLogsSnapshot(payload)
|
|
349
|
+
? { kind: 'capturedLogs', snapshot: payload }
|
|
350
|
+
: { kind: 'error', detail: 'Invalid captured logs snapshot' };
|
|
351
|
+
case 'log':
|
|
352
|
+
return {
|
|
353
|
+
kind: 'log',
|
|
354
|
+
line: typeof frame.log === 'string' ? frame.log : String(frame.log ?? ''),
|
|
355
|
+
};
|
|
356
|
+
case 'history_end':
|
|
357
|
+
return { kind: 'historyEnd' };
|
|
358
|
+
case 'error':
|
|
359
|
+
return {
|
|
360
|
+
kind: 'error',
|
|
361
|
+
detail: typeof frame.detail === 'string' && frame.detail
|
|
362
|
+
? frame.detail
|
|
363
|
+
: 'Log stream failed',
|
|
364
|
+
};
|
|
365
|
+
default:
|
|
366
|
+
return { kind: 'ignore' };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function routeConfigBody(route) {
|
|
370
|
+
const body = { port: route.port };
|
|
371
|
+
if (route.auth !== undefined)
|
|
372
|
+
body.auth = route.auth;
|
|
373
|
+
if (route.prefix !== undefined)
|
|
374
|
+
body.prefix = route.prefix;
|
|
375
|
+
if (route.remove_headers?.length)
|
|
376
|
+
body.remove_headers = route.remove_headers;
|
|
377
|
+
return body;
|
|
378
|
+
}
|
|
379
|
+
function routesConfigBody(routes) {
|
|
380
|
+
return Object.fromEntries(Object.entries(routes ?? {}).map(([name, route]) => [name, routeConfigBody(route)]));
|
|
381
|
+
}
|
|
382
|
+
const REQUIRED_START_LAUNCH_CONFIG_KEYS = [
|
|
383
|
+
'image',
|
|
384
|
+
'env',
|
|
385
|
+
'secrets',
|
|
386
|
+
'routes',
|
|
387
|
+
'command',
|
|
388
|
+
'entrypoint',
|
|
389
|
+
'restart',
|
|
390
|
+
'sync_root',
|
|
391
|
+
'sync_uid',
|
|
392
|
+
'sync_gid',
|
|
393
|
+
'registry_url',
|
|
394
|
+
'registry_auth',
|
|
395
|
+
'runtime_scopes',
|
|
396
|
+
];
|
|
397
|
+
const OPENCLAW_SECRET_ONLY_ENV_KEYS = [
|
|
398
|
+
'SLACK_BOT_TOKEN',
|
|
399
|
+
'SLACK_APP_TOKEN',
|
|
400
|
+
'SLACK_USER_TOKEN',
|
|
401
|
+
'SLACK_SIGNING_SECRET',
|
|
402
|
+
'SLACK_RELAY_AUTH_TOKEN',
|
|
403
|
+
];
|
|
404
|
+
function rejectOpenClawSecretOnlyEnv(env, noun = 'env') {
|
|
405
|
+
const matches = OPENCLAW_SECRET_ONLY_ENV_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(env, key));
|
|
406
|
+
if (matches.length > 0) {
|
|
407
|
+
throw new Error(`${matches.join(', ')} must be supplied through secrets, not ${noun}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function cloneCompleteLaunchConfig(value) {
|
|
411
|
+
if (!isPlainRecord(value))
|
|
412
|
+
throw new Error('launchConfig must be a complete object');
|
|
413
|
+
const missing = REQUIRED_START_LAUNCH_CONFIG_KEYS.filter((key) => !Object.prototype.hasOwnProperty.call(value, key));
|
|
414
|
+
if (missing.length > 0) {
|
|
415
|
+
throw new Error(`launchConfig is incomplete; missing: ${missing.join(', ')}`);
|
|
416
|
+
}
|
|
417
|
+
if (Object.prototype.hasOwnProperty.call(value, 'sync_include')
|
|
418
|
+
&& Object.prototype.hasOwnProperty.call(value, 'sync_exclude')) {
|
|
419
|
+
throw new Error('launchConfig cannot carry both sync policies');
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(value.sync_include) && value.sync_include.length === 0) {
|
|
422
|
+
throw new Error('syncInclude must contain at least one path; omit it to sync all');
|
|
423
|
+
}
|
|
424
|
+
if (Array.isArray(value.sync_exclude) && (value.sync_exclude.includes('*') || value.sync_exclude.includes('**'))) {
|
|
425
|
+
throw new Error('syncExclude cannot exclude the entire sync root; omit it to sync all');
|
|
426
|
+
}
|
|
427
|
+
if (typeof value.restart !== 'boolean') {
|
|
428
|
+
throw new Error('launchConfig restart must be a boolean');
|
|
429
|
+
}
|
|
430
|
+
const executor = value.executor;
|
|
431
|
+
if (executor !== undefined && executor !== null && executor !== 'process' && executor !== 'docker') {
|
|
432
|
+
throw new Error("launchConfig executor must be 'process' or 'docker'");
|
|
433
|
+
}
|
|
434
|
+
if (executor === 'process' && value.docker != null) {
|
|
435
|
+
throw new Error('docker launch options require the docker executor');
|
|
436
|
+
}
|
|
437
|
+
return structuredClone(value);
|
|
438
|
+
}
|
|
439
|
+
export const OPENCLAW_TRUSTED_PROXIES_ENV = 'OPENCLAW_TRUSTED_PROXIES';
|
|
440
|
+
const DEFAULT_OPENCLAW_MODEL_ENV = Object.freeze({
|
|
441
|
+
HYPER_MODELS: 'default-anthropic',
|
|
442
|
+
HYPER_EMBEDDING_MODELS: 'qwen3-embedding-4b',
|
|
443
|
+
});
|
|
444
|
+
const DEFAULT_HERMES_MODEL_ENV = DEFAULT_OPENCLAW_MODEL_ENV;
|
|
445
|
+
export function buildOpenClawTrustedProxiesEnv(trustedProxies) {
|
|
446
|
+
const proxies = (trustedProxies ?? []).map((proxy) => proxy.trim()).filter(Boolean);
|
|
447
|
+
return proxies.length > 0 ? { [OPENCLAW_TRUSTED_PROXIES_ENV]: proxies.join(',') } : {};
|
|
448
|
+
}
|
|
449
|
+
function droppedLaunchConfigKeys(data) {
|
|
450
|
+
const warnings = Array.isArray(data.warnings) ? data.warnings : [];
|
|
451
|
+
return warnings
|
|
452
|
+
.filter((warning) => (Boolean(warning) && typeof warning === 'object'))
|
|
453
|
+
.filter((warning) => warning.code === 'unsupported_launch_config_keys_dropped')
|
|
454
|
+
.flatMap((warning) => (Array.isArray(warning.dropped_keys) ? warning.dropped_keys.map(String) : []));
|
|
455
|
+
}
|
|
456
|
+
function buildBuzzLaunchEnv(runtime, buzz) {
|
|
457
|
+
if (!buzz.privateKeyNsec.trim())
|
|
458
|
+
throw new Error('buzz.privateKeyNsec is required');
|
|
459
|
+
if (!buzz.relayUrl.trim())
|
|
460
|
+
throw new Error('buzz.relayUrl is required');
|
|
461
|
+
const harness = BUZZ_RUNTIME_COMMANDS[runtime];
|
|
462
|
+
const env = {
|
|
463
|
+
BUZZ_RELAY_URL: buzz.relayUrl,
|
|
464
|
+
BUZZ_ACP_AGENT_COMMAND: harness.command,
|
|
465
|
+
BUZZ_ACP_AGENT_ARGS: harness.args.join(','),
|
|
466
|
+
};
|
|
467
|
+
if (runtime === 'claude-code') {
|
|
468
|
+
env.CLAUDE_CODE_EXECUTABLE = '/opt/hypercli/bin/claude';
|
|
469
|
+
}
|
|
470
|
+
if (buzz.rustLog)
|
|
471
|
+
env.RUST_LOG = buzz.rustLog;
|
|
472
|
+
const optional = {
|
|
473
|
+
BUZZ_ACP_MODEL: buzz.model,
|
|
474
|
+
BUZZ_ACP_RESPOND_TO: buzz.respondTo,
|
|
475
|
+
BUZZ_ACP_RESPOND_TO_ALLOWLIST: buzz.respondToAllowlist?.length
|
|
476
|
+
? buzz.respondToAllowlist.join(',')
|
|
477
|
+
: undefined,
|
|
478
|
+
};
|
|
479
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
480
|
+
if (value)
|
|
481
|
+
env[key] = value;
|
|
482
|
+
}
|
|
483
|
+
return env;
|
|
484
|
+
}
|
|
485
|
+
function buildBuzzLaunchSecrets(buzz) {
|
|
486
|
+
if (!buzz.privateKeyNsec.trim())
|
|
487
|
+
throw new Error('buzz.privateKeyNsec is required');
|
|
488
|
+
const secrets = {
|
|
489
|
+
BUZZ_PRIVATE_KEY: buzz.privateKeyNsec,
|
|
490
|
+
};
|
|
491
|
+
// The NIP-OA attestation is a bearer credential: keep it in the k8s-backed
|
|
492
|
+
// secrets projection (unrolled to env at launch) rather than plaintext env.
|
|
493
|
+
if (buzz.authTag)
|
|
494
|
+
secrets.BUZZ_AUTH_TAG = buzz.authTag;
|
|
495
|
+
return secrets;
|
|
496
|
+
}
|
|
497
|
+
/** Construct the Buzz-owned launch fields for create or a stopped deployment update.
|
|
498
|
+
* Callers retain all other stored launch fields. No network or identity generation.
|
|
499
|
+
*/
|
|
500
|
+
export function buildBuzzLaunchConfig(runtime, buzz, apiBase, stored = {}) {
|
|
501
|
+
if (!Object.prototype.hasOwnProperty.call(BUZZ_RUNTIME_COMMANDS, runtime)) {
|
|
502
|
+
throw new Error('Buzz requires a coding runtime');
|
|
503
|
+
}
|
|
504
|
+
const env = { ...(stored.env ?? {}) };
|
|
505
|
+
const secrets = { ...(stored.secrets ?? {}) };
|
|
506
|
+
if (buzz) {
|
|
507
|
+
for (const key of BUZZ_RESERVED_ENV_KEYS) {
|
|
508
|
+
delete env[key];
|
|
509
|
+
delete secrets[key];
|
|
510
|
+
}
|
|
511
|
+
Object.assign(env, buildBuzzLaunchEnv(runtime, buzz));
|
|
512
|
+
Object.assign(secrets, buildBuzzLaunchSecrets(buzz));
|
|
513
|
+
}
|
|
514
|
+
env.RUST_LOG ??= DEFAULT_BUZZ_RUST_LOG;
|
|
515
|
+
for (const key of [
|
|
516
|
+
'HYPER_ACP_WS_LISTEN', 'HYPER_ACP_LOG', 'HYPER_ACP_WS_TOKEN',
|
|
517
|
+
'BUZZ_ACP_RELAY_OBSERVER', 'HYPER_ACP_AGENT_COMMAND', 'HYPER_ACP_AGENT_ARGS',
|
|
518
|
+
'HYPER_ACP_AUTO_APPROVE_PERMISSION', 'HYPER_ACP_PERMISSIONS', 'HYPER_ACP_PERMISSION_MODE',
|
|
519
|
+
]) {
|
|
520
|
+
delete env[key];
|
|
521
|
+
delete secrets[key];
|
|
522
|
+
}
|
|
523
|
+
env.HYPER_ACP_WS_URL = defaultHyperAcpWsUrl(apiBase);
|
|
524
|
+
return { ...stored, env, secrets, routes: {}, restart: false,
|
|
525
|
+
command: [stored.executor === 'process' ? 'hyper-acp' : '/usr/local/bin/hyper-acp'] };
|
|
526
|
+
}
|
|
527
|
+
/** Whether the runtime has a Buzz coding harness launch mapping. */
|
|
528
|
+
export function supportsBuzzRuntime(runtime) {
|
|
529
|
+
return typeof runtime === 'string' && Object.prototype.hasOwnProperty.call(BUZZ_RUNTIME_COMMANDS, runtime);
|
|
530
|
+
}
|
|
531
|
+
/** Maximum decoded bytes for the native runner file transport. */
|
|
532
|
+
export const RUNNER_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
533
|
+
function nativeFilePath(path) {
|
|
534
|
+
if (!path || encodeUtf8(path).byteLength > 4096 || /[\\:\0]/.test(path)
|
|
535
|
+
|| path.split('/').some((part) => !part || part === '.' || part === '..' || /[ .]$/.test(part))) {
|
|
536
|
+
throw new Error('Runner file paths must be portable paths relative to the assignment root');
|
|
537
|
+
}
|
|
538
|
+
return path;
|
|
539
|
+
}
|
|
540
|
+
function resolveSyncRootFilePath(path) {
|
|
541
|
+
const normalized = path.replace(/\\/g, '/');
|
|
542
|
+
if (normalized.startsWith('/') || /^[a-z]:/i.test(normalized) || normalized.includes('\0')) {
|
|
543
|
+
throw new Error('agent file paths must be relative to the sync root');
|
|
544
|
+
}
|
|
545
|
+
const parts = normalized.split('/');
|
|
546
|
+
if (parts.includes('..')) {
|
|
547
|
+
throw new Error('agent file paths must stay within the sync root');
|
|
548
|
+
}
|
|
549
|
+
return parts.filter((part) => part && part !== '.').join('/');
|
|
550
|
+
}
|
|
551
|
+
function normalizeWritableBackendFilePath(path) {
|
|
552
|
+
return resolveSyncRootFilePath(path);
|
|
553
|
+
}
|
|
554
|
+
function isUuidRef(value) {
|
|
555
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.trim());
|
|
556
|
+
}
|
|
557
|
+
function isDirectAgentIdRef(value) {
|
|
558
|
+
const raw = value.trim();
|
|
559
|
+
return isUuidRef(raw) || /^[0-9a-f]{6,}$/i.test(raw) || /^(agent|external)[-_:]/i.test(raw);
|
|
560
|
+
}
|
|
561
|
+
function isSelfAgentRef(value) {
|
|
562
|
+
return value.trim().toLowerCase() === 'self';
|
|
563
|
+
}
|
|
564
|
+
function agentRoutesStateFromData(data) {
|
|
565
|
+
return {
|
|
566
|
+
agentId: String(data.agent_id ?? ''),
|
|
567
|
+
routes: structuredClone(data.routes ?? {}),
|
|
568
|
+
cors: data.cors === null || data.cors === undefined ? null : structuredClone(data.cors),
|
|
569
|
+
routeStatuses: structuredClone(data.route_statuses ?? {}),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function agentAccessIdentityFromData(data) {
|
|
573
|
+
const payload = data ?? {};
|
|
574
|
+
const agentId = payload.agent_id ? String(payload.agent_id) : null;
|
|
575
|
+
return {
|
|
576
|
+
userId: String(payload.user_id ?? ''),
|
|
577
|
+
authType: String(payload.auth_type ?? ''),
|
|
578
|
+
agentId,
|
|
579
|
+
tags: (payload.tags ?? []).map((tag) => String(tag)),
|
|
580
|
+
capabilities: (payload.capabilities ?? []).map((item) => String(item)),
|
|
581
|
+
keyId: payload.key_id ? String(payload.key_id) : null,
|
|
582
|
+
keyName: payload.key_name ? String(payload.key_name) : null,
|
|
583
|
+
teamId: payload.team_id ? String(payload.team_id) : null,
|
|
584
|
+
planId: payload.plan_id ? String(payload.plan_id) : null,
|
|
585
|
+
isAgentRuntimeKey: Boolean(agentId),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** Backend-discovered file access scoped to an agent's retained storage root. */
|
|
589
|
+
export class AgentFiles {
|
|
590
|
+
agent;
|
|
591
|
+
deployments;
|
|
592
|
+
constructor(agent, deployments) {
|
|
593
|
+
this.agent = agent;
|
|
594
|
+
this.deployments = deployments;
|
|
595
|
+
}
|
|
596
|
+
async list(path = '') {
|
|
597
|
+
return this.deployments.filesList(this.agent, path);
|
|
598
|
+
}
|
|
599
|
+
async readBytes(path, options) {
|
|
600
|
+
return this.deployments.fileReadBytes(this.agent, path, options);
|
|
601
|
+
}
|
|
602
|
+
async readBytesWithMetadata(path, options) {
|
|
603
|
+
return this.deployments.fileReadBytesWithMetadata(this.agent, path, options);
|
|
604
|
+
}
|
|
605
|
+
async read(path, options) {
|
|
606
|
+
return this.deployments.fileRead(this.agent, path, options);
|
|
607
|
+
}
|
|
608
|
+
async writeBytes(path, content, options = {}) {
|
|
609
|
+
return this.deployments.fileWriteBytes(this.agent, path, content, options);
|
|
610
|
+
}
|
|
611
|
+
async write(path, content) {
|
|
612
|
+
return this.deployments.fileWrite(this.agent, path, content);
|
|
613
|
+
}
|
|
614
|
+
async delete(path, options = {}) {
|
|
615
|
+
return this.deployments.fileDelete(this.agent, path, options);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** Canonical states understood by this SDK. AgentState remains forward-open. */
|
|
619
|
+
export const CANONICAL_AGENT_STATES = [
|
|
620
|
+
'CREATING',
|
|
621
|
+
'STARTING',
|
|
622
|
+
'RESTORING',
|
|
623
|
+
'RUNNING',
|
|
624
|
+
'STOPPING',
|
|
625
|
+
'STOPPED',
|
|
626
|
+
'ARCHIVING',
|
|
627
|
+
'ARCHIVED',
|
|
628
|
+
'FAILED',
|
|
629
|
+
'NO_NAMESPACE',
|
|
630
|
+
'DELETED',
|
|
631
|
+
];
|
|
632
|
+
export const AGENT_TRANSITIONAL_STATES = new Set([
|
|
633
|
+
'CREATING',
|
|
634
|
+
'STARTING',
|
|
635
|
+
'RESTORING',
|
|
636
|
+
'STOPPING',
|
|
637
|
+
'ARCHIVING',
|
|
638
|
+
]);
|
|
639
|
+
export const AGENT_RUNTIME_INACTIVE_STATES = new Set([
|
|
640
|
+
'STOPPED',
|
|
641
|
+
'ARCHIVING',
|
|
642
|
+
'ARCHIVED',
|
|
643
|
+
'FAILED',
|
|
644
|
+
'NO_NAMESPACE',
|
|
645
|
+
'DELETED',
|
|
646
|
+
]);
|
|
647
|
+
export function isAgentTransitionalState(state) {
|
|
648
|
+
return AGENT_TRANSITIONAL_STATES.has(state.toUpperCase());
|
|
649
|
+
}
|
|
650
|
+
export function isAgentRuntimeInactiveState(state) {
|
|
651
|
+
return AGENT_RUNTIME_INACTIVE_STATES.has(state.toUpperCase());
|
|
652
|
+
}
|
|
653
|
+
function parseDate(value) {
|
|
654
|
+
if (typeof value !== 'string' || !value)
|
|
655
|
+
return null;
|
|
656
|
+
return new Date(value.replace('Z', '+00:00'));
|
|
657
|
+
}
|
|
658
|
+
function metaStatusFromDict(data) {
|
|
659
|
+
if (!isPlainRecord(data))
|
|
660
|
+
return null;
|
|
661
|
+
const observed = data.observed_state === 'RUNNING' || data.observed_state === 'STOPPED'
|
|
662
|
+
? data.observed_state
|
|
663
|
+
: null;
|
|
664
|
+
return {
|
|
665
|
+
status: typeof data.status === 'string' ? data.status : '',
|
|
666
|
+
clusterId: typeof data.cluster_id === 'string' ? data.cluster_id : null,
|
|
667
|
+
namespace: typeof data.namespace === 'string' ? data.namespace : null,
|
|
668
|
+
observedState: observed,
|
|
669
|
+
reason: typeof data.reason === 'string' ? data.reason : null,
|
|
670
|
+
message: typeof data.message === 'string' ? data.message : null,
|
|
671
|
+
observedAt: typeof data.observed_at === 'string' ? data.observed_at : null,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
function agentMetaFromDict(data) {
|
|
675
|
+
if (!isPlainRecord(data))
|
|
676
|
+
return null;
|
|
677
|
+
const meta = structuredClone(data);
|
|
678
|
+
if (Object.prototype.hasOwnProperty.call(data, 'status')) {
|
|
679
|
+
meta.status = metaStatusFromDict(data.status);
|
|
680
|
+
}
|
|
681
|
+
return meta;
|
|
682
|
+
}
|
|
683
|
+
function sleep(ms) {
|
|
684
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
685
|
+
}
|
|
686
|
+
function isTruthyEnv(value) {
|
|
687
|
+
return ['1', 'true', 'yes', 'on', 'enabled'].includes(String(value ?? '').trim().toLowerCase());
|
|
688
|
+
}
|
|
689
|
+
function isFalseyEnv(value) {
|
|
690
|
+
return ['0', 'false', 'no', 'off', 'disabled'].includes(String(value ?? '').trim().toLowerCase());
|
|
691
|
+
}
|
|
692
|
+
function isPlainRecord(value) {
|
|
693
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
694
|
+
}
|
|
695
|
+
function flattenConfigValue(value, prefix, out) {
|
|
696
|
+
if (!prefix) {
|
|
697
|
+
if (isPlainRecord(value)) {
|
|
698
|
+
for (const [key, child] of Object.entries(value)) {
|
|
699
|
+
flattenConfigValue(child, key, out);
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
out[''] = value;
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
out[prefix] = value;
|
|
707
|
+
if (Array.isArray(value)) {
|
|
708
|
+
value.forEach((child, index) => flattenConfigValue(child, `${prefix}[${index}]`, out));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (isPlainRecord(value)) {
|
|
712
|
+
for (const [key, child] of Object.entries(value)) {
|
|
713
|
+
flattenConfigValue(child, `${prefix}.${key}`, out);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
export function flattenLaunchConfig(launchConfig) {
|
|
718
|
+
const flat = {};
|
|
719
|
+
if (!isPlainRecord(launchConfig))
|
|
720
|
+
return flat;
|
|
721
|
+
flattenConfigValue(launchConfig, '', flat);
|
|
722
|
+
return flat;
|
|
723
|
+
}
|
|
724
|
+
function pathParts(path) {
|
|
725
|
+
if (Array.isArray(path))
|
|
726
|
+
return path;
|
|
727
|
+
const parts = [];
|
|
728
|
+
for (const part of path.replace(/\[(\d+)\]/g, '.$1').split('.')) {
|
|
729
|
+
if (!part)
|
|
730
|
+
continue;
|
|
731
|
+
parts.push(/^\d+$/.test(part) ? Number(part) : part);
|
|
732
|
+
}
|
|
733
|
+
return parts;
|
|
734
|
+
}
|
|
735
|
+
export function getLaunchConfigValue(launchConfig, path) {
|
|
736
|
+
let current = launchConfig;
|
|
737
|
+
for (const part of pathParts(path)) {
|
|
738
|
+
if (typeof part === 'number') {
|
|
739
|
+
if (!Array.isArray(current))
|
|
740
|
+
return undefined;
|
|
741
|
+
current = current[part];
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (!isPlainRecord(current))
|
|
745
|
+
return undefined;
|
|
746
|
+
current = current[part];
|
|
747
|
+
}
|
|
748
|
+
return current;
|
|
749
|
+
}
|
|
750
|
+
export function routesHaveDesktop(routes) {
|
|
751
|
+
if (!isPlainRecord(routes))
|
|
752
|
+
return false;
|
|
753
|
+
if (isPlainRecord(routes.desktop))
|
|
754
|
+
return true;
|
|
755
|
+
return Object.values(routes).some((route) => isPlainRecord(route) && route.prefix === 'desktop');
|
|
756
|
+
}
|
|
757
|
+
export function launchConfigHasDesktop(launchConfig) {
|
|
758
|
+
if (!isPlainRecord(launchConfig))
|
|
759
|
+
return false;
|
|
760
|
+
const desktopEnabled = getLaunchConfigValue(launchConfig, 'env.HYPER_DESKTOP_ENABLED');
|
|
761
|
+
if (isFalseyEnv(desktopEnabled))
|
|
762
|
+
return false;
|
|
763
|
+
if (isTruthyEnv(desktopEnabled))
|
|
764
|
+
return true;
|
|
765
|
+
return routesHaveDesktop(getLaunchConfigValue(launchConfig, 'routes'));
|
|
766
|
+
}
|
|
767
|
+
export function agentConfigHasDesktop(source) {
|
|
768
|
+
if (!source)
|
|
769
|
+
return false;
|
|
770
|
+
const launchConfig = source.launchConfig ?? source.launch_config;
|
|
771
|
+
const desktopEnabled = getLaunchConfigValue(launchConfig, 'env.HYPER_DESKTOP_ENABLED');
|
|
772
|
+
if (isFalseyEnv(desktopEnabled))
|
|
773
|
+
return false;
|
|
774
|
+
return (launchConfigHasDesktop(launchConfig) ||
|
|
775
|
+
routesHaveDesktop(source.routes));
|
|
776
|
+
}
|
|
777
|
+
function browserDesktopRedirectPath(options = {}) {
|
|
778
|
+
const redirect = (options.redirect ?? 'vnc_lite.html').trim() || 'vnc_lite.html';
|
|
779
|
+
if (redirect.includes('\\')) {
|
|
780
|
+
throw new Error('Desktop redirect must be a relative path');
|
|
781
|
+
}
|
|
782
|
+
const base = 'https://desktop.local';
|
|
783
|
+
const parsed = new URL(redirect, `${base}/`);
|
|
784
|
+
if (parsed.origin !== base) {
|
|
785
|
+
throw new Error('Desktop redirect must be a relative path');
|
|
786
|
+
}
|
|
787
|
+
if (options.resize !== null) {
|
|
788
|
+
const resize = options.resize ?? 'scale';
|
|
789
|
+
if (resize.trim()) {
|
|
790
|
+
// vnc_lite.html (the default viewer) takes `scale=true`; the full vnc.html
|
|
791
|
+
// UI takes `resize=scale`. Emit the parameter for whichever page the
|
|
792
|
+
// redirect targets.
|
|
793
|
+
const targetsLite = (parsed.pathname.endsWith('vnc_lite.html') || parsed.pathname.endsWith('vnc_auto.html'));
|
|
794
|
+
if (targetsLite)
|
|
795
|
+
parsed.searchParams.set('scale', resize === 'scale' ? 'true' : resize);
|
|
796
|
+
else
|
|
797
|
+
parsed.searchParams.set('resize', resize);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const pathname = parsed.pathname.replace(/^\/+/, '') || 'vnc_lite.html';
|
|
801
|
+
return `${pathname}${parsed.search}${parsed.hash}`;
|
|
802
|
+
}
|
|
803
|
+
export function buildBrowserDesktopUrl(desktopBaseUrl, token, options = {}) {
|
|
804
|
+
const jwt = token.trim();
|
|
805
|
+
if (!jwt)
|
|
806
|
+
throw new Error('Desktop token is required');
|
|
807
|
+
const url = new URL('/_jwt_auth', desktopBaseUrl);
|
|
808
|
+
url.searchParams.set('jwt', jwt);
|
|
809
|
+
url.searchParams.set('redirect', browserDesktopRedirectPath(options));
|
|
810
|
+
return url.toString();
|
|
811
|
+
}
|
|
812
|
+
function isDirectoryListingPayload(value) {
|
|
813
|
+
if (!value || typeof value !== 'object')
|
|
814
|
+
return false;
|
|
815
|
+
const payload = value;
|
|
816
|
+
return (payload.type === 'directory' &&
|
|
817
|
+
Array.isArray(payload.directories) &&
|
|
818
|
+
Array.isArray(payload.files));
|
|
819
|
+
}
|
|
820
|
+
export { defaultAcpProxyWsUrl, defaultHyperAcpWsUrl, resolveAgentsApiBase } from './agent-urls.js';
|
|
821
|
+
function randomHexToken(bytes) {
|
|
822
|
+
const buffer = new Uint8Array(bytes);
|
|
823
|
+
if (globalThis.crypto?.getRandomValues) {
|
|
824
|
+
globalThis.crypto.getRandomValues(buffer);
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
randomFillSync(bytes ? buffer.subarray(0, bytes) : buffer);
|
|
828
|
+
}
|
|
829
|
+
return Array.from(buffer, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
830
|
+
}
|
|
831
|
+
function encodeFilePath(path) {
|
|
832
|
+
return path
|
|
833
|
+
.replace(/^\/+/, '')
|
|
834
|
+
.split('/')
|
|
835
|
+
.filter(Boolean)
|
|
836
|
+
.map((part) => encodeURIComponent(part))
|
|
837
|
+
.join('/');
|
|
838
|
+
}
|
|
839
|
+
function decodeUtf8(content) {
|
|
840
|
+
return new TextDecoder().decode(content);
|
|
841
|
+
}
|
|
842
|
+
function encodeUtf8(content) {
|
|
843
|
+
return new TextEncoder().encode(content);
|
|
844
|
+
}
|
|
845
|
+
function encodeBase64(data) {
|
|
846
|
+
if (typeof Buffer !== 'undefined') {
|
|
847
|
+
return Buffer.from(data).toString('base64');
|
|
848
|
+
}
|
|
849
|
+
// Browser fallback: btoa over chunked binary string (avoids arg-limit blowups)
|
|
850
|
+
let binary = '';
|
|
851
|
+
const CHUNK = 0x8000;
|
|
852
|
+
for (let i = 0; i < data.length; i += CHUNK) {
|
|
853
|
+
binary += String.fromCharCode(...data.subarray(i, i + CHUNK));
|
|
854
|
+
}
|
|
855
|
+
return btoa(binary);
|
|
856
|
+
}
|
|
857
|
+
function fileReadLimitError(path, maxBytes) {
|
|
858
|
+
return new Error(`File ${path} exceeds the ${maxBytes / 1024 / 1024} MiB read limit`);
|
|
859
|
+
}
|
|
860
|
+
async function readResponseBytes(response, path, maxBytes) {
|
|
861
|
+
if (maxBytes === undefined)
|
|
862
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
863
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
864
|
+
throw new RangeError('maxBytes must be a non-negative safe integer');
|
|
865
|
+
}
|
|
866
|
+
const contentLength = Number(response.headers.get('content-length'));
|
|
867
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
868
|
+
await response.body?.cancel();
|
|
869
|
+
throw fileReadLimitError(path, maxBytes);
|
|
870
|
+
}
|
|
871
|
+
if (!response.body) {
|
|
872
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
873
|
+
if (bytes.byteLength > maxBytes)
|
|
874
|
+
throw fileReadLimitError(path, maxBytes);
|
|
875
|
+
return bytes;
|
|
876
|
+
}
|
|
877
|
+
const reader = response.body.getReader();
|
|
878
|
+
const chunks = [];
|
|
879
|
+
let totalBytes = 0;
|
|
880
|
+
try {
|
|
881
|
+
while (true) {
|
|
882
|
+
const { done, value } = await reader.read();
|
|
883
|
+
if (done)
|
|
884
|
+
break;
|
|
885
|
+
totalBytes += value.byteLength;
|
|
886
|
+
if (totalBytes > maxBytes) {
|
|
887
|
+
await reader.cancel();
|
|
888
|
+
throw fileReadLimitError(path, maxBytes);
|
|
889
|
+
}
|
|
890
|
+
chunks.push(value);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
finally {
|
|
894
|
+
reader.releaseLock();
|
|
895
|
+
}
|
|
896
|
+
const bytes = new Uint8Array(totalBytes);
|
|
897
|
+
let offset = 0;
|
|
898
|
+
for (const chunk of chunks) {
|
|
899
|
+
bytes.set(chunk, offset);
|
|
900
|
+
offset += chunk.byteLength;
|
|
901
|
+
}
|
|
902
|
+
return bytes;
|
|
903
|
+
}
|
|
904
|
+
function toUint8Array(content) {
|
|
905
|
+
if (typeof content === 'string')
|
|
906
|
+
return encodeUtf8(content);
|
|
907
|
+
if (content instanceof Uint8Array)
|
|
908
|
+
return content;
|
|
909
|
+
return new Uint8Array(content);
|
|
910
|
+
}
|
|
911
|
+
function execResultFromDict(data) {
|
|
912
|
+
return {
|
|
913
|
+
exitCode: data.exit_code ?? -1,
|
|
914
|
+
stdout: data.stdout || '',
|
|
915
|
+
stderr: data.stderr || '',
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
function ownKeysEqual(value, expected) {
|
|
919
|
+
const keys = Object.keys(value).sort();
|
|
920
|
+
return keys.length === expected.length
|
|
921
|
+
&& keys.every((key, index) => key === [...expected].sort()[index]);
|
|
922
|
+
}
|
|
923
|
+
function validateAgentWsToken(value, agentId, purpose, shell) {
|
|
924
|
+
const base = purpose === 'shell'
|
|
925
|
+
? ['agent_id', 'expires_at', 'shell', 'ws_url']
|
|
926
|
+
: ['agent_id', 'expires_at', 'ws_url'];
|
|
927
|
+
const invalid = () => new Error(`Backend returned an invalid Agent ${purpose} token response`);
|
|
928
|
+
if (!isPlainRecord(value))
|
|
929
|
+
throw invalid();
|
|
930
|
+
if (!ownKeysEqual(value, [...base, 'token']) || typeof value.token !== 'string' || !value.token) {
|
|
931
|
+
throw invalid();
|
|
932
|
+
}
|
|
933
|
+
if (value.agent_id !== agentId
|
|
934
|
+
|| typeof value.expires_at !== 'string'
|
|
935
|
+
|| !value.expires_at
|
|
936
|
+
|| typeof value.ws_url !== 'string'
|
|
937
|
+
|| !value.ws_url
|
|
938
|
+
|| (purpose === 'shell' && value.shell !== shell)) {
|
|
939
|
+
throw invalid();
|
|
940
|
+
}
|
|
941
|
+
let parsed;
|
|
942
|
+
try {
|
|
943
|
+
parsed = new URL(value.ws_url);
|
|
944
|
+
}
|
|
945
|
+
catch {
|
|
946
|
+
throw invalid();
|
|
947
|
+
}
|
|
948
|
+
if (!['ws:', 'wss:'].includes(parsed.protocol)
|
|
949
|
+
|| !parsed.hostname
|
|
950
|
+
|| parsed.username
|
|
951
|
+
|| parsed.password
|
|
952
|
+
|| parsed.search
|
|
953
|
+
|| parsed.hash
|
|
954
|
+
|| !parsed.pathname.endsWith(`/ws/${purpose}/${agentId}`)) {
|
|
955
|
+
throw invalid();
|
|
956
|
+
}
|
|
957
|
+
return value;
|
|
958
|
+
}
|
|
959
|
+
function validateDeploymentEventToken(value) {
|
|
960
|
+
const invalid = () => new Error('Backend returned an invalid deployment event token response');
|
|
961
|
+
if (!isPlainRecord(value) || !ownKeysEqual(value, ['token', 'ws_url']))
|
|
962
|
+
throw invalid();
|
|
963
|
+
if (typeof value.token !== 'string' || !value.token || typeof value.ws_url !== 'string' || !value.ws_url) {
|
|
964
|
+
throw invalid();
|
|
965
|
+
}
|
|
966
|
+
let parsed;
|
|
967
|
+
try {
|
|
968
|
+
parsed = new URL(value.ws_url);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
throw invalid();
|
|
972
|
+
}
|
|
973
|
+
if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.hostname || parsed.search || parsed.hash) {
|
|
974
|
+
throw invalid();
|
|
975
|
+
}
|
|
976
|
+
return { token: value.token, ws_url: value.ws_url };
|
|
977
|
+
}
|
|
978
|
+
function validateAgentLogsToken(value) {
|
|
979
|
+
const invalid = () => new Error('Backend returned an invalid Agent logs token response');
|
|
980
|
+
if (!isPlainRecord(value))
|
|
981
|
+
throw invalid();
|
|
982
|
+
if (!ownKeysEqual(value, ['agent_id', 'expires_at', 'token', 'ws_url']))
|
|
983
|
+
throw invalid();
|
|
984
|
+
if (typeof value.token !== 'string' || !value.token)
|
|
985
|
+
throw invalid();
|
|
986
|
+
if (typeof value.agent_id !== 'string' || !value.agent_id)
|
|
987
|
+
throw invalid();
|
|
988
|
+
if (typeof value.expires_at !== 'string' || !value.expires_at)
|
|
989
|
+
throw invalid();
|
|
990
|
+
if (typeof value.ws_url !== 'string' || !value.ws_url)
|
|
991
|
+
throw invalid();
|
|
992
|
+
if (typeof value.ws_url === 'string') {
|
|
993
|
+
let parsed;
|
|
994
|
+
try {
|
|
995
|
+
parsed = new URL(value.ws_url);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
throw invalid();
|
|
999
|
+
}
|
|
1000
|
+
if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.hostname || parsed.search || parsed.hash) {
|
|
1001
|
+
throw invalid();
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return value;
|
|
1005
|
+
}
|
|
1006
|
+
function validateAgentMetricsResult(value) {
|
|
1007
|
+
if (!isPlainRecord(value) || value.event !== 'agent_metrics_result') {
|
|
1008
|
+
throw new Error('Agent metrics WebSocket returned an invalid result frame');
|
|
1009
|
+
}
|
|
1010
|
+
if (value.ok === false && ownKeysEqual(value, ['error', 'event', 'ok'])
|
|
1011
|
+
&& typeof value.error === 'string' && value.error) {
|
|
1012
|
+
throw new Error(value.error);
|
|
1013
|
+
}
|
|
1014
|
+
if (value.ok !== true
|
|
1015
|
+
|| !ownKeysEqual(value, ['cpu', 'event', 'memory', 'ok', 'timestamp'])
|
|
1016
|
+
|| typeof value.cpu !== 'string'
|
|
1017
|
+
|| typeof value.memory !== 'string'
|
|
1018
|
+
|| !Number.isInteger(value.timestamp)) {
|
|
1019
|
+
throw new Error('Agent metrics WebSocket returned an invalid result frame');
|
|
1020
|
+
}
|
|
1021
|
+
return value;
|
|
1022
|
+
}
|
|
1023
|
+
function validateAgentExecResult(value) {
|
|
1024
|
+
if (!isPlainRecord(value) || value.event !== 'agent_exec_result') {
|
|
1025
|
+
throw new Error('Agent exec WebSocket returned an invalid result frame');
|
|
1026
|
+
}
|
|
1027
|
+
if (value.ok === false && ownKeysEqual(value, ['error', 'event', 'ok'])
|
|
1028
|
+
&& typeof value.error === 'string' && value.error) {
|
|
1029
|
+
throw new Error(value.error);
|
|
1030
|
+
}
|
|
1031
|
+
if (value.ok !== true
|
|
1032
|
+
|| !ownKeysEqual(value, ['event', 'exit_code', 'ok', 'stderr', 'stdout'])
|
|
1033
|
+
|| !Number.isInteger(value.exit_code)
|
|
1034
|
+
|| typeof value.stdout !== 'string'
|
|
1035
|
+
|| typeof value.stderr !== 'string') {
|
|
1036
|
+
throw new Error('Agent exec WebSocket returned an invalid result frame');
|
|
1037
|
+
}
|
|
1038
|
+
return execResultFromDict(value);
|
|
1039
|
+
}
|
|
1040
|
+
function agentStateFromDict(data) {
|
|
1041
|
+
const launchConfig = isPlainRecord(data.launch_config) ? structuredClone(data.launch_config) : null;
|
|
1042
|
+
if (launchConfig) {
|
|
1043
|
+
delete launchConfig.secrets;
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
id: data.id ?? '',
|
|
1047
|
+
userId: data.user_id ?? '',
|
|
1048
|
+
state: data.state ?? 'unknown',
|
|
1049
|
+
name: data.name ?? null,
|
|
1050
|
+
handle: data.handle ?? null,
|
|
1051
|
+
displayName: data.display_name ?? data.name ?? null,
|
|
1052
|
+
avatarUrl: data.avatar_url ?? null,
|
|
1053
|
+
avatarAudioUrl: data.avatar_audio_url ?? null,
|
|
1054
|
+
displayIdentity: data.display_identity ? structuredClone(data.display_identity) : null,
|
|
1055
|
+
runtime: data.runtime ?? null,
|
|
1056
|
+
managed: data.managed ?? null,
|
|
1057
|
+
isLaunchable: data.is_launchable ?? data.managed !== false,
|
|
1058
|
+
gatewayId: data.gateway_id ?? null,
|
|
1059
|
+
relayKey: data.relay_key ?? null,
|
|
1060
|
+
cpu: data.cpu ?? 0,
|
|
1061
|
+
memory: data.memory ?? 0,
|
|
1062
|
+
requestedSize: data.requested_size == null
|
|
1063
|
+
? null
|
|
1064
|
+
: parseAgentSlotSize(data.requested_size, 'Agent requested_size'),
|
|
1065
|
+
hostname: data.hostname ?? null,
|
|
1066
|
+
tags: Array.isArray(data.tags) ? data.tags : [],
|
|
1067
|
+
jwtToken: data.jwt_token ?? null,
|
|
1068
|
+
jwtExpiresAt: parseDate(data.jwt_expires_at),
|
|
1069
|
+
startedAt: parseDate(data.started_at),
|
|
1070
|
+
stoppedAt: parseDate(data.stopped_at),
|
|
1071
|
+
archivedAt: parseDate(data.archived_at),
|
|
1072
|
+
// Independently nullable from archivedAt: SPEC has a new Agent with
|
|
1073
|
+
// neither, an ARCHIVED Agent with both, and a restored Agent with a
|
|
1074
|
+
// prefix but no archivedAt. Dropping it made that tri-state unreadable.
|
|
1075
|
+
archivePrefix: typeof data.archive_prefix === 'string' ? data.archive_prefix : null,
|
|
1076
|
+
deletedAt: parseDate(data.deleted_at),
|
|
1077
|
+
disconnectedAt: parseDate(data.disconnected_at),
|
|
1078
|
+
agentSlotId: typeof data.agent_slot_id === 'string' ? data.agent_slot_id : null,
|
|
1079
|
+
clusterId: data.cluster_id ?? null,
|
|
1080
|
+
runner: isPlainRecord(data.runner)
|
|
1081
|
+
? {
|
|
1082
|
+
tags: Array.isArray(data.runner.tags) ? data.runner.tags.map(String) : [],
|
|
1083
|
+
runnerId: typeof data.runner.runner_id === 'string' ? data.runner.runner_id : null,
|
|
1084
|
+
}
|
|
1085
|
+
: null,
|
|
1086
|
+
launchEpoch: data.launch_epoch ?? 0,
|
|
1087
|
+
createdAt: parseDate(data.created_at),
|
|
1088
|
+
updatedAt: parseDate(data.updated_at),
|
|
1089
|
+
launchConfig,
|
|
1090
|
+
meta: agentMetaFromDict(data.meta),
|
|
1091
|
+
routes: data.routes ?? (isPlainRecord(launchConfig?.routes)
|
|
1092
|
+
? launchConfig.routes
|
|
1093
|
+
: {}),
|
|
1094
|
+
command: data.command ?? (Array.isArray(launchConfig?.command) ? launchConfig.command : []),
|
|
1095
|
+
entrypoint: data.entrypoint ?? (Array.isArray(launchConfig?.entrypoint) ? launchConfig.entrypoint : []),
|
|
1096
|
+
dryRun: Boolean(data.dry_run),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
function normalizeExecutor(executor) {
|
|
1100
|
+
// undefined/null leaves the stored executor alone; the Backend treats
|
|
1101
|
+
// pre-existing runner rows without one as docker.
|
|
1102
|
+
if (executor === undefined || executor === null)
|
|
1103
|
+
return undefined;
|
|
1104
|
+
if (executor !== 'process' && executor !== 'docker') {
|
|
1105
|
+
throw new Error("executor must be 'process' or 'docker'");
|
|
1106
|
+
}
|
|
1107
|
+
return executor;
|
|
1108
|
+
}
|
|
1109
|
+
function normalizeDockerOptions(docker) {
|
|
1110
|
+
// undefined leaves stored runner docker options alone; null (or an empty
|
|
1111
|
+
// volumes list) clears them, because the Backend treats provided-but-empty
|
|
1112
|
+
// docker as absent on a replacement write.
|
|
1113
|
+
if (docker === undefined)
|
|
1114
|
+
return undefined;
|
|
1115
|
+
if (docker === null)
|
|
1116
|
+
return null;
|
|
1117
|
+
if (!isPlainRecord(docker))
|
|
1118
|
+
throw new Error('docker accepts only a volumes list');
|
|
1119
|
+
const extra = Object.keys(docker).filter((key) => key !== 'volumes');
|
|
1120
|
+
if (extra.length > 0)
|
|
1121
|
+
throw new Error(`Unsupported docker settings: ${extra.sort().join(', ')}`);
|
|
1122
|
+
const volumes = docker.volumes ?? [];
|
|
1123
|
+
if (!Array.isArray(volumes) || volumes.some((volume) => typeof volume !== 'string')) {
|
|
1124
|
+
throw new Error('docker volumes must be a list of strings');
|
|
1125
|
+
}
|
|
1126
|
+
if (volumes.length > MAX_DOCKER_VOLUMES) {
|
|
1127
|
+
throw new Error(`docker volumes accept at most ${MAX_DOCKER_VOLUMES} entries`);
|
|
1128
|
+
}
|
|
1129
|
+
for (const volume of volumes) {
|
|
1130
|
+
const segments = volume.split(':');
|
|
1131
|
+
if (segments.length < 2 || segments.length > 3) {
|
|
1132
|
+
throw new Error(`docker volume must be source:target[:ro]: ${volume}`);
|
|
1133
|
+
}
|
|
1134
|
+
const [source, target] = segments;
|
|
1135
|
+
if (!source || !target || volume.includes('\0')) {
|
|
1136
|
+
throw new Error(`docker volume paths must be non-empty and NUL-free: ${volume}`);
|
|
1137
|
+
}
|
|
1138
|
+
if (!source.startsWith('/') || !target.startsWith('/')) {
|
|
1139
|
+
throw new Error(`docker volume source and target must be absolute: ${volume}`);
|
|
1140
|
+
}
|
|
1141
|
+
if (segments.length === 3 && segments[2] !== 'ro') {
|
|
1142
|
+
throw new Error(`docker volume mode must be ro or omitted: ${volume}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (volumes.length === 0)
|
|
1146
|
+
return null;
|
|
1147
|
+
return { volumes: [...volumes] };
|
|
1148
|
+
}
|
|
1149
|
+
export function buildAgentConfig(config = {}, options = {}) {
|
|
1150
|
+
const preparedConfig = structuredClone(config);
|
|
1151
|
+
const nestedLaunchKeys = Object.keys(preparedConfig).filter((key) => LAUNCH_CONFIG_KEYS.has(key));
|
|
1152
|
+
if (nestedLaunchKeys.length) {
|
|
1153
|
+
throw new Error(`Launch settings must be top-level fields, not nested under config: ${nestedLaunchKeys.join(', ')}`);
|
|
1154
|
+
}
|
|
1155
|
+
const env = { ...(options.env ?? {}) };
|
|
1156
|
+
const secrets = { ...(options.secrets ?? {}) };
|
|
1157
|
+
const collidingKeys = Object.keys(env).filter((key) => Object.prototype.hasOwnProperty.call(secrets, key));
|
|
1158
|
+
if (collidingKeys.length > 0) {
|
|
1159
|
+
throw new Error(`Launch keys cannot appear in both env and secrets: ${collidingKeys.join(', ')}`);
|
|
1160
|
+
}
|
|
1161
|
+
const normalizeSyncOwner = (value, field) => {
|
|
1162
|
+
if (value === undefined || value === null)
|
|
1163
|
+
return undefined;
|
|
1164
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 4_294_967_294) {
|
|
1165
|
+
throw new Error(`${field} must be an integer between 0 and 4294967294`);
|
|
1166
|
+
}
|
|
1167
|
+
return value;
|
|
1168
|
+
};
|
|
1169
|
+
const syncUid = normalizeSyncOwner(options.syncUid, 'syncUid');
|
|
1170
|
+
const syncGid = normalizeSyncOwner(options.syncGid, 'syncGid');
|
|
1171
|
+
let registryAuth = {};
|
|
1172
|
+
if (options.registryAuth !== undefined && options.registryAuth !== null) {
|
|
1173
|
+
const keys = Object.keys(options.registryAuth).sort();
|
|
1174
|
+
if (keys.length !== 2 || keys[0] !== 'password' || keys[1] !== 'username') {
|
|
1175
|
+
throw new Error('registryAuth requires exactly username and password');
|
|
1176
|
+
}
|
|
1177
|
+
const username = options.registryAuth.username.trim();
|
|
1178
|
+
if (!username)
|
|
1179
|
+
throw new Error('registryAuth username must be non-empty');
|
|
1180
|
+
if (!options.registryAuth.password)
|
|
1181
|
+
throw new Error('registryAuth password must be non-empty');
|
|
1182
|
+
registryAuth = { username, password: options.registryAuth.password };
|
|
1183
|
+
}
|
|
1184
|
+
const prepared = {
|
|
1185
|
+
image: options.image ?? null,
|
|
1186
|
+
env,
|
|
1187
|
+
secrets,
|
|
1188
|
+
routes: routesConfigBody(options.routes),
|
|
1189
|
+
command: [...(options.command ?? [])],
|
|
1190
|
+
entrypoint: [...(options.entrypoint ?? [])],
|
|
1191
|
+
restart: options.restart ?? false,
|
|
1192
|
+
sync_root: options.syncRoot ?? null,
|
|
1193
|
+
sync_uid: syncUid ?? null,
|
|
1194
|
+
sync_gid: syncGid ?? null,
|
|
1195
|
+
registry_url: options.registryUrl ?? null,
|
|
1196
|
+
registry_auth: registryAuth,
|
|
1197
|
+
runtime_scopes: [...(options.runtimeScopes ?? DEFAULT_AGENT_RUNTIME_SCOPES)],
|
|
1198
|
+
};
|
|
1199
|
+
if (Object.keys(preparedConfig).length > 0)
|
|
1200
|
+
prepared.config = preparedConfig;
|
|
1201
|
+
if (options.cors !== undefined)
|
|
1202
|
+
prepared.cors = options.cors === null ? null : structuredClone(options.cors);
|
|
1203
|
+
if (options.syncInclude !== undefined) {
|
|
1204
|
+
if (options.syncInclude !== null && options.syncInclude.length === 0) {
|
|
1205
|
+
throw new Error('syncInclude must contain at least one path; omit it to sync all');
|
|
1206
|
+
}
|
|
1207
|
+
prepared.sync_include = options.syncInclude === null ? null : [...options.syncInclude];
|
|
1208
|
+
}
|
|
1209
|
+
if (options.syncInclude === undefined && options.syncExclude !== undefined) {
|
|
1210
|
+
if (options.syncExclude !== null && (options.syncExclude.includes('*') || options.syncExclude.includes('**'))) {
|
|
1211
|
+
throw new Error('syncExclude cannot exclude the entire sync root; omit it to sync all');
|
|
1212
|
+
}
|
|
1213
|
+
prepared.sync_exclude = options.syncExclude === null ? null : [...options.syncExclude];
|
|
1214
|
+
}
|
|
1215
|
+
const docker = normalizeDockerOptions(options.docker);
|
|
1216
|
+
const executor = normalizeExecutor(options.executor);
|
|
1217
|
+
if (executor === 'process' && docker != null) {
|
|
1218
|
+
throw new Error('docker launch options require the docker executor');
|
|
1219
|
+
}
|
|
1220
|
+
if (docker !== undefined)
|
|
1221
|
+
prepared.docker = docker;
|
|
1222
|
+
if (executor !== undefined)
|
|
1223
|
+
prepared.executor = executor;
|
|
1224
|
+
if (options.cwd !== undefined)
|
|
1225
|
+
prepared.cwd = options.cwd;
|
|
1226
|
+
return { config: prepared };
|
|
1227
|
+
}
|
|
1228
|
+
function buildAgentCreateConfig(config, options) {
|
|
1229
|
+
const complete = buildAgentConfig(config, options).config;
|
|
1230
|
+
const prepared = {};
|
|
1231
|
+
if (complete.config && Object.keys(complete.config).length > 0)
|
|
1232
|
+
prepared.config = complete.config;
|
|
1233
|
+
if (Object.keys(complete.env).length > 0)
|
|
1234
|
+
prepared.env = complete.env;
|
|
1235
|
+
if (Object.keys(complete.secrets).length > 0)
|
|
1236
|
+
prepared.secrets = complete.secrets;
|
|
1237
|
+
if (options.routes !== undefined && options.routes !== null)
|
|
1238
|
+
prepared.routes = complete.routes;
|
|
1239
|
+
if (options.command !== undefined && options.command !== null)
|
|
1240
|
+
prepared.command = complete.command;
|
|
1241
|
+
if (options.entrypoint !== undefined && options.entrypoint !== null)
|
|
1242
|
+
prepared.entrypoint = complete.entrypoint;
|
|
1243
|
+
if (options.image !== undefined && options.image !== null)
|
|
1244
|
+
prepared.image = complete.image;
|
|
1245
|
+
if (options.syncRoot !== undefined && options.syncRoot !== null)
|
|
1246
|
+
prepared.sync_root = complete.sync_root;
|
|
1247
|
+
if (Object.prototype.hasOwnProperty.call(complete, 'sync_include')) {
|
|
1248
|
+
prepared.sync_include = complete.sync_include;
|
|
1249
|
+
}
|
|
1250
|
+
else if (Object.prototype.hasOwnProperty.call(complete, 'sync_exclude')) {
|
|
1251
|
+
prepared.sync_exclude = complete.sync_exclude;
|
|
1252
|
+
}
|
|
1253
|
+
if (options.syncUid !== undefined && options.syncUid !== null)
|
|
1254
|
+
prepared.sync_uid = complete.sync_uid;
|
|
1255
|
+
if (options.syncGid !== undefined && options.syncGid !== null)
|
|
1256
|
+
prepared.sync_gid = complete.sync_gid;
|
|
1257
|
+
if (options.registryUrl !== undefined && options.registryUrl !== null)
|
|
1258
|
+
prepared.registry_url = complete.registry_url;
|
|
1259
|
+
if (options.registryAuth !== undefined && options.registryAuth !== null)
|
|
1260
|
+
prepared.registry_auth = complete.registry_auth;
|
|
1261
|
+
if (Object.prototype.hasOwnProperty.call(complete, 'cors') && complete.cors != null) {
|
|
1262
|
+
prepared.cors = complete.cors;
|
|
1263
|
+
}
|
|
1264
|
+
prepared.restart = complete.restart;
|
|
1265
|
+
if (options.runtimeScopes !== undefined && options.runtimeScopes !== null)
|
|
1266
|
+
prepared.runtime_scopes = complete.runtime_scopes;
|
|
1267
|
+
if (complete.docker)
|
|
1268
|
+
prepared.docker = complete.docker;
|
|
1269
|
+
if (complete.executor !== undefined)
|
|
1270
|
+
prepared.executor = complete.executor;
|
|
1271
|
+
if (complete.cwd !== undefined)
|
|
1272
|
+
prepared.cwd = complete.cwd;
|
|
1273
|
+
return prepared;
|
|
1274
|
+
}
|
|
1275
|
+
export async function startSlackOAuth(options) {
|
|
1276
|
+
const relayBaseUrl = normalizeSlackRelayBaseUrl(options.relayBaseUrl);
|
|
1277
|
+
if (!relayBaseUrl)
|
|
1278
|
+
throw new Error('Slack relay base URL is required');
|
|
1279
|
+
if (!options.token)
|
|
1280
|
+
throw new Error('Slack OAuth requires an app token');
|
|
1281
|
+
const response = await fetch(`${relayBaseUrl}/slack/oauth/start`, {
|
|
1282
|
+
method: 'GET',
|
|
1283
|
+
headers: {
|
|
1284
|
+
'Authorization': `Bearer ${options.token}`,
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
if (!response.ok) {
|
|
1288
|
+
let detail = response.statusText || 'Slack OAuth start failed';
|
|
1289
|
+
try {
|
|
1290
|
+
const payload = await response.json();
|
|
1291
|
+
if (typeof payload.detail === 'string' && payload.detail)
|
|
1292
|
+
detail = payload.detail;
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
// Keep the HTTP status text when the error body is not JSON.
|
|
1296
|
+
}
|
|
1297
|
+
throw new APIError(response.status, detail);
|
|
1298
|
+
}
|
|
1299
|
+
const payload = await response.json();
|
|
1300
|
+
const authorizeUrl = typeof payload.authorize_url === 'string' ? payload.authorize_url : '';
|
|
1301
|
+
if (!authorizeUrl)
|
|
1302
|
+
throw new Error('Slack OAuth start did not return an authorization URL');
|
|
1303
|
+
return {
|
|
1304
|
+
authorizeUrl,
|
|
1305
|
+
expiresAt: typeof payload.expires_at === 'string' ? payload.expires_at : null,
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
export async function getSlackInstallStatus(options) {
|
|
1309
|
+
const relayBaseUrl = normalizeSlackRelayBaseUrl(options.relayBaseUrl);
|
|
1310
|
+
if (!relayBaseUrl)
|
|
1311
|
+
throw new Error('Slack relay base URL is required');
|
|
1312
|
+
if (!options.token)
|
|
1313
|
+
throw new Error('Slack install status requires an app token');
|
|
1314
|
+
const response = await fetch(`${relayBaseUrl}/slack/install`, {
|
|
1315
|
+
method: 'GET',
|
|
1316
|
+
headers: { 'Authorization': `Bearer ${options.token}` },
|
|
1317
|
+
});
|
|
1318
|
+
if (!response.ok) {
|
|
1319
|
+
let detail = response.statusText || 'Slack install status failed';
|
|
1320
|
+
try {
|
|
1321
|
+
const payload = await response.json();
|
|
1322
|
+
if (typeof payload.detail === 'string' && payload.detail)
|
|
1323
|
+
detail = payload.detail;
|
|
1324
|
+
}
|
|
1325
|
+
catch {
|
|
1326
|
+
// Keep the HTTP status text when the error body is not JSON.
|
|
1327
|
+
}
|
|
1328
|
+
throw new APIError(response.status, detail);
|
|
1329
|
+
}
|
|
1330
|
+
const payload = await response.json();
|
|
1331
|
+
return {
|
|
1332
|
+
connected: payload.connected === true,
|
|
1333
|
+
teamId: typeof payload.team_id === 'string' ? payload.team_id : null,
|
|
1334
|
+
teamName: typeof payload.team_name === 'string' ? payload.team_name : null,
|
|
1335
|
+
botUserId: typeof payload.bot_user_id === 'string' ? payload.bot_user_id : null,
|
|
1336
|
+
installerUserId: typeof payload.installer_user_id === 'string' ? payload.installer_user_id : null,
|
|
1337
|
+
updatedAt: typeof payload.updated_at === 'string' ? payload.updated_at : null,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function slackRelayAuthorizedUrl(path, options) {
|
|
1341
|
+
const relayBaseUrl = normalizeSlackRelayBaseUrl(options.relayBaseUrl);
|
|
1342
|
+
if (!relayBaseUrl)
|
|
1343
|
+
throw new Error('Slack relay base URL is required');
|
|
1344
|
+
if (!options.token)
|
|
1345
|
+
throw new Error('Slack directory lookup requires an app token');
|
|
1346
|
+
const url = new URL(path, `${relayBaseUrl}/`);
|
|
1347
|
+
if (options.cursor)
|
|
1348
|
+
url.searchParams.set('cursor', options.cursor);
|
|
1349
|
+
if (typeof options.limit === 'number' && Number.isFinite(options.limit)) {
|
|
1350
|
+
url.searchParams.set('limit', String(Math.trunc(options.limit)));
|
|
1351
|
+
}
|
|
1352
|
+
return url;
|
|
1353
|
+
}
|
|
1354
|
+
export async function listSlackDirectoryConversations(options) {
|
|
1355
|
+
const url = slackRelayAuthorizedUrl('/slack/directory/conversations', options);
|
|
1356
|
+
if (options.types)
|
|
1357
|
+
url.searchParams.set('types', options.types);
|
|
1358
|
+
const response = await fetch(url.toString(), {
|
|
1359
|
+
method: 'GET',
|
|
1360
|
+
headers: { 'Authorization': `Bearer ${options.token}` },
|
|
1361
|
+
});
|
|
1362
|
+
if (!response.ok) {
|
|
1363
|
+
let detail = response.statusText || 'Slack conversation lookup failed';
|
|
1364
|
+
try {
|
|
1365
|
+
const payload = await response.json();
|
|
1366
|
+
if (typeof payload.detail === 'string' && payload.detail)
|
|
1367
|
+
detail = payload.detail;
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
// Keep the HTTP status text when the error body is not JSON.
|
|
1371
|
+
}
|
|
1372
|
+
throw new APIError(response.status, detail);
|
|
1373
|
+
}
|
|
1374
|
+
const payload = await response.json();
|
|
1375
|
+
const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
|
|
1376
|
+
return {
|
|
1377
|
+
conversations: conversations
|
|
1378
|
+
.filter((item) => Boolean(item && typeof item === 'object' && !Array.isArray(item) && typeof item.id === 'string'))
|
|
1379
|
+
.map((item) => ({
|
|
1380
|
+
id: item.id,
|
|
1381
|
+
name: typeof item.name === 'string' ? item.name : null,
|
|
1382
|
+
isChannel: typeof item.is_channel === 'boolean' ? item.is_channel : null,
|
|
1383
|
+
isGroup: typeof item.is_group === 'boolean' ? item.is_group : null,
|
|
1384
|
+
isIm: typeof item.is_im === 'boolean' ? item.is_im : null,
|
|
1385
|
+
isMpim: typeof item.is_mpim === 'boolean' ? item.is_mpim : null,
|
|
1386
|
+
isMember: typeof item.is_member === 'boolean' ? item.is_member : null,
|
|
1387
|
+
isPrivate: typeof item.is_private === 'boolean' ? item.is_private : null,
|
|
1388
|
+
})),
|
|
1389
|
+
nextCursor: typeof payload.next_cursor === 'string' ? payload.next_cursor : null,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
export async function listSlackDirectoryUsers(options) {
|
|
1393
|
+
const url = slackRelayAuthorizedUrl('/slack/directory/users', options);
|
|
1394
|
+
const response = await fetch(url.toString(), {
|
|
1395
|
+
method: 'GET',
|
|
1396
|
+
headers: { 'Authorization': `Bearer ${options.token}` },
|
|
1397
|
+
});
|
|
1398
|
+
if (!response.ok) {
|
|
1399
|
+
let detail = response.statusText || 'Slack user lookup failed';
|
|
1400
|
+
try {
|
|
1401
|
+
const payload = await response.json();
|
|
1402
|
+
if (typeof payload.detail === 'string' && payload.detail)
|
|
1403
|
+
detail = payload.detail;
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
// Keep the HTTP status text when the error body is not JSON.
|
|
1407
|
+
}
|
|
1408
|
+
throw new APIError(response.status, detail);
|
|
1409
|
+
}
|
|
1410
|
+
const payload = await response.json();
|
|
1411
|
+
const users = Array.isArray(payload.users) ? payload.users : [];
|
|
1412
|
+
return {
|
|
1413
|
+
users: users
|
|
1414
|
+
.filter((item) => Boolean(item && typeof item === 'object' && !Array.isArray(item) && typeof item.id === 'string'))
|
|
1415
|
+
.map((item) => ({
|
|
1416
|
+
id: item.id,
|
|
1417
|
+
name: typeof item.name === 'string' ? item.name : null,
|
|
1418
|
+
realName: typeof item.real_name === 'string' ? item.real_name : null,
|
|
1419
|
+
teamId: typeof item.team_id === 'string' ? item.team_id : null,
|
|
1420
|
+
isBot: typeof item.is_bot === 'boolean' ? item.is_bot : null,
|
|
1421
|
+
deleted: typeof item.deleted === 'boolean' ? item.deleted : null,
|
|
1422
|
+
})),
|
|
1423
|
+
nextCursor: typeof payload.next_cursor === 'string' ? payload.next_cursor : null,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
export async function attachSlackRelayAgent(options) {
|
|
1427
|
+
const relayBaseUrl = normalizeSlackRelayBaseUrl(options.relayBaseUrl);
|
|
1428
|
+
const agentId = options.agentId.trim();
|
|
1429
|
+
if (!relayBaseUrl)
|
|
1430
|
+
throw new Error('Slack relay base URL is required');
|
|
1431
|
+
if (!options.token)
|
|
1432
|
+
throw new Error('Slack relay attach requires an app token');
|
|
1433
|
+
if (!agentId)
|
|
1434
|
+
throw new Error('Slack relay attach requires an agent id');
|
|
1435
|
+
const response = await fetch(`${relayBaseUrl}/slack/agents/${encodeURIComponent(agentId)}/relay`, {
|
|
1436
|
+
method: 'POST',
|
|
1437
|
+
headers: { 'Authorization': `Bearer ${options.token}` },
|
|
1438
|
+
});
|
|
1439
|
+
if (!response.ok) {
|
|
1440
|
+
let detail = response.statusText || 'Slack relay attach failed';
|
|
1441
|
+
try {
|
|
1442
|
+
const payload = await response.json();
|
|
1443
|
+
if (typeof payload.detail === 'string' && payload.detail)
|
|
1444
|
+
detail = payload.detail;
|
|
1445
|
+
}
|
|
1446
|
+
catch {
|
|
1447
|
+
// Keep the HTTP status text when the error body is not JSON.
|
|
1448
|
+
}
|
|
1449
|
+
throw new APIError(response.status, detail);
|
|
1450
|
+
}
|
|
1451
|
+
const payload = await response.json();
|
|
1452
|
+
const agentIdValue = typeof payload.agent_id === 'string' ? payload.agent_id : '';
|
|
1453
|
+
const gatewayIdValue = typeof payload.gateway_id === 'string' ? payload.gateway_id : '';
|
|
1454
|
+
if (!agentIdValue || !gatewayIdValue)
|
|
1455
|
+
throw new Error('Slack relay attach response is missing agent identity');
|
|
1456
|
+
return {
|
|
1457
|
+
connected: payload.connected === true,
|
|
1458
|
+
agentId: agentIdValue,
|
|
1459
|
+
gatewayId: gatewayIdValue,
|
|
1460
|
+
restartRequired: payload.restart_required !== false,
|
|
1461
|
+
teamId: typeof payload.team_id === 'string' ? payload.team_id : null,
|
|
1462
|
+
teamName: typeof payload.team_name === 'string' ? payload.team_name : null,
|
|
1463
|
+
botUserId: typeof payload.bot_user_id === 'string' ? payload.bot_user_id : null,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
export function buildOpenClawDesktopRoute(options = {}) {
|
|
1467
|
+
return {
|
|
1468
|
+
desktop: {
|
|
1469
|
+
port: options.desktopPort ?? 3000,
|
|
1470
|
+
auth: options.desktopAuth ?? true,
|
|
1471
|
+
prefix: options.desktopPrefix ?? 'desktop',
|
|
1472
|
+
},
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
function envBool(value) {
|
|
1476
|
+
return value ? '1' : '0';
|
|
1477
|
+
}
|
|
1478
|
+
function envNonNegativeInteger(name, value) {
|
|
1479
|
+
const integer = Number(value);
|
|
1480
|
+
if (!Number.isInteger(integer) || integer < 0) {
|
|
1481
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
1482
|
+
}
|
|
1483
|
+
return String(integer);
|
|
1484
|
+
}
|
|
1485
|
+
export function buildOpenClawMemoryIndexEnv(memoryIndex = null) {
|
|
1486
|
+
if (!memoryIndex)
|
|
1487
|
+
return {};
|
|
1488
|
+
const env = { ...OPENCLAW_MEMORY_SEARCH_ENV_DEFAULTS };
|
|
1489
|
+
if (memoryIndex.enabled !== undefined && memoryIndex.enabled !== null) {
|
|
1490
|
+
env.OPENCLAW_MEMORY_SEARCH_ENABLED = envBool(memoryIndex.enabled);
|
|
1491
|
+
}
|
|
1492
|
+
if (memoryIndex.onSessionStart !== undefined && memoryIndex.onSessionStart !== null) {
|
|
1493
|
+
env.OPENCLAW_MEMORY_SEARCH_SYNC_ON_SESSION_START = envBool(memoryIndex.onSessionStart);
|
|
1494
|
+
}
|
|
1495
|
+
if (memoryIndex.onSearch !== undefined && memoryIndex.onSearch !== null) {
|
|
1496
|
+
env.OPENCLAW_MEMORY_SEARCH_SYNC_ON_SEARCH = envBool(memoryIndex.onSearch);
|
|
1497
|
+
}
|
|
1498
|
+
if (memoryIndex.watch !== undefined && memoryIndex.watch !== null) {
|
|
1499
|
+
env.OPENCLAW_MEMORY_SEARCH_SYNC_WATCH = envBool(memoryIndex.watch);
|
|
1500
|
+
}
|
|
1501
|
+
if (memoryIndex.watchDebounceMs !== undefined && memoryIndex.watchDebounceMs !== null) {
|
|
1502
|
+
env.OPENCLAW_MEMORY_SEARCH_SYNC_WATCH_DEBOUNCE_MS = envNonNegativeInteger('watchDebounceMs', memoryIndex.watchDebounceMs);
|
|
1503
|
+
}
|
|
1504
|
+
if (memoryIndex.intervalMinutes !== undefined && memoryIndex.intervalMinutes !== null) {
|
|
1505
|
+
env.OPENCLAW_MEMORY_SEARCH_SYNC_INTERVAL_MINUTES = envNonNegativeInteger('intervalMinutes', memoryIndex.intervalMinutes);
|
|
1506
|
+
}
|
|
1507
|
+
return env;
|
|
1508
|
+
}
|
|
1509
|
+
export function buildOpenClawCronEnv(enabled = null) {
|
|
1510
|
+
return {
|
|
1511
|
+
...OPENCLAW_CRON_ENV_DEFAULTS,
|
|
1512
|
+
...(enabled !== null ? { OPENCLAW_CRON_ENABLED: envBool(enabled) } : {}),
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
export function buildHermesCronEnv(enabled = null) {
|
|
1516
|
+
return {
|
|
1517
|
+
...HERMES_CRON_ENV_DEFAULTS,
|
|
1518
|
+
...(enabled !== null ? { HERMES_CRON_ENABLED: envBool(enabled) } : {}),
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
export function buildWorkspacesSyncEnv(workspacesSync = null) {
|
|
1522
|
+
if (workspacesSync === false)
|
|
1523
|
+
return { HYPER_WORKSPACES_BOOT_SYNC: '0' };
|
|
1524
|
+
const options = typeof workspacesSync === 'object' && workspacesSync !== null ? workspacesSync : {};
|
|
1525
|
+
if (options.enabled === false)
|
|
1526
|
+
return { HYPER_WORKSPACES_BOOT_SYNC: '0' };
|
|
1527
|
+
const env = { ...WORKSPACES_SYNC_ENV_DEFAULTS };
|
|
1528
|
+
if (options.enabled !== undefined && options.enabled !== null) {
|
|
1529
|
+
env.HYPER_WORKSPACES_BOOT_SYNC = envBool(options.enabled);
|
|
1530
|
+
}
|
|
1531
|
+
if (options.readyOnly !== undefined && options.readyOnly !== null) {
|
|
1532
|
+
env.HYPER_WORKSPACES_SYNC_READY_ONLY = envBool(options.readyOnly);
|
|
1533
|
+
}
|
|
1534
|
+
if (options.workspace) {
|
|
1535
|
+
env.HYPER_WORKSPACES_SYNC_WORKSPACE = options.workspace;
|
|
1536
|
+
}
|
|
1537
|
+
return env;
|
|
1538
|
+
}
|
|
1539
|
+
async function getFsPromises() {
|
|
1540
|
+
return import('node:fs/promises');
|
|
1541
|
+
}
|
|
1542
|
+
function bindAgent(agent, deployments) {
|
|
1543
|
+
agent._deployments = deployments;
|
|
1544
|
+
return agent;
|
|
1545
|
+
}
|
|
1546
|
+
export class Agent {
|
|
1547
|
+
id;
|
|
1548
|
+
userId;
|
|
1549
|
+
state;
|
|
1550
|
+
name;
|
|
1551
|
+
handle;
|
|
1552
|
+
displayName;
|
|
1553
|
+
avatarUrl;
|
|
1554
|
+
avatarAudioUrl;
|
|
1555
|
+
displayIdentity;
|
|
1556
|
+
runtime;
|
|
1557
|
+
managed;
|
|
1558
|
+
isLaunchable;
|
|
1559
|
+
gatewayId;
|
|
1560
|
+
relayKey;
|
|
1561
|
+
cpu;
|
|
1562
|
+
memory;
|
|
1563
|
+
requestedSize;
|
|
1564
|
+
hostname;
|
|
1565
|
+
tags;
|
|
1566
|
+
jwtToken;
|
|
1567
|
+
jwtExpiresAt;
|
|
1568
|
+
startedAt;
|
|
1569
|
+
stoppedAt;
|
|
1570
|
+
archivedAt;
|
|
1571
|
+
archivePrefix;
|
|
1572
|
+
deletedAt;
|
|
1573
|
+
disconnectedAt;
|
|
1574
|
+
agentSlotId;
|
|
1575
|
+
clusterId;
|
|
1576
|
+
runner;
|
|
1577
|
+
launchEpoch;
|
|
1578
|
+
createdAt;
|
|
1579
|
+
updatedAt;
|
|
1580
|
+
launchConfig;
|
|
1581
|
+
meta;
|
|
1582
|
+
routes;
|
|
1583
|
+
command;
|
|
1584
|
+
entrypoint;
|
|
1585
|
+
dryRun;
|
|
1586
|
+
_deployments = null;
|
|
1587
|
+
constructor(fields) {
|
|
1588
|
+
this.id = fields.id;
|
|
1589
|
+
this.userId = fields.userId;
|
|
1590
|
+
this.state = fields.state;
|
|
1591
|
+
this.name = fields.name ?? null;
|
|
1592
|
+
this.handle = fields.handle ?? null;
|
|
1593
|
+
this.displayName = fields.displayName ?? this.name;
|
|
1594
|
+
this.avatarUrl = fields.avatarUrl ?? null;
|
|
1595
|
+
this.avatarAudioUrl = fields.avatarAudioUrl ?? null;
|
|
1596
|
+
this.displayIdentity = fields.displayIdentity ? structuredClone(fields.displayIdentity) : null;
|
|
1597
|
+
this.runtime = fields.runtime ?? null;
|
|
1598
|
+
this.managed = fields.managed ?? null;
|
|
1599
|
+
this.isLaunchable = fields.isLaunchable ?? true;
|
|
1600
|
+
this.gatewayId = fields.gatewayId ?? null;
|
|
1601
|
+
this.relayKey = fields.relayKey ? structuredClone(fields.relayKey) : null;
|
|
1602
|
+
this.cpu = fields.cpu;
|
|
1603
|
+
this.memory = fields.memory;
|
|
1604
|
+
this.requestedSize = fields.requestedSize ?? null;
|
|
1605
|
+
this.hostname = fields.hostname ?? null;
|
|
1606
|
+
this.tags = [...(fields.tags ?? [])];
|
|
1607
|
+
this.jwtToken = fields.jwtToken ?? null;
|
|
1608
|
+
this.jwtExpiresAt = fields.jwtExpiresAt ?? null;
|
|
1609
|
+
this.startedAt = fields.startedAt ?? null;
|
|
1610
|
+
this.stoppedAt = fields.stoppedAt ?? null;
|
|
1611
|
+
this.archivedAt = fields.archivedAt ?? null;
|
|
1612
|
+
this.archivePrefix = fields.archivePrefix ?? null;
|
|
1613
|
+
this.deletedAt = fields.deletedAt ?? null;
|
|
1614
|
+
this.disconnectedAt = fields.disconnectedAt ?? null;
|
|
1615
|
+
this.agentSlotId = fields.agentSlotId ?? null;
|
|
1616
|
+
this.clusterId = fields.clusterId ?? null;
|
|
1617
|
+
this.runner = fields.runner ?? null;
|
|
1618
|
+
this.launchEpoch = fields.launchEpoch ?? 0;
|
|
1619
|
+
this.createdAt = fields.createdAt ?? null;
|
|
1620
|
+
this.updatedAt = fields.updatedAt ?? null;
|
|
1621
|
+
this.launchConfig = fields.launchConfig ?? null;
|
|
1622
|
+
this.meta = fields.meta ? structuredClone(fields.meta) : null;
|
|
1623
|
+
this.routes = { ...fields.routes };
|
|
1624
|
+
this.command = [...fields.command];
|
|
1625
|
+
this.entrypoint = [...fields.entrypoint];
|
|
1626
|
+
this.dryRun = fields.dryRun;
|
|
1627
|
+
}
|
|
1628
|
+
static fromDict(data) {
|
|
1629
|
+
return new Agent(agentStateFromDict(data));
|
|
1630
|
+
}
|
|
1631
|
+
get publicUrl() {
|
|
1632
|
+
return this.hostname ? `https://${this.hostname}` : null;
|
|
1633
|
+
}
|
|
1634
|
+
routePrefix(routeName, defaultPrefix = null) {
|
|
1635
|
+
const route = this.routes[routeName] ?? {};
|
|
1636
|
+
const prefix = route.prefix;
|
|
1637
|
+
if (typeof prefix === 'undefined' || prefix === null) {
|
|
1638
|
+
return defaultPrefix;
|
|
1639
|
+
}
|
|
1640
|
+
return String(prefix);
|
|
1641
|
+
}
|
|
1642
|
+
routeUrl(routeName, defaultPrefix = null) {
|
|
1643
|
+
if (!this.hostname)
|
|
1644
|
+
return null;
|
|
1645
|
+
const prefix = this.routePrefix(routeName, defaultPrefix);
|
|
1646
|
+
if (prefix === null)
|
|
1647
|
+
return null;
|
|
1648
|
+
return prefix === '' ? `https://${this.hostname}` : `https://${prefix}-${this.hostname}`;
|
|
1649
|
+
}
|
|
1650
|
+
get desktopUrl() {
|
|
1651
|
+
return this.routeUrl('desktop', 'desktop');
|
|
1652
|
+
}
|
|
1653
|
+
get vncUrl() {
|
|
1654
|
+
return this.desktopUrl;
|
|
1655
|
+
}
|
|
1656
|
+
browserDesktopUrl(token, options = {}) {
|
|
1657
|
+
if (!this.desktopUrl)
|
|
1658
|
+
return null;
|
|
1659
|
+
return buildBrowserDesktopUrl(this.desktopUrl, token, options);
|
|
1660
|
+
}
|
|
1661
|
+
get shellUrl() {
|
|
1662
|
+
return this.routeUrl('shell');
|
|
1663
|
+
}
|
|
1664
|
+
get isRunning() {
|
|
1665
|
+
return this.state.toLowerCase() === 'running';
|
|
1666
|
+
}
|
|
1667
|
+
get isTransitioning() {
|
|
1668
|
+
return isAgentTransitionalState(this.state);
|
|
1669
|
+
}
|
|
1670
|
+
/** True when the agent is cold-restorable from its verified archive. */
|
|
1671
|
+
get isArchived() {
|
|
1672
|
+
return this.state.toUpperCase() === 'ARCHIVED';
|
|
1673
|
+
}
|
|
1674
|
+
/** True only for an explicitly included deletion tombstone. */
|
|
1675
|
+
get isDeleted() {
|
|
1676
|
+
return this.state.toUpperCase() === 'DELETED';
|
|
1677
|
+
}
|
|
1678
|
+
get hasDesktop() {
|
|
1679
|
+
return agentConfigHasDesktop({
|
|
1680
|
+
launchConfig: this.launchConfig,
|
|
1681
|
+
routes: this.routes,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
requireDeployments() {
|
|
1685
|
+
if (!this._deployments) {
|
|
1686
|
+
throw new Error('Agent is not bound to a Deployments client');
|
|
1687
|
+
}
|
|
1688
|
+
return this._deployments;
|
|
1689
|
+
}
|
|
1690
|
+
/** Runtime auth flows for this agent's pod (coding runtimes only). */
|
|
1691
|
+
get auth() {
|
|
1692
|
+
return new RuntimeAuthClient(this);
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Connect to this agent's ACP surface through the backend session proxy.
|
|
1696
|
+
*
|
|
1697
|
+
* Default (`options.transport` unset): dials `/ws/acp` — the client-facing
|
|
1698
|
+
* ACP session authority (sessions/README §14). The proxy owns the backend
|
|
1699
|
+
* session record `{session_id → legs}`, fans runtime frames out to every
|
|
1700
|
+
* attached session client, and answers `session/new` with the backend
|
|
1701
|
+
* session id (backend-keyed, not the pod-side ACP id). Create-or-attach
|
|
1702
|
+
* semantics key on `options.sessionId`:
|
|
1703
|
+
*
|
|
1704
|
+
* - omitted: the socket starts session-less; `newSession()` runs the
|
|
1705
|
+
* proxy's `session/new`, minting the backend session.
|
|
1706
|
+
* - provided: the dial itself is session-less; AFTER the `initialize`
|
|
1707
|
+
* handshake the client resumes the session over the connected socket
|
|
1708
|
+
* (`session/resume` on v1), without replay. History uses the separate
|
|
1709
|
+
* platform sessions API. A refused resume rejects acpConnect with the
|
|
1710
|
+
* original error and closes its client; it never creates a replacement
|
|
1711
|
+
* session.
|
|
1712
|
+
*
|
|
1713
|
+
* `transport: 'direct'` dials the agent-keyed `/ws` bridge instead
|
|
1714
|
+
* (`?agent_id&token`), the pre-proxy path. Infra/debug only: `/ws` is
|
|
1715
|
+
* being hardened to runtime + backend-service identities, and combining
|
|
1716
|
+
* it with `sessionId` throws (the bridge has no session binding).
|
|
1717
|
+
*
|
|
1718
|
+
* The ACP `initialize` handshake offers protocol version 1 only
|
|
1719
|
+
* (see `client.negotiatedProtocolVersion`). The proxy authority is
|
|
1720
|
+
* v1-only and the client closes on any answered version other than 1.
|
|
1721
|
+
* The `cwd` default comes from the runtime's validated launch-directory
|
|
1722
|
+
* advertisement, never from the pod sync root or the caller's machine.
|
|
1723
|
+
*
|
|
1724
|
+
* Auth: the dial carries the client API key on the `token` query param;
|
|
1725
|
+
* `options.token` overrides that credential.
|
|
1726
|
+
*/
|
|
1727
|
+
async acpConnect(options = {}) {
|
|
1728
|
+
const deployments = this.requireDeployments();
|
|
1729
|
+
const transport = options.transport ?? 'proxy';
|
|
1730
|
+
if (transport === 'direct' && options.sessionId) {
|
|
1731
|
+
throw new Error("acpConnect: sessionId is a proxy-transport option; the direct /ws bridge " +
|
|
1732
|
+
"has no session binding (drop sessionId or use the default 'proxy' transport)");
|
|
1733
|
+
}
|
|
1734
|
+
const url = new URL(transport === 'direct'
|
|
1735
|
+
? defaultHyperAcpWsUrl(deployments.agentApiBase)
|
|
1736
|
+
: defaultAcpProxyWsUrl(deployments.agentApiBase));
|
|
1737
|
+
url.searchParams.set('agent_id', this.id);
|
|
1738
|
+
url.searchParams.set('token', options.token ?? deployments.agentApiKey);
|
|
1739
|
+
const client = await CodingAgentAcpClient.connect({ url: url.toString(), token: '' }, { ...options,
|
|
1740
|
+
resolveDefaultCwd: options.resolveDefaultCwd ?? (async () => (await deployments.runtimePaths(this.id, { signal: options.signal })).cwd),
|
|
1741
|
+
});
|
|
1742
|
+
if (options.sessionId) {
|
|
1743
|
+
try {
|
|
1744
|
+
await client.resumeSession(options.sessionId);
|
|
1745
|
+
}
|
|
1746
|
+
catch (error) {
|
|
1747
|
+
client.close();
|
|
1748
|
+
throw error;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
return client;
|
|
1752
|
+
}
|
|
1753
|
+
acpPoolValue = null;
|
|
1754
|
+
/**
|
|
1755
|
+
* Lazily created connection pool for this agent's ACP surface. Two
|
|
1756
|
+
* clients dialed to the same agent share one pod-side stdio session, so all
|
|
1757
|
+
* ACP consumers (chat panes, session sweeps, turn drivers) must ride ONE
|
|
1758
|
+
* pooled connection via leases instead of dialing their own — the bridge
|
|
1759
|
+
* rejects a duplicate runtime attach. The pool closes the connection when
|
|
1760
|
+
* the last lease releases, so a lease taken by a long-lived consumer (a
|
|
1761
|
+
* turn driver) outlives callers that unmount (a chat pane) and vice versa.
|
|
1762
|
+
*
|
|
1763
|
+
* Under the default proxy transport the pooled connection dials session-less:
|
|
1764
|
+
* prompting a session id the proxy has never seen (a stored id whose
|
|
1765
|
+
* runtime was evicted backend-side) fails with an unknown-session error.
|
|
1766
|
+
* Prompt against ids created through this proxy, or attach first with a
|
|
1767
|
+
* dedicated `acpConnect({ sessionId })`.
|
|
1768
|
+
*/
|
|
1769
|
+
get acpPool() {
|
|
1770
|
+
if (this.acpPoolValue === null) {
|
|
1771
|
+
this.acpPoolValue = new CodingAgentAcpPool({ connect: (_key, options) => this.acpConnect(options) });
|
|
1772
|
+
}
|
|
1773
|
+
return this.acpPoolValue;
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Acquire a lease on this agent's pooled ACP connection and return a ready
|
|
1777
|
+
* per-session {@link AcpTurnDriver} bound to it. `options.sessionId` is the
|
|
1778
|
+
* pinned ACP session id (the id the app persists, e.g. under
|
|
1779
|
+
* `localStorage["acp-session:<agentId>"]`); drivers are cheap — one per
|
|
1780
|
+
* session, all sharing the same lease-held connection. The driver holds
|
|
1781
|
+
* its lease until
|
|
1782
|
+
* `driver.close()`; the pooled connection stays up for other leaseholders.
|
|
1783
|
+
*/
|
|
1784
|
+
async acpTurnDriver(options) {
|
|
1785
|
+
const lease = await this.acpPool.acquire(this.id);
|
|
1786
|
+
try {
|
|
1787
|
+
return new AcpTurnDriver(lease, options);
|
|
1788
|
+
}
|
|
1789
|
+
catch (error) {
|
|
1790
|
+
lease.release();
|
|
1791
|
+
throw error;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
routeRequiresAuth(routeName, defaultValue = true) {
|
|
1795
|
+
const route = this.routes[routeName];
|
|
1796
|
+
if (!route || typeof route.auth === 'undefined') {
|
|
1797
|
+
return defaultValue;
|
|
1798
|
+
}
|
|
1799
|
+
return Boolean(route.auth);
|
|
1800
|
+
}
|
|
1801
|
+
async refreshToken() {
|
|
1802
|
+
const data = await this.requireDeployments().refreshToken(this.id);
|
|
1803
|
+
this.jwtToken = data.token ?? null;
|
|
1804
|
+
this.jwtExpiresAt = parseDate(data.expires_at);
|
|
1805
|
+
return data;
|
|
1806
|
+
}
|
|
1807
|
+
async waitRunning(timeoutMs = 300_000, pollIntervalMs = 5_000) {
|
|
1808
|
+
return this.requireDeployments().waitRunning(this.id, timeoutMs, pollIntervalMs, this.launchEpoch > 0 ? this.launchEpoch : undefined);
|
|
1809
|
+
}
|
|
1810
|
+
async update(options) {
|
|
1811
|
+
return this.requireDeployments().update(this.id, options);
|
|
1812
|
+
}
|
|
1813
|
+
async resetRuntimeDefaults(options) {
|
|
1814
|
+
return this.requireDeployments().resetRuntimeDefaults(this.id, options);
|
|
1815
|
+
}
|
|
1816
|
+
async resize(options) {
|
|
1817
|
+
return this.requireDeployments().resize(this.id, options);
|
|
1818
|
+
}
|
|
1819
|
+
/** Accept background archival and return its transitional Agent projection. */
|
|
1820
|
+
async archive(options) {
|
|
1821
|
+
return this.requireDeployments().archive(this.id, options);
|
|
1822
|
+
}
|
|
1823
|
+
async env() {
|
|
1824
|
+
const response = await this.requireDeployments().env(this.id);
|
|
1825
|
+
if (response.launch_epoch < this.launchEpoch) {
|
|
1826
|
+
throw new Error('agent env belongs to an older launch epoch');
|
|
1827
|
+
}
|
|
1828
|
+
return response.env;
|
|
1829
|
+
}
|
|
1830
|
+
async setEnv(key, value) {
|
|
1831
|
+
return this.requireDeployments().setEnv(this.id, key, value);
|
|
1832
|
+
}
|
|
1833
|
+
async deleteEnv(key) {
|
|
1834
|
+
return this.requireDeployments().deleteEnv(this.id, key);
|
|
1835
|
+
}
|
|
1836
|
+
async secretNames() {
|
|
1837
|
+
const response = await this.requireDeployments().secretNames(this.id);
|
|
1838
|
+
if (response.launch_epoch < this.launchEpoch) {
|
|
1839
|
+
throw new Error('agent Secret names belong to an older launch epoch');
|
|
1840
|
+
}
|
|
1841
|
+
return response.names;
|
|
1842
|
+
}
|
|
1843
|
+
async secret(key) {
|
|
1844
|
+
const response = await this.requireDeployments().secret(this.id, key);
|
|
1845
|
+
if (response.launch_epoch < this.launchEpoch) {
|
|
1846
|
+
throw new Error('agent Secret belongs to an older launch epoch');
|
|
1847
|
+
}
|
|
1848
|
+
return response.value;
|
|
1849
|
+
}
|
|
1850
|
+
async setSecret(key, value) {
|
|
1851
|
+
return this.requireDeployments().setSecret(this.id, key, value);
|
|
1852
|
+
}
|
|
1853
|
+
async deleteSecret(key) {
|
|
1854
|
+
return this.requireDeployments().deleteSecret(this.id, key);
|
|
1855
|
+
}
|
|
1856
|
+
async exec(command, options = {}) {
|
|
1857
|
+
return this.requireDeployments().exec(this, command, options);
|
|
1858
|
+
}
|
|
1859
|
+
/** Reef-backed files scoped to this agent's configured sync root. */
|
|
1860
|
+
get files() {
|
|
1861
|
+
return new AgentFiles(this, this.requireDeployments());
|
|
1862
|
+
}
|
|
1863
|
+
async filesList(path = '') {
|
|
1864
|
+
return this.files.list(path);
|
|
1865
|
+
}
|
|
1866
|
+
async fileReadBytes(path, options) {
|
|
1867
|
+
return this.files.readBytes(path, options);
|
|
1868
|
+
}
|
|
1869
|
+
async fileReadBytesWithMetadata(path, options) {
|
|
1870
|
+
return this.files.readBytesWithMetadata(path, options);
|
|
1871
|
+
}
|
|
1872
|
+
async fileRead(path, options) {
|
|
1873
|
+
return this.files.read(path, options);
|
|
1874
|
+
}
|
|
1875
|
+
async fileWriteBytes(path, content) {
|
|
1876
|
+
return this.files.writeBytes(path, content);
|
|
1877
|
+
}
|
|
1878
|
+
async fileWrite(path, content) {
|
|
1879
|
+
return this.files.write(path, content);
|
|
1880
|
+
}
|
|
1881
|
+
async fileDelete(path, options = {}) {
|
|
1882
|
+
return this.files.delete(path, options);
|
|
1883
|
+
}
|
|
1884
|
+
async cpTo(localPath, remotePath) {
|
|
1885
|
+
return this.requireDeployments().cpTo(this, localPath, remotePath);
|
|
1886
|
+
}
|
|
1887
|
+
async cpFrom(remotePath, localPath) {
|
|
1888
|
+
return this.requireDeployments().cpFrom(this, remotePath, localPath);
|
|
1889
|
+
}
|
|
1890
|
+
async shellConnect(shell, options) {
|
|
1891
|
+
return this.requireDeployments().shellConnect(this.id, shell, options);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
// Runtime auth surfaces are launch-shape data per runtime label, not class
|
|
1895
|
+
// behavior. openclaw/hermes-agent pods have no in-pod login flow and carry
|
|
1896
|
+
// no table entry.
|
|
1897
|
+
const RUNTIME_AUTH_CONFIG = {
|
|
1898
|
+
'buzz-agent': {
|
|
1899
|
+
agentCommand: ['buzz-agent'],
|
|
1900
|
+
statusCommand: null,
|
|
1901
|
+
logoutCommand: null,
|
|
1902
|
+
nativeMethods: [],
|
|
1903
|
+
},
|
|
1904
|
+
opencode: {
|
|
1905
|
+
agentCommand: ['opencode', 'acp'],
|
|
1906
|
+
statusCommand: ['opencode', 'auth', 'list'],
|
|
1907
|
+
logoutCommand: ['opencode', 'auth', 'logout'],
|
|
1908
|
+
nativeMethods: [],
|
|
1909
|
+
},
|
|
1910
|
+
codex: {
|
|
1911
|
+
agentCommand: ['codex-acp'],
|
|
1912
|
+
statusCommand: ['codex', 'login', 'status'],
|
|
1913
|
+
logoutCommand: ['codex', 'logout'],
|
|
1914
|
+
nativeMethods: [{
|
|
1915
|
+
id: 'device',
|
|
1916
|
+
name: 'Device authentication',
|
|
1917
|
+
description: 'Authenticate Codex with a device code.',
|
|
1918
|
+
kind: 'native',
|
|
1919
|
+
command: ['codex', 'login', '--device-auth'],
|
|
1920
|
+
metadata: {},
|
|
1921
|
+
}],
|
|
1922
|
+
},
|
|
1923
|
+
'claude-code': {
|
|
1924
|
+
agentCommand: ['claude-agent-acp'],
|
|
1925
|
+
statusCommand: ['claude', 'auth', 'status', '--json'],
|
|
1926
|
+
logoutCommand: ['claude', 'auth', 'logout'],
|
|
1927
|
+
nativeMethods: [
|
|
1928
|
+
{ id: 'claude-ai', name: 'Claude.ai', description: '', kind: 'native', command: ['claude', 'auth', 'login', '--claudeai'], metadata: {} },
|
|
1929
|
+
{ id: 'console', name: 'Anthropic Console', description: '', kind: 'native', command: ['claude', 'auth', 'login', '--console'], metadata: {} },
|
|
1930
|
+
{ id: 'sso', name: 'Enterprise SSO', description: '', kind: 'native', command: ['claude', 'auth', 'login', '--sso'], metadata: {} },
|
|
1931
|
+
],
|
|
1932
|
+
},
|
|
1933
|
+
goose: {
|
|
1934
|
+
agentCommand: ['goose', 'acp'],
|
|
1935
|
+
statusCommand: null,
|
|
1936
|
+
logoutCommand: null,
|
|
1937
|
+
nativeMethods: [],
|
|
1938
|
+
},
|
|
1939
|
+
'kimi-code': {
|
|
1940
|
+
agentCommand: ['kimi', 'acp'],
|
|
1941
|
+
statusCommand: null,
|
|
1942
|
+
logoutCommand: null,
|
|
1943
|
+
nativeMethods: [],
|
|
1944
|
+
},
|
|
1945
|
+
pi: {
|
|
1946
|
+
agentCommand: ['pi-acp'],
|
|
1947
|
+
statusCommand: null,
|
|
1948
|
+
logoutCommand: null,
|
|
1949
|
+
nativeMethods: [],
|
|
1950
|
+
},
|
|
1951
|
+
};
|
|
1952
|
+
function shellQuote(value) {
|
|
1953
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
1954
|
+
}
|
|
1955
|
+
function commandString(command) {
|
|
1956
|
+
return command.map(shellQuote).join(' ');
|
|
1957
|
+
}
|
|
1958
|
+
// Terminal control-sequence matching intentionally includes ESC and BEL.
|
|
1959
|
+
/* eslint-disable no-control-regex */
|
|
1960
|
+
const TERMINAL_ESCAPE_PATTERN = new RegExp('\\x1B(?:\\[[0-?]*[ -/]*[@-~]|\\][^\\x07]*(?:\\x07|\\x1B\\\\))', 'g');
|
|
1961
|
+
/* eslint-enable no-control-regex */
|
|
1962
|
+
function stripTerminalCodes(value) {
|
|
1963
|
+
return value.replace(TERMINAL_ESCAPE_PATTERN, '').replace(/\r/g, '');
|
|
1964
|
+
}
|
|
1965
|
+
async function websocketMessageText(data) {
|
|
1966
|
+
if (typeof data === 'string')
|
|
1967
|
+
return data;
|
|
1968
|
+
if (data instanceof ArrayBuffer)
|
|
1969
|
+
return decodeUtf8(new Uint8Array(data));
|
|
1970
|
+
if (ArrayBuffer.isView(data))
|
|
1971
|
+
return decodeUtf8(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
1972
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob)
|
|
1973
|
+
return data.text();
|
|
1974
|
+
return String(data ?? '');
|
|
1975
|
+
}
|
|
1976
|
+
export class RuntimeLoginSession {
|
|
1977
|
+
authClient;
|
|
1978
|
+
socket;
|
|
1979
|
+
requiresDeviceChallenge;
|
|
1980
|
+
output = '';
|
|
1981
|
+
verificationUrl = null;
|
|
1982
|
+
userCode = null;
|
|
1983
|
+
interactiveRequired = false;
|
|
1984
|
+
rawOutput = '';
|
|
1985
|
+
exitCode = null;
|
|
1986
|
+
marker;
|
|
1987
|
+
completion;
|
|
1988
|
+
ready;
|
|
1989
|
+
complete;
|
|
1990
|
+
markReady;
|
|
1991
|
+
constructor(authClient, socket, command, requiresDeviceChallenge) {
|
|
1992
|
+
this.authClient = authClient;
|
|
1993
|
+
this.socket = socket;
|
|
1994
|
+
this.requiresDeviceChallenge = requiresDeviceChallenge;
|
|
1995
|
+
this.marker = `__HYPERCLI_AUTH_EXIT_${randomHexToken(12)}__=`;
|
|
1996
|
+
this.completion = new Promise((resolve) => { this.complete = resolve; });
|
|
1997
|
+
this.ready = new Promise((resolve) => { this.markReady = resolve; });
|
|
1998
|
+
socket.onmessage = (event) => {
|
|
1999
|
+
void websocketMessageText(event.data).then((chunk) => this.consume(chunk));
|
|
2000
|
+
};
|
|
2001
|
+
socket.onclose = () => {
|
|
2002
|
+
this.markReady();
|
|
2003
|
+
this.complete();
|
|
2004
|
+
};
|
|
2005
|
+
socket.send(`${commandString(command)}; _hypercli_auth_rc=$?; printf '\\n${this.marker}%s\\n' "$_hypercli_auth_rc"\n`);
|
|
2006
|
+
}
|
|
2007
|
+
static async start(authClient, command, challengeTimeoutMs = 45_000, requiresDeviceChallenge = false) {
|
|
2008
|
+
const socket = await authClient.agent.shellConnect();
|
|
2009
|
+
const session = new RuntimeLoginSession(authClient, socket, command, requiresDeviceChallenge);
|
|
2010
|
+
let timer;
|
|
2011
|
+
try {
|
|
2012
|
+
await Promise.race([
|
|
2013
|
+
session.ready,
|
|
2014
|
+
new Promise((_, reject) => {
|
|
2015
|
+
timer = setTimeout(() => reject(new Error('Timed out waiting for runtime login instructions')), challengeTimeoutMs);
|
|
2016
|
+
}),
|
|
2017
|
+
]);
|
|
2018
|
+
return session;
|
|
2019
|
+
}
|
|
2020
|
+
catch (error) {
|
|
2021
|
+
session.cancel();
|
|
2022
|
+
throw error;
|
|
2023
|
+
}
|
|
2024
|
+
finally {
|
|
2025
|
+
if (timer)
|
|
2026
|
+
clearTimeout(timer);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
consume(chunk) {
|
|
2030
|
+
this.rawOutput += chunk;
|
|
2031
|
+
this.output = stripTerminalCodes(this.rawOutput);
|
|
2032
|
+
const markerIndex = this.output.lastIndexOf(this.marker);
|
|
2033
|
+
if (markerIndex >= 0) {
|
|
2034
|
+
const match = this.output.slice(markerIndex + this.marker.length).match(/^(-?\d+)/);
|
|
2035
|
+
if (match) {
|
|
2036
|
+
this.exitCode = Number(match[1]);
|
|
2037
|
+
this.markReady();
|
|
2038
|
+
this.complete();
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
this.verificationUrl ??= this.output.match(/https?:\/\/[^\s"'<>]+(?=[\s"'<>])/)?.[0] ?? null;
|
|
2042
|
+
this.userCode ??= this.output.match(/\b(?:user|device|verification|one[- ]time)\s+code\b\s*(?:is|:)?\s*(?:\([^\r\n)]*\)\s*)*((?!authorization\b)[A-Z0-9](?:[A-Z0-9-]*[A-Z0-9])?)(?=[\s.,;:)\]])/i)?.[1] ?? null;
|
|
2043
|
+
this.interactiveRequired ||= /\b(select|choose)\b.*\b(provider|login method)\b/i.test(this.output);
|
|
2044
|
+
const challengeReady = this.requiresDeviceChallenge
|
|
2045
|
+
? Boolean(this.verificationUrl && this.userCode)
|
|
2046
|
+
: Boolean(this.verificationUrl || this.userCode);
|
|
2047
|
+
if (challengeReady || this.interactiveRequired)
|
|
2048
|
+
this.markReady();
|
|
2049
|
+
}
|
|
2050
|
+
send(text) {
|
|
2051
|
+
this.socket.send(text.endsWith('\n') ? text : `${text}\n`);
|
|
2052
|
+
}
|
|
2053
|
+
async wait(timeoutMs = 600_000) {
|
|
2054
|
+
let timer;
|
|
2055
|
+
try {
|
|
2056
|
+
await Promise.race([
|
|
2057
|
+
this.completion,
|
|
2058
|
+
new Promise((_, reject) => {
|
|
2059
|
+
timer = setTimeout(() => reject(new Error('Runtime authentication timed out')), timeoutMs);
|
|
2060
|
+
}),
|
|
2061
|
+
]);
|
|
2062
|
+
}
|
|
2063
|
+
catch (error) {
|
|
2064
|
+
this.cancel();
|
|
2065
|
+
throw error;
|
|
2066
|
+
}
|
|
2067
|
+
finally {
|
|
2068
|
+
if (timer)
|
|
2069
|
+
clearTimeout(timer);
|
|
2070
|
+
}
|
|
2071
|
+
if (this.exitCode !== null && this.exitCode !== 0) {
|
|
2072
|
+
throw new Error(`Runtime authentication failed (${this.exitCode}): ${this.output.trim()}`);
|
|
2073
|
+
}
|
|
2074
|
+
return this.authClient.status();
|
|
2075
|
+
}
|
|
2076
|
+
cancel() {
|
|
2077
|
+
// The SDK supports Node runtimes where the socket is supplied by `ws` and
|
|
2078
|
+
// no global WebSocket constructor exists. OPEN is the protocol state 1.
|
|
2079
|
+
if (this.socket.readyState === 1)
|
|
2080
|
+
this.socket.send('\x03');
|
|
2081
|
+
this.socket.close();
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
export class RuntimeAuthClient {
|
|
2085
|
+
agent;
|
|
2086
|
+
config;
|
|
2087
|
+
constructor(agent) {
|
|
2088
|
+
this.agent = agent;
|
|
2089
|
+
const config = RUNTIME_AUTH_CONFIG[agent.runtime];
|
|
2090
|
+
if (!config) {
|
|
2091
|
+
throw new Error(`Runtime authentication is not available for runtime '${agent.runtime ?? 'generic'}'`);
|
|
2092
|
+
}
|
|
2093
|
+
this.config = config;
|
|
2094
|
+
}
|
|
2095
|
+
async methods() {
|
|
2096
|
+
const discovered = [];
|
|
2097
|
+
if (this.agent.runtime === 'opencode' && discovered.length === 0) {
|
|
2098
|
+
discovered.push({
|
|
2099
|
+
id: 'provider',
|
|
2100
|
+
name: 'Provider login',
|
|
2101
|
+
description: '',
|
|
2102
|
+
kind: 'native',
|
|
2103
|
+
command: ['opencode', 'auth', 'login'],
|
|
2104
|
+
metadata: {},
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
const seen = new Set(discovered.map((method) => method.id));
|
|
2108
|
+
for (const method of this.config.nativeMethods) {
|
|
2109
|
+
if (!seen.has(method.id))
|
|
2110
|
+
discovered.push({ ...method, command: [...method.command], metadata: { ...method.metadata } });
|
|
2111
|
+
}
|
|
2112
|
+
return discovered;
|
|
2113
|
+
}
|
|
2114
|
+
async status() {
|
|
2115
|
+
if (!this.config.statusCommand) {
|
|
2116
|
+
throw new Error(`Runtime authentication status is unsupported for '${this.agent.runtime}'; use the runtime's native authentication interface`);
|
|
2117
|
+
}
|
|
2118
|
+
const result = await this.agent.exec([...this.config.statusCommand]);
|
|
2119
|
+
const output = stripTerminalCodes([result.stdout, result.stderr].filter(Boolean).join('\n')).trim();
|
|
2120
|
+
const detail = { exitCode: result.exitCode, output };
|
|
2121
|
+
if (this.agent.runtime === 'claude-code') {
|
|
2122
|
+
try {
|
|
2123
|
+
const payload = JSON.parse(stripTerminalCodes(result.stdout));
|
|
2124
|
+
Object.assign(detail, payload);
|
|
2125
|
+
const loginMethod = payload.loginMethod ?? payload.authMethod;
|
|
2126
|
+
return {
|
|
2127
|
+
authenticated: payload.loggedIn === true || payload.authenticated === true ||
|
|
2128
|
+
(typeof loginMethod === 'string' && loginMethod.toLowerCase() !== 'none'),
|
|
2129
|
+
provider: typeof (payload.subscriptionType ?? payload.provider ?? payload.apiProvider) === 'string'
|
|
2130
|
+
? String(payload.subscriptionType ?? payload.provider ?? payload.apiProvider) : null,
|
|
2131
|
+
account: typeof payload.email === 'string' ? payload.email : null,
|
|
2132
|
+
method: typeof loginMethod === 'string' ? loginMethod : null,
|
|
2133
|
+
detail,
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
catch {
|
|
2137
|
+
// Fall through to the generic status parser.
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
const negative = /\b(not logged|not authenticated|unauthenticated|no credentials|0 credentials)\b/i.test(output);
|
|
2141
|
+
return { authenticated: result.exitCode === 0 && !negative, detail };
|
|
2142
|
+
}
|
|
2143
|
+
async login(options = {}) {
|
|
2144
|
+
const methods = await this.methods();
|
|
2145
|
+
const method = options.method
|
|
2146
|
+
? methods.find((candidate) => candidate.id === options.method)
|
|
2147
|
+
: methods.find((candidate) => candidate.command.length > 0) ?? methods[0];
|
|
2148
|
+
if (!method) {
|
|
2149
|
+
throw new Error(options.method
|
|
2150
|
+
? `Unknown authentication method: ${options.method}`
|
|
2151
|
+
: 'No authentication methods are available');
|
|
2152
|
+
}
|
|
2153
|
+
let command = [...method.command];
|
|
2154
|
+
if (!command.length) {
|
|
2155
|
+
throw new Error(`Authentication method '${method.id}' has no native login command`);
|
|
2156
|
+
}
|
|
2157
|
+
if (this.agent.runtime === 'opencode') {
|
|
2158
|
+
if (options.provider)
|
|
2159
|
+
command.push('--provider', options.provider);
|
|
2160
|
+
if (options.providerMethod)
|
|
2161
|
+
command.push('--method', options.providerMethod);
|
|
2162
|
+
}
|
|
2163
|
+
if (this.agent.runtime === 'claude-code' && options.email)
|
|
2164
|
+
command.push('--email', options.email);
|
|
2165
|
+
const requiresDeviceChallenge = method.kind === 'device'
|
|
2166
|
+
|| method.id === 'device'
|
|
2167
|
+
|| command.some((part) => part.toLowerCase().includes('device-auth'));
|
|
2168
|
+
return RuntimeLoginSession.start(this, command, options.challengeTimeoutMs, requiresDeviceChallenge);
|
|
2169
|
+
}
|
|
2170
|
+
async logout(provider) {
|
|
2171
|
+
if (this.config.logoutCommand === null) {
|
|
2172
|
+
const reason = this.agent.runtime === 'goose'
|
|
2173
|
+
? 'uses its injected deployment credential'
|
|
2174
|
+
: 'does not expose a noninteractive logout command';
|
|
2175
|
+
throw new Error(`${this.agent.runtime} ${reason} and cannot log out`);
|
|
2176
|
+
}
|
|
2177
|
+
const command = [...this.config.logoutCommand];
|
|
2178
|
+
if (this.agent.runtime === 'opencode' && provider)
|
|
2179
|
+
command.push(provider);
|
|
2180
|
+
const result = await this.agent.exec(command);
|
|
2181
|
+
if (result.exitCode !== 0) {
|
|
2182
|
+
throw new Error(`Runtime logout failed (${result.exitCode}): ${stripTerminalCodes(result.stderr || result.stdout).trim()}`);
|
|
2183
|
+
}
|
|
2184
|
+
return this.status();
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
const OPENCLAW_ACP_ROW = {
|
|
2188
|
+
image: DEFAULT_OPENCLAW_IMAGE,
|
|
2189
|
+
syncRoot: DEFAULT_OPENCLAW_SYNC_ROOT,
|
|
2190
|
+
syncUid: 1000,
|
|
2191
|
+
syncGid: 1000,
|
|
2192
|
+
// Mirrors the openclaw image ENV (`openclaw` resolves via PATH on the
|
|
2193
|
+
// runner host; the image pins it in ENV for the container instead).
|
|
2194
|
+
agentCommand: ['openclaw', 'acp'],
|
|
2195
|
+
env: DEFAULT_OPENCLAW_MODEL_ENV,
|
|
2196
|
+
cronEnv: buildOpenClawCronEnv,
|
|
2197
|
+
syncExclude: DEFAULT_OPENCLAW_SYNC_EXCLUDE,
|
|
2198
|
+
workspacesSyncEnv: true,
|
|
2199
|
+
stripConfigBag: true,
|
|
2200
|
+
openclawControlUi: true,
|
|
2201
|
+
openclawMemoryIndex: true,
|
|
2202
|
+
// openclaw.json reseeds on boot; the openclaw.sqlite trio clears
|
|
2203
|
+
// device_pairing_paired/device_auth_tokens/device_pairing_pending rows so a
|
|
2204
|
+
// stale scope-capped pairing (operator.pairing-only → FORBIDDEN on
|
|
2205
|
+
// operator.read/write) can never survive a runtime reset — the in-pod ACP
|
|
2206
|
+
// client re-pairs on loopback with silent auto-approve (operator.admin).
|
|
2207
|
+
// The sqlite holds only disposable metadata (audit/diagnostic events,
|
|
2208
|
+
// boot-reseeded cron, config tables); chat history lives in the agent db.
|
|
2209
|
+
resetFiles: [
|
|
2210
|
+
'.openclaw/openclaw.json',
|
|
2211
|
+
'.openclaw/state/openclaw.sqlite',
|
|
2212
|
+
'.openclaw/state/openclaw.sqlite-wal',
|
|
2213
|
+
'.openclaw/state/openclaw.sqlite-shm',
|
|
2214
|
+
],
|
|
2215
|
+
// Legacy inert per-device pairing records (devices/*.json*); unknown names,
|
|
2216
|
+
// so the whole directory goes.
|
|
2217
|
+
resetDirs: ['.openclaw/devices'],
|
|
2218
|
+
};
|
|
2219
|
+
const ACP_RUNTIME_TABLE = {
|
|
2220
|
+
openclaw_acp: OPENCLAW_ACP_ROW,
|
|
2221
|
+
hermes_acp: {
|
|
2222
|
+
image: DEFAULT_HERMES_AGENT_IMAGE,
|
|
2223
|
+
syncRoot: DEFAULT_HERMES_AGENT_SYNC_ROOT,
|
|
2224
|
+
syncUid: DEFAULT_HERMES_AGENT_SYNC_UID,
|
|
2225
|
+
syncGid: DEFAULT_HERMES_AGENT_SYNC_GID,
|
|
2226
|
+
// The hermes image ENV pins the venv absolute path; on the runner host
|
|
2227
|
+
// the hermes-acp binary resolves via PATH instead.
|
|
2228
|
+
agentCommand: ['hermes-acp'],
|
|
2229
|
+
env: DEFAULT_HERMES_MODEL_ENV,
|
|
2230
|
+
cronEnv: buildHermesCronEnv,
|
|
2231
|
+
syncExclude: DEFAULT_HERMES_AGENT_SYNC_EXCLUDE,
|
|
2232
|
+
resetFiles: ['.hermes/config.yaml', '.hermes/mem0.json'],
|
|
2233
|
+
},
|
|
2234
|
+
'buzz-agent': {
|
|
2235
|
+
image: DEFAULT_BUZZ_AGENT_IMAGE,
|
|
2236
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2237
|
+
syncUid: 1000,
|
|
2238
|
+
syncGid: 1000,
|
|
2239
|
+
// Coding harnesses share their agent command with the runtime-auth table.
|
|
2240
|
+
agentCommand: RUNTIME_AUTH_CONFIG['buzz-agent'].agentCommand,
|
|
2241
|
+
workspacesSyncEnv: true,
|
|
2242
|
+
codingHarness: true,
|
|
2243
|
+
},
|
|
2244
|
+
opencode: {
|
|
2245
|
+
image: DEFAULT_OPENCODE_IMAGE,
|
|
2246
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2247
|
+
syncUid: 1000,
|
|
2248
|
+
syncGid: 1000,
|
|
2249
|
+
agentCommand: RUNTIME_AUTH_CONFIG.opencode.agentCommand,
|
|
2250
|
+
workspacesSyncEnv: true,
|
|
2251
|
+
codingHarness: true,
|
|
2252
|
+
},
|
|
2253
|
+
codex: {
|
|
2254
|
+
image: DEFAULT_CODEX_IMAGE,
|
|
2255
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2256
|
+
syncUid: 1000,
|
|
2257
|
+
syncGid: 1000,
|
|
2258
|
+
agentCommand: RUNTIME_AUTH_CONFIG.codex.agentCommand,
|
|
2259
|
+
workspacesSyncEnv: true,
|
|
2260
|
+
codingHarness: true,
|
|
2261
|
+
},
|
|
2262
|
+
'claude-code': {
|
|
2263
|
+
image: DEFAULT_CLAUDE_CODE_IMAGE,
|
|
2264
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2265
|
+
syncUid: 1000,
|
|
2266
|
+
syncGid: 1000,
|
|
2267
|
+
agentCommand: RUNTIME_AUTH_CONFIG['claude-code'].agentCommand,
|
|
2268
|
+
workspacesSyncEnv: true,
|
|
2269
|
+
codingHarness: true,
|
|
2270
|
+
},
|
|
2271
|
+
goose: {
|
|
2272
|
+
image: DEFAULT_GOOSE_IMAGE,
|
|
2273
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2274
|
+
syncUid: 1000,
|
|
2275
|
+
syncGid: 1000,
|
|
2276
|
+
agentCommand: RUNTIME_AUTH_CONFIG.goose.agentCommand,
|
|
2277
|
+
workspacesSyncEnv: true,
|
|
2278
|
+
codingHarness: true,
|
|
2279
|
+
},
|
|
2280
|
+
'kimi-code': {
|
|
2281
|
+
image: DEFAULT_KIMI_CODE_IMAGE,
|
|
2282
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2283
|
+
syncUid: 1000,
|
|
2284
|
+
syncGid: 1000,
|
|
2285
|
+
agentCommand: RUNTIME_AUTH_CONFIG['kimi-code'].agentCommand,
|
|
2286
|
+
workspacesSyncEnv: true,
|
|
2287
|
+
codingHarness: true,
|
|
2288
|
+
},
|
|
2289
|
+
pi: {
|
|
2290
|
+
image: DEFAULT_PI_IMAGE,
|
|
2291
|
+
syncRoot: DEFAULT_CODING_AGENT_SYNC_ROOT,
|
|
2292
|
+
syncUid: 1000,
|
|
2293
|
+
syncGid: 1000,
|
|
2294
|
+
agentCommand: RUNTIME_AUTH_CONFIG.pi.agentCommand,
|
|
2295
|
+
workspacesSyncEnv: true,
|
|
2296
|
+
codingHarness: true,
|
|
2297
|
+
},
|
|
2298
|
+
};
|
|
2299
|
+
export class Deployments {
|
|
2300
|
+
apiKey;
|
|
2301
|
+
apiBase;
|
|
2302
|
+
agentsWsUrl;
|
|
2303
|
+
agentHttp;
|
|
2304
|
+
constructor(http, agentApiKey, agentApiBase, agentsWsUrl, requestTimeout) {
|
|
2305
|
+
this.apiKey = agentApiKey || http.apiKey;
|
|
2306
|
+
this.apiBase = resolveAgentsApiBase(agentApiBase || getAgentsApiBaseUrl(), true);
|
|
2307
|
+
this.agentsWsUrl = agentsWsUrl || defaultAgentsWsUrl(this.apiBase);
|
|
2308
|
+
const agentTimeout = requestTimeout ?? (http instanceof HTTPClient ? http.timeout : undefined);
|
|
2309
|
+
this.agentHttp = http instanceof HTTPClient ? new HTTPClient(this.apiBase, this.apiKey, agentTimeout) : http;
|
|
2310
|
+
}
|
|
2311
|
+
get agentApiKey() {
|
|
2312
|
+
return this.apiKey;
|
|
2313
|
+
}
|
|
2314
|
+
get agentApiBase() {
|
|
2315
|
+
return this.apiBase;
|
|
2316
|
+
}
|
|
2317
|
+
/** Authoritative runtime-host launch directory, resolved by the platform. */
|
|
2318
|
+
runtimePaths(agentId, requestOptions = {}) {
|
|
2319
|
+
return this.agentHttp.get(`/deployments/${encodeURIComponent(agentId)}/runtime-paths`, undefined, requestOptions);
|
|
2320
|
+
}
|
|
2321
|
+
hydrateAgent(data) {
|
|
2322
|
+
// One flat Agent for every runtime; the peer determines ACP availability.
|
|
2323
|
+
return bindAgent(Agent.fromDict(data), this);
|
|
2324
|
+
}
|
|
2325
|
+
async getById(agentId, requestOptions = {}) {
|
|
2326
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}`;
|
|
2327
|
+
const data = Object.keys(requestOptions).length === 0
|
|
2328
|
+
? await this.agentHttp.get(path)
|
|
2329
|
+
: await this.agentHttp.get(path, undefined, requestOptions);
|
|
2330
|
+
return this.hydrateAgent(data);
|
|
2331
|
+
}
|
|
2332
|
+
async resolveAgent(agentIdOrName, requestOptions = {}) {
|
|
2333
|
+
const raw = String(agentIdOrName || '').trim();
|
|
2334
|
+
if (!raw) {
|
|
2335
|
+
throw new Error('agentIdOrName is required');
|
|
2336
|
+
}
|
|
2337
|
+
if (isUuidRef(raw)) {
|
|
2338
|
+
return this.getById(raw, requestOptions);
|
|
2339
|
+
}
|
|
2340
|
+
const matches = [];
|
|
2341
|
+
for (const agent of await this.list(requestOptions)) {
|
|
2342
|
+
const values = [agent.id, agent.name, agent.handle, agent.hostname];
|
|
2343
|
+
if (values.some((value) => String(value || '') === raw)) {
|
|
2344
|
+
matches.push(agent);
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
if (values.some((value) => String(value || '').startsWith(raw))) {
|
|
2348
|
+
matches.push(agent);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
if (matches.length === 0) {
|
|
2352
|
+
throw new Error(`Agent not found: ${raw}`);
|
|
2353
|
+
}
|
|
2354
|
+
if (matches.length > 1) {
|
|
2355
|
+
throw new Error(`Agent reference is ambiguous: ${raw} (${matches.slice(0, 5).map((agent) => agent.id).join(', ')})`);
|
|
2356
|
+
}
|
|
2357
|
+
return this.getById(matches[0].id, requestOptions);
|
|
2358
|
+
}
|
|
2359
|
+
async resolveAgentId(agentIdOrName, requestOptions = {}) {
|
|
2360
|
+
const raw = String(agentIdOrName || '').trim();
|
|
2361
|
+
if (!raw) {
|
|
2362
|
+
throw new Error('agentIdOrName is required');
|
|
2363
|
+
}
|
|
2364
|
+
if (isSelfAgentRef(raw)) {
|
|
2365
|
+
// An Agent introspects itself; it does not start, stop, or edit its own
|
|
2366
|
+
// routes. Status is the only self operation, and it is served directly
|
|
2367
|
+
// by GET /deployments/self -- nothing resolves a self reference to an id
|
|
2368
|
+
// any more.
|
|
2369
|
+
throw new Error('self is only supported for status');
|
|
2370
|
+
}
|
|
2371
|
+
if (isDirectAgentIdRef(raw)) {
|
|
2372
|
+
return raw;
|
|
2373
|
+
}
|
|
2374
|
+
return (await this.resolveAgent(raw, requestOptions)).id;
|
|
2375
|
+
}
|
|
2376
|
+
async routesTarget(agentIdOrName) {
|
|
2377
|
+
const raw = String(agentIdOrName || '').trim();
|
|
2378
|
+
if (isSelfAgentRef(raw)) {
|
|
2379
|
+
return 'self';
|
|
2380
|
+
}
|
|
2381
|
+
return this.resolveAgentId(raw);
|
|
2382
|
+
}
|
|
2383
|
+
async agentIdFor(target) {
|
|
2384
|
+
return typeof target === 'string' ? this.resolveAgentId(target) : target.id;
|
|
2385
|
+
}
|
|
2386
|
+
async fetchRaw(path, init = {}) {
|
|
2387
|
+
const headers = new Headers(init.headers ?? {});
|
|
2388
|
+
headers.set('Authorization', `Bearer ${this.apiKey}`);
|
|
2389
|
+
const contentType = headers.get('Content-Type');
|
|
2390
|
+
const body = init.body && contentType?.includes('application/json') && typeof init.body !== 'string'
|
|
2391
|
+
? JSON.stringify(init.body)
|
|
2392
|
+
: init.body;
|
|
2393
|
+
const response = await fetch(`${this.apiBase}${path}`, {
|
|
2394
|
+
...init,
|
|
2395
|
+
headers,
|
|
2396
|
+
body,
|
|
2397
|
+
});
|
|
2398
|
+
if (!response.ok) {
|
|
2399
|
+
let detail = response.statusText;
|
|
2400
|
+
try {
|
|
2401
|
+
const payload = await response.clone().json();
|
|
2402
|
+
detail = typeof payload.detail === 'string' ? payload.detail : response.statusText;
|
|
2403
|
+
}
|
|
2404
|
+
catch {
|
|
2405
|
+
const text = await response.text();
|
|
2406
|
+
detail = text || response.statusText;
|
|
2407
|
+
}
|
|
2408
|
+
throw new APIError(response.status, detail);
|
|
2409
|
+
}
|
|
2410
|
+
return response;
|
|
2411
|
+
}
|
|
2412
|
+
async fileAccess(agentId, signal) {
|
|
2413
|
+
const payload = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/files/token`, undefined, { redirect: 'error', ...(signal ? { signal } : {}) });
|
|
2414
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
2415
|
+
throw new Error('Backend returned an invalid Agent file token response');
|
|
2416
|
+
}
|
|
2417
|
+
const fields = 'transport' in payload ? ['transport', 'executor', 'max_bytes', 'chunk_bytes'] : ['url', 'token', 'expires_at'];
|
|
2418
|
+
if (Object.keys(payload).length !== fields.length || fields.some((key) => !(key in payload))) {
|
|
2419
|
+
throw new Error('Backend returned an invalid Agent file token response');
|
|
2420
|
+
}
|
|
2421
|
+
if ('transport' in payload) {
|
|
2422
|
+
// Executor is informational and null until observed; the file operation determines availability.
|
|
2423
|
+
if (payload.transport !== 'runner' || (payload.executor !== null && payload.executor !== 'process' && payload.executor !== 'docker') || !Number.isSafeInteger(payload.max_bytes) || payload.max_bytes <= 0) {
|
|
2424
|
+
throw new Error('Backend returned an invalid runner file transport');
|
|
2425
|
+
}
|
|
2426
|
+
const chunkBytes = payload.chunk_bytes;
|
|
2427
|
+
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0 || chunkBytes > payload.max_bytes) {
|
|
2428
|
+
throw new Error('Backend returned an invalid runner chunk size');
|
|
2429
|
+
}
|
|
2430
|
+
return { transport: 'runner', maxBytes: Math.min(payload.max_bytes, RUNNER_FILE_MAX_BYTES), chunkBytes };
|
|
2431
|
+
}
|
|
2432
|
+
const token = typeof payload?.token === 'string' ? payload.token.trim() : '';
|
|
2433
|
+
const expiresAt = typeof payload?.expires_at === 'string' ? payload.expires_at.trim() : '';
|
|
2434
|
+
let url;
|
|
2435
|
+
try {
|
|
2436
|
+
url = new URL(typeof payload?.url === 'string' ? payload.url : '');
|
|
2437
|
+
}
|
|
2438
|
+
catch {
|
|
2439
|
+
throw new Error('Backend returned an invalid Agent file token response');
|
|
2440
|
+
}
|
|
2441
|
+
if (url.protocol !== 'https:' ||
|
|
2442
|
+
!url.hostname ||
|
|
2443
|
+
url.username ||
|
|
2444
|
+
url.password ||
|
|
2445
|
+
url.search ||
|
|
2446
|
+
url.hash ||
|
|
2447
|
+
url.pathname !== '/_reef' ||
|
|
2448
|
+
!token ||
|
|
2449
|
+
!expiresAt) {
|
|
2450
|
+
throw new Error('Backend returned an invalid Agent file token response');
|
|
2451
|
+
}
|
|
2452
|
+
return { url: url.toString().replace(/\/+$/, ''), token };
|
|
2453
|
+
}
|
|
2454
|
+
async fetchReef(access, path, init = {}) {
|
|
2455
|
+
const headers = new Headers(init.headers ?? {});
|
|
2456
|
+
headers.set('Authorization', `Bearer ${access.token}`);
|
|
2457
|
+
const response = await fetch(`${access.url}${path}`, {
|
|
2458
|
+
...init,
|
|
2459
|
+
headers,
|
|
2460
|
+
redirect: 'error',
|
|
2461
|
+
});
|
|
2462
|
+
if (!response.ok) {
|
|
2463
|
+
let detail = response.statusText;
|
|
2464
|
+
try {
|
|
2465
|
+
const payload = await response.clone().json();
|
|
2466
|
+
detail = typeof payload.detail === 'string' ? payload.detail : response.statusText;
|
|
2467
|
+
}
|
|
2468
|
+
catch {
|
|
2469
|
+
const text = await response.text();
|
|
2470
|
+
detail = text || response.statusText;
|
|
2471
|
+
}
|
|
2472
|
+
throw new APIError(response.status, detail);
|
|
2473
|
+
}
|
|
2474
|
+
return response;
|
|
2475
|
+
}
|
|
2476
|
+
async create(options = {}) {
|
|
2477
|
+
const config = buildAgentCreateConfig(options.config ?? {}, options);
|
|
2478
|
+
const body = { ...config };
|
|
2479
|
+
if (options.dryRun)
|
|
2480
|
+
body.dry_run = true;
|
|
2481
|
+
if (options.name)
|
|
2482
|
+
body.name = options.name;
|
|
2483
|
+
if (options.handle !== undefined)
|
|
2484
|
+
body.handle = options.handle;
|
|
2485
|
+
if (options.size)
|
|
2486
|
+
body.size = options.size;
|
|
2487
|
+
if (options.meta?.ui)
|
|
2488
|
+
body.meta = { ui: structuredClone(options.meta.ui) };
|
|
2489
|
+
if (options.meta?.integrations)
|
|
2490
|
+
body.meta = { ...body.meta, integrations: structuredClone(options.meta.integrations) };
|
|
2491
|
+
if (options.tags?.length)
|
|
2492
|
+
body.tags = [...options.tags];
|
|
2493
|
+
if (options.runner) {
|
|
2494
|
+
body.runner = {
|
|
2495
|
+
...(options.runner.tags ? { tags: [...options.runner.tags] } : {}),
|
|
2496
|
+
...(options.runner.runnerId ? { runner_id: options.runner.runnerId } : {}),
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
if (options.runtime)
|
|
2500
|
+
body.runtime = options.runtime;
|
|
2501
|
+
const data = await this.agentHttp.post(DEPLOYMENTS_API_PREFIX, body, { retries: 1 });
|
|
2502
|
+
return this.hydrateAgent(data);
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Create a managed agent for a runtime in one call.
|
|
2506
|
+
*
|
|
2507
|
+
* `runtime` selects the launch defaults (image, sync root and
|
|
2508
|
+
* include/exclude presets, uid/gid, env presets, routes, boot command) from
|
|
2509
|
+
* the ACP_RUNTIME_TABLE data table; `options` folds the old per-runtime
|
|
2510
|
+
* facade option types — each runtime reads the knobs it understands and
|
|
2511
|
+
* ignores the rest. `create()` stays the raw generic entry; `createAgent`
|
|
2512
|
+
* is the typed one. Every non-generic runtime boots behind hyper-acp;
|
|
2513
|
+
* `buzz` switches to the Buzz launch (coding runtimes only).
|
|
2514
|
+
*/
|
|
2515
|
+
async createAgent(runtime, options = {}) {
|
|
2516
|
+
if (runtime === 'generic')
|
|
2517
|
+
return this.create(options);
|
|
2518
|
+
return this.createAcpAgentDeployment(runtime, options);
|
|
2519
|
+
}
|
|
2520
|
+
/**
|
|
2521
|
+
* Create an ACP-fronted coding agent. All coding runtimes share one launch
|
|
2522
|
+
* contract; `runtime` selects the default image and sync includes.
|
|
2523
|
+
* Runtime configuration roots are selected on the runtime host.
|
|
2524
|
+
*
|
|
2525
|
+
* @deprecated Use {@link Deployments.createAgent} with the runtime label;
|
|
2526
|
+
* the folded entry takes the same options bag.
|
|
2527
|
+
*/
|
|
2528
|
+
async createCodingAgent(runtime, options = {}) {
|
|
2529
|
+
return this.createAgent(runtime, options);
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* The one canonical ACP launch path: every non-generic runtime launches
|
|
2533
|
+
* through here, with per-family differences read from ACP_RUNTIME_TABLE.
|
|
2534
|
+
*/
|
|
2535
|
+
async createAcpAgentDeployment(runtime, options) {
|
|
2536
|
+
const family = ACP_RUNTIME_TABLE[runtime];
|
|
2537
|
+
const processExecutor = options.executor === 'process';
|
|
2538
|
+
if (processExecutor && options.image !== undefined && options.image !== null) {
|
|
2539
|
+
// Runner-process launch: the runner host executes `command` directly,
|
|
2540
|
+
// so there is no image to pull. Mirrors the Backend's 400
|
|
2541
|
+
// "image is only supported for the docker executor".
|
|
2542
|
+
throw new Error('image requires the docker executor');
|
|
2543
|
+
}
|
|
2544
|
+
if (options.buzzEnabled && options.buzz) {
|
|
2545
|
+
throw new Error('buzzEnabled cannot be combined with buzz');
|
|
2546
|
+
}
|
|
2547
|
+
if ((options.buzzEnabled || options.buzz) && options.command !== undefined && options.command !== null) {
|
|
2548
|
+
throw new Error('Buzz launch cannot be combined with an explicit command');
|
|
2549
|
+
}
|
|
2550
|
+
if ((options.buzzEnabled || options.buzz) && !family.codingHarness) {
|
|
2551
|
+
throw new Error(`Buzz launch is supported on coding-agent runtimes only, not '${runtime}'`);
|
|
2552
|
+
}
|
|
2553
|
+
const buzzLaunch = options.buzzEnabled || options.buzz !== undefined && options.buzz !== null;
|
|
2554
|
+
if (buzzLaunch && options.size !== undefined && options.size !== 'large' && options.size !== 'medium') {
|
|
2555
|
+
throw new Error("Buzz coding agents require size='large' or 'medium'");
|
|
2556
|
+
}
|
|
2557
|
+
rejectOpenClawSecretOnlyEnv({ ...(options.env ?? {}) });
|
|
2558
|
+
const effectiveEnv = {
|
|
2559
|
+
...(family.workspacesSyncEnv ? buildWorkspacesSyncEnv(options.workspacesSync ?? null) : null),
|
|
2560
|
+
...(family.cronEnv ? family.cronEnv(options.cronEnabled ?? null) : null),
|
|
2561
|
+
...(family.openclawMemoryIndex ? buildOpenClawMemoryIndexEnv(options.memoryIndex) : null),
|
|
2562
|
+
...(family.env ?? null),
|
|
2563
|
+
...(options.env ?? {}),
|
|
2564
|
+
};
|
|
2565
|
+
if (family.openclawControlUi) {
|
|
2566
|
+
// OpenClaw treats this env as a full replace for
|
|
2567
|
+
// gateway.controlUi.allowedOrigins, and every HyperCLI surface (desktop,
|
|
2568
|
+
// console) drives the control UI from dynamic origins. The only value
|
|
2569
|
+
// that lands reliably is the wildcard — always write it.
|
|
2570
|
+
effectiveEnv.OPENCLAW_CONTROL_UI_ALLOWED_ORIGIN = '*';
|
|
2571
|
+
Object.assign(effectiveEnv, buildOpenClawTrustedProxiesEnv(options.trustedProxies));
|
|
2572
|
+
}
|
|
2573
|
+
if (options.permissionMode !== undefined) {
|
|
2574
|
+
throw new Error('permissionMode launch presets are retired; select an advertised native session mode');
|
|
2575
|
+
}
|
|
2576
|
+
const effectiveSecrets = { ...(options.secrets ?? {}) };
|
|
2577
|
+
if (family.codingHarness) {
|
|
2578
|
+
for (const key of ['BUZZ_PRIVATE_KEY', 'NOSTR_PRIVATE_KEY']) {
|
|
2579
|
+
const value = effectiveEnv[key];
|
|
2580
|
+
if (value === undefined)
|
|
2581
|
+
continue;
|
|
2582
|
+
delete effectiveEnv[key];
|
|
2583
|
+
if (effectiveSecrets[key] !== undefined && effectiveSecrets[key] !== value) {
|
|
2584
|
+
throw new Error(`${key} conflicts between env and secrets`);
|
|
2585
|
+
}
|
|
2586
|
+
effectiveSecrets[key] = value;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
// The process executor has no image: the runner host runs `command`,
|
|
2590
|
+
// resolving the bare hyper-acp binary via the host's PATH. Hosted/docker
|
|
2591
|
+
// launches keep the container path.
|
|
2592
|
+
const resolvedImage = processExecutor ? undefined : options.image ?? family.image;
|
|
2593
|
+
const hyperAcpBinary = processExecutor ? 'hyper-acp' : '/usr/local/bin/hyper-acp';
|
|
2594
|
+
const buzzConfig = buzzLaunch ? buildBuzzLaunchConfig(runtime, options.buzz ?? null, this.apiBase, { env: effectiveEnv, secrets: effectiveSecrets,
|
|
2595
|
+
executor: options.executor }) : null;
|
|
2596
|
+
let syncInclude;
|
|
2597
|
+
let syncExclude;
|
|
2598
|
+
if (options.syncInclude !== undefined && options.syncInclude !== null) {
|
|
2599
|
+
syncInclude = options.syncInclude;
|
|
2600
|
+
syncExclude = undefined;
|
|
2601
|
+
}
|
|
2602
|
+
else if (options.syncExclude !== undefined) {
|
|
2603
|
+
syncInclude = undefined;
|
|
2604
|
+
syncExclude = options.syncExclude ?? undefined;
|
|
2605
|
+
}
|
|
2606
|
+
else if (options.syncInclude === null) {
|
|
2607
|
+
syncInclude = undefined;
|
|
2608
|
+
syncExclude = undefined;
|
|
2609
|
+
}
|
|
2610
|
+
else if (family.codingHarness) {
|
|
2611
|
+
// Read live: tests and embedders may retarget the include presets.
|
|
2612
|
+
const defaultInclude = DEFAULT_CODING_AGENT_SYNC_INCLUDES[runtime];
|
|
2613
|
+
syncInclude = defaultInclude ?? undefined;
|
|
2614
|
+
syncExclude = defaultInclude === null ? [] : undefined;
|
|
2615
|
+
}
|
|
2616
|
+
else if (family.syncExclude) {
|
|
2617
|
+
syncExclude = [...family.syncExclude];
|
|
2618
|
+
}
|
|
2619
|
+
const defaultRoutes = options.openClawRoutes?.includeDesktop
|
|
2620
|
+
? buildOpenClawDesktopRoute(options.openClawRoutes) : {};
|
|
2621
|
+
const effectiveOptions = {
|
|
2622
|
+
...options,
|
|
2623
|
+
meta: buzzLaunch ? {
|
|
2624
|
+
...options.meta,
|
|
2625
|
+
integrations: { ...options.meta?.integrations, buzz: { enabled: true } },
|
|
2626
|
+
} : options.meta,
|
|
2627
|
+
runtime,
|
|
2628
|
+
size: buzzLaunch ? (options.size ?? 'large') : options.size,
|
|
2629
|
+
env: buzzConfig?.env ?? effectiveEnv,
|
|
2630
|
+
secrets: buzzConfig?.secrets ?? effectiveSecrets,
|
|
2631
|
+
routes: buzzConfig?.routes ?? options.routes ?? defaultRoutes,
|
|
2632
|
+
image: resolvedImage,
|
|
2633
|
+
command: options.buzzEnabled || options.buzz
|
|
2634
|
+
? buzzConfig.command
|
|
2635
|
+
// Runner-verbatim-spawn contract: the runner spawns
|
|
2636
|
+
// launch_config.command exactly as stored, so the SDK constructs the
|
|
2637
|
+
// full hyper-acp argv per runtime at create time — hyper-acp plus
|
|
2638
|
+
// `--agent-command <runtime agent command>` and one repeated
|
|
2639
|
+
// `--agent-arg` per extra agent argv element, mirroring the image's
|
|
2640
|
+
// HYPER_ACP_AGENT_COMMAND/HYPER_ACP_AGENT_ARGS env split. There is no
|
|
2641
|
+
// HYPER_ACP_AGENT_COMMAND env injection anywhere and no backend
|
|
2642
|
+
// command map or runner fallback. ACP_RUNTIME_TABLE.agentCommand
|
|
2643
|
+
// covers every runtime the process executor admits, so a bare
|
|
2644
|
+
// hyper-acp (which would spawn a guaranteed-dead child-less bridge)
|
|
2645
|
+
// can never ship. A caller-provided `command` override still wins.
|
|
2646
|
+
// Container launches are unchanged: the image ENV/CMD already
|
|
2647
|
+
// carries the agent command (openclaw/hermes images need no explicit
|
|
2648
|
+
// command), so only coding-harness containers keep the bare
|
|
2649
|
+
// container-path hyper-acp binary.
|
|
2650
|
+
: options.command ?? (processExecutor
|
|
2651
|
+
? [
|
|
2652
|
+
hyperAcpBinary,
|
|
2653
|
+
'--agent-command', family.agentCommand[0],
|
|
2654
|
+
...family.agentCommand.slice(1).flatMap((arg) => ['--agent-arg', arg]),
|
|
2655
|
+
]
|
|
2656
|
+
: family.codingHarness ? [hyperAcpBinary] : undefined),
|
|
2657
|
+
syncRoot: options.syncRoot ?? family.syncRoot,
|
|
2658
|
+
syncInclude,
|
|
2659
|
+
syncExclude,
|
|
2660
|
+
syncUid: options.syncUid ?? family.syncUid,
|
|
2661
|
+
syncGid: options.syncGid ?? family.syncGid,
|
|
2662
|
+
// Native process exit leaves hosted Buzz stopped; generic launch options
|
|
2663
|
+
// cannot opt it back into automatic restart.
|
|
2664
|
+
restart: buzzLaunch ? false : options.restart,
|
|
2665
|
+
runtimeScopes: options.runtimeScopes ?? DEFAULT_AGENT_RUNTIME_SCOPES,
|
|
2666
|
+
};
|
|
2667
|
+
if (family.stripConfigBag) {
|
|
2668
|
+
delete effectiveOptions.config;
|
|
2669
|
+
}
|
|
2670
|
+
const agent = await this.create(effectiveOptions);
|
|
2671
|
+
if (agent.runtime !== runtime) {
|
|
2672
|
+
throw new Error(`Deployment response did not identify runtime '${runtime}'`);
|
|
2673
|
+
}
|
|
2674
|
+
return agent;
|
|
2675
|
+
}
|
|
2676
|
+
async budget() {
|
|
2677
|
+
return this.agentHttp.get(`${DEPLOYMENTS_API_PREFIX}/budget`);
|
|
2678
|
+
}
|
|
2679
|
+
async oneShotAgentWebSocket(agentId, purpose, request, timeoutMs = 45_000) {
|
|
2680
|
+
const rawToken = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/${purpose}/token`);
|
|
2681
|
+
const token = validateAgentWsToken(rawToken, agentId, purpose);
|
|
2682
|
+
const parsed = new URL(token.ws_url);
|
|
2683
|
+
parsed.searchParams.set('token', token.token);
|
|
2684
|
+
const WebSocketImpl = preferredWebSocket();
|
|
2685
|
+
let ws;
|
|
2686
|
+
try {
|
|
2687
|
+
ws = new WebSocketImpl(parsed.toString());
|
|
2688
|
+
}
|
|
2689
|
+
catch (error) {
|
|
2690
|
+
throw new Error(`Agent ${purpose} WebSocket connection failed`, { cause: error });
|
|
2691
|
+
}
|
|
2692
|
+
return await new Promise((resolve, reject) => {
|
|
2693
|
+
let opened = false;
|
|
2694
|
+
let settled = false;
|
|
2695
|
+
let socketError;
|
|
2696
|
+
let result;
|
|
2697
|
+
let resultCount = 0;
|
|
2698
|
+
const timer = setTimeout(() => {
|
|
2699
|
+
finish(new Error(`Agent ${purpose} WebSocket timed out`));
|
|
2700
|
+
ws.close(1000, 'Client timeout');
|
|
2701
|
+
}, timeoutMs);
|
|
2702
|
+
const finish = (error) => {
|
|
2703
|
+
if (settled)
|
|
2704
|
+
return;
|
|
2705
|
+
settled = true;
|
|
2706
|
+
clearTimeout(timer);
|
|
2707
|
+
if (error)
|
|
2708
|
+
reject(error);
|
|
2709
|
+
else
|
|
2710
|
+
resolve(result);
|
|
2711
|
+
};
|
|
2712
|
+
ws.onopen = () => {
|
|
2713
|
+
opened = true;
|
|
2714
|
+
if (request !== undefined)
|
|
2715
|
+
ws.send(JSON.stringify(request));
|
|
2716
|
+
};
|
|
2717
|
+
ws.onmessage = (event) => {
|
|
2718
|
+
const rejectFrame = (message, reason) => {
|
|
2719
|
+
const error = new Error(message);
|
|
2720
|
+
try {
|
|
2721
|
+
ws.close(1008, reason);
|
|
2722
|
+
}
|
|
2723
|
+
finally {
|
|
2724
|
+
finish(error);
|
|
2725
|
+
}
|
|
2726
|
+
};
|
|
2727
|
+
if (typeof event.data !== 'string') {
|
|
2728
|
+
rejectFrame(`Agent ${purpose} WebSocket returned a non-text result frame`, 'Invalid result frame');
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
resultCount += 1;
|
|
2732
|
+
if (resultCount !== 1) {
|
|
2733
|
+
rejectFrame(`Agent ${purpose} WebSocket returned more than one result frame`, 'Too many result frames');
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
try {
|
|
2737
|
+
result = JSON.parse(event.data);
|
|
2738
|
+
}
|
|
2739
|
+
catch {
|
|
2740
|
+
rejectFrame(`Agent ${purpose} WebSocket returned invalid JSON`, 'Invalid result frame');
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
ws.onerror = (event) => {
|
|
2744
|
+
const socketEvent = event;
|
|
2745
|
+
const error = typeof socketEvent.error === 'object'
|
|
2746
|
+
&& socketEvent.error instanceof Error
|
|
2747
|
+
? socketEvent.error
|
|
2748
|
+
: undefined;
|
|
2749
|
+
socketError = new Error(`Agent ${purpose} WebSocket connection failed`, { cause: error });
|
|
2750
|
+
};
|
|
2751
|
+
ws.onclose = (event) => {
|
|
2752
|
+
if (socketError) {
|
|
2753
|
+
finish(socketError);
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
if (event.code !== 1000) {
|
|
2757
|
+
const reason = event.reason ? `: ${event.reason}` : '';
|
|
2758
|
+
finish(new Error(`Agent ${purpose} WebSocket closed with code ${event.code}${reason}`));
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
if (!opened || resultCount !== 1) {
|
|
2762
|
+
finish(new Error(`Agent ${purpose} WebSocket closed without one result frame`));
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
finish();
|
|
2766
|
+
};
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
async metrics(agentIdOrName) {
|
|
2770
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
2771
|
+
return validateAgentMetricsResult(await this.oneShotAgentWebSocket(agentId, 'metrics'));
|
|
2772
|
+
}
|
|
2773
|
+
async list(options = {}) {
|
|
2774
|
+
return (await this.listWithCapacity(options)).items;
|
|
2775
|
+
}
|
|
2776
|
+
async listWithCapacity(options = {}) {
|
|
2777
|
+
const params = {};
|
|
2778
|
+
if (options.state)
|
|
2779
|
+
params.state = options.state;
|
|
2780
|
+
if (options.handle)
|
|
2781
|
+
params.handle = options.handle;
|
|
2782
|
+
if (options.name)
|
|
2783
|
+
params.name = options.name;
|
|
2784
|
+
if (options.query)
|
|
2785
|
+
params.q = options.query;
|
|
2786
|
+
if (options.includeDeleted !== undefined && options.includeDeleted !== null) {
|
|
2787
|
+
params.include_deleted = options.includeDeleted ? 'true' : 'false';
|
|
2788
|
+
}
|
|
2789
|
+
const requestOptions = {
|
|
2790
|
+
retries: options.retries,
|
|
2791
|
+
backoff: options.backoff,
|
|
2792
|
+
timeout: options.timeout,
|
|
2793
|
+
signal: options.signal,
|
|
2794
|
+
retryStatuses: options.retryStatuses,
|
|
2795
|
+
};
|
|
2796
|
+
const cleanRequestOptions = {};
|
|
2797
|
+
for (const key of Object.keys(requestOptions)) {
|
|
2798
|
+
const value = requestOptions[key];
|
|
2799
|
+
if (value !== undefined) {
|
|
2800
|
+
cleanRequestOptions[key] = value;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
const data = await this.agentHttp.get(DEPLOYMENTS_API_PREFIX, Object.keys(params).length ? params : undefined, Object.keys(cleanRequestOptions).length ? cleanRequestOptions : undefined);
|
|
2804
|
+
const payload = Array.isArray(data) ? { items: data } : data;
|
|
2805
|
+
const items = (payload.items ?? []).map((item) => this.hydrateAgent(item));
|
|
2806
|
+
const runningFallback = items.filter((agent) => !isAgentRuntimeInactiveState(agent.state)).length;
|
|
2807
|
+
return {
|
|
2808
|
+
items,
|
|
2809
|
+
totalAgents: Number(payload.total_agents ?? items.length),
|
|
2810
|
+
maxAgentsPerAccount: Number(payload.max_agents_per_account ?? 0),
|
|
2811
|
+
runningAgents: Number(payload.running_agents ?? runningFallback),
|
|
2812
|
+
slots: Object.fromEntries(Object.entries(payload.slots ?? {}).map(([size, raw]) => {
|
|
2813
|
+
const inventory = raw;
|
|
2814
|
+
return [size, {
|
|
2815
|
+
granted: Number(inventory.granted ?? 0),
|
|
2816
|
+
used: Number(inventory.used ?? inventory.occupied ?? 0),
|
|
2817
|
+
available: Number(inventory.available ?? 0),
|
|
2818
|
+
}];
|
|
2819
|
+
})),
|
|
2820
|
+
agentSlots: (payload.agent_slots ?? []).map(agentSlotFromDict),
|
|
2821
|
+
pooledTpd: Number(payload.pooled_tpd ?? 0),
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
async get(agentIdOrName, requestOptions = {}) {
|
|
2825
|
+
const raw = String(agentIdOrName || '').trim();
|
|
2826
|
+
if (!raw)
|
|
2827
|
+
throw new Error('agentIdOrName is required');
|
|
2828
|
+
if (isSelfAgentRef(raw)) {
|
|
2829
|
+
return this.getById('self', requestOptions);
|
|
2830
|
+
}
|
|
2831
|
+
if (!isDirectAgentIdRef(raw)) {
|
|
2832
|
+
return this.resolveAgent(raw, requestOptions);
|
|
2833
|
+
}
|
|
2834
|
+
try {
|
|
2835
|
+
return await this.getById(raw, requestOptions);
|
|
2836
|
+
}
|
|
2837
|
+
catch (error) {
|
|
2838
|
+
if (!(error instanceof APIError) || ![404, 422].includes(error.statusCode) || isUuidRef(raw)) {
|
|
2839
|
+
throw error;
|
|
2840
|
+
}
|
|
2841
|
+
return this.resolveAgent(raw, requestOptions);
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
async attachSlackRelayAgent(agentIdOrName, options) {
|
|
2845
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
2846
|
+
return attachSlackRelayAgent({
|
|
2847
|
+
relayBaseUrl: options.relayBaseUrl,
|
|
2848
|
+
token: options.token || this.apiKey,
|
|
2849
|
+
agentId,
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
async subscribe(handler, options = {}) {
|
|
2853
|
+
const stableConnectionMs = 10_000;
|
|
2854
|
+
let retryDelay = 250;
|
|
2855
|
+
const waitBeforeReconnect = async () => {
|
|
2856
|
+
let abortRetry = () => { };
|
|
2857
|
+
const aborted = new Promise((resolve) => {
|
|
2858
|
+
if (options.signal?.aborted)
|
|
2859
|
+
resolve();
|
|
2860
|
+
else {
|
|
2861
|
+
abortRetry = resolve;
|
|
2862
|
+
options.signal?.addEventListener('abort', abortRetry, { once: true });
|
|
2863
|
+
}
|
|
2864
|
+
});
|
|
2865
|
+
try {
|
|
2866
|
+
await Promise.race([sleep(retryDelay), aborted]);
|
|
2867
|
+
}
|
|
2868
|
+
finally {
|
|
2869
|
+
options.signal?.removeEventListener('abort', abortRetry);
|
|
2870
|
+
}
|
|
2871
|
+
retryDelay = Math.min(retryDelay * 2, 5_000);
|
|
2872
|
+
};
|
|
2873
|
+
while (!options.signal?.aborted) {
|
|
2874
|
+
try {
|
|
2875
|
+
const token = validateDeploymentEventToken(await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/events/token`, undefined, { signal: options.signal }));
|
|
2876
|
+
const eventUrl = new URL(token.ws_url);
|
|
2877
|
+
eventUrl.searchParams.set('token', token.token);
|
|
2878
|
+
const WebSocketImpl = preferredWebSocket();
|
|
2879
|
+
const ws = new WebSocketImpl(eventUrl.toString());
|
|
2880
|
+
let readyAt = null;
|
|
2881
|
+
let closedAt = null;
|
|
2882
|
+
await new Promise((resolve, reject) => {
|
|
2883
|
+
let opened = false;
|
|
2884
|
+
let ready = false;
|
|
2885
|
+
let processing = Promise.resolve();
|
|
2886
|
+
const readyTimer = setTimeout(() => {
|
|
2887
|
+
ws.close(4002, 'Deployment event ready timed out');
|
|
2888
|
+
reject(new Error('Deployment event ready timed out'));
|
|
2889
|
+
}, 10_000);
|
|
2890
|
+
const abort = () => ws.close(1000, 'Subscription cancelled');
|
|
2891
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
2892
|
+
ws.addEventListener('open', () => { opened = true; });
|
|
2893
|
+
ws.addEventListener('message', (message) => {
|
|
2894
|
+
processing = processing.then(async () => {
|
|
2895
|
+
const frame = JSON.parse(await websocketMessageText(message.data));
|
|
2896
|
+
if (!ready) {
|
|
2897
|
+
if (frame.type !== 'ready') {
|
|
2898
|
+
throw new Error('Deployment event socket did not send ready');
|
|
2899
|
+
}
|
|
2900
|
+
ready = true;
|
|
2901
|
+
readyAt = Date.now();
|
|
2902
|
+
clearTimeout(readyTimer);
|
|
2903
|
+
await options.onReady?.();
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
if (((frame.type === 'deployment.transition' || frame.type === 'deployment.import_status')
|
|
2907
|
+
&& typeof frame.agent_id === 'string'
|
|
2908
|
+
&& frame.agent_id.length > 0) || (frame.type === 'runner.presence'
|
|
2909
|
+
&& typeof frame.runner_id === 'string'
|
|
2910
|
+
&& frame.runner_id.length > 0) || ((frame.type === 'addReaction' || frame.type === 'removeReaction')
|
|
2911
|
+
&& typeof frame.agent_id === 'string' && frame.agent_id.length > 0
|
|
2912
|
+
&& typeof frame.session_id === 'string' && frame.session_id.length > 0
|
|
2913
|
+
&& Number.isSafeInteger(frame.seq) && frame.seq > 0
|
|
2914
|
+
&& typeof frame.message_id === 'string' && frame.message_id.length > 0)) {
|
|
2915
|
+
if (frame.type === 'addReaction' || frame.type === 'removeReaction') {
|
|
2916
|
+
frame.meta = sessionMessageMetaFromWire(frame.meta ?? null, false);
|
|
2917
|
+
}
|
|
2918
|
+
await handler(frame);
|
|
2919
|
+
}
|
|
2920
|
+
}).catch((error) => {
|
|
2921
|
+
ws.close(4002, 'Invalid deployment event');
|
|
2922
|
+
reject(error);
|
|
2923
|
+
});
|
|
2924
|
+
});
|
|
2925
|
+
ws.addEventListener('error', () => reject(new Error('Deployment event WebSocket failed')));
|
|
2926
|
+
ws.addEventListener('close', () => {
|
|
2927
|
+
closedAt = Date.now();
|
|
2928
|
+
clearTimeout(readyTimer);
|
|
2929
|
+
options.signal?.removeEventListener('abort', abort);
|
|
2930
|
+
processing.then(resolve, reject);
|
|
2931
|
+
});
|
|
2932
|
+
if (options.signal?.aborted)
|
|
2933
|
+
abort();
|
|
2934
|
+
void opened;
|
|
2935
|
+
});
|
|
2936
|
+
if (!options.signal?.aborted) {
|
|
2937
|
+
// A `ready` frame followed by an immediate close is not a healthy
|
|
2938
|
+
// stream. Reset only after a useful stable interval so reconnects
|
|
2939
|
+
// and their authoritative REST resyncs retain exponential backoff.
|
|
2940
|
+
if (readyAt !== null
|
|
2941
|
+
&& closedAt !== null
|
|
2942
|
+
&& closedAt - readyAt >= stableConnectionMs) {
|
|
2943
|
+
retryDelay = 250;
|
|
2944
|
+
}
|
|
2945
|
+
await waitBeforeReconnect();
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
catch (error) {
|
|
2949
|
+
if (options.signal?.aborted)
|
|
2950
|
+
break;
|
|
2951
|
+
if (error instanceof TypeError && error.message === 'Invalid session reaction metadata')
|
|
2952
|
+
throw error;
|
|
2953
|
+
if (error instanceof APIError && [401, 403].includes(error.statusCode))
|
|
2954
|
+
throw error;
|
|
2955
|
+
if (error instanceof Error && error.message === 'Backend returned an invalid deployment event token response') {
|
|
2956
|
+
throw error;
|
|
2957
|
+
}
|
|
2958
|
+
await waitBeforeReconnect();
|
|
2959
|
+
void error;
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
async waitForState(agentIdOrName, states, timeoutMs = 300_000, failureStates = [], minimumLaunchEpoch, pollIntervalMs = 5_000) {
|
|
2964
|
+
if (!states.length)
|
|
2965
|
+
throw new Error('states must not be empty');
|
|
2966
|
+
if (minimumLaunchEpoch !== undefined && minimumLaunchEpoch < 0) {
|
|
2967
|
+
throw new Error('minimumLaunchEpoch must be non-negative');
|
|
2968
|
+
}
|
|
2969
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
2970
|
+
const deadline = Date.now() + timeoutMs;
|
|
2971
|
+
let lastState = '';
|
|
2972
|
+
let wakePending = true;
|
|
2973
|
+
let wake = null;
|
|
2974
|
+
const controller = new AbortController();
|
|
2975
|
+
const desired = new Set(states.map((state) => state.toLowerCase()));
|
|
2976
|
+
const failures = new Set(failureStates.map((state) => state.toLowerCase()));
|
|
2977
|
+
const effectivePollIntervalMs = Math.max(1, pollIntervalMs);
|
|
2978
|
+
const stateLabel = states.join(', ');
|
|
2979
|
+
let pendingFailureState = null;
|
|
2980
|
+
const refresh = async (confirmFailure = false) => {
|
|
2981
|
+
const agent = await this.get(agentId);
|
|
2982
|
+
lastState = String(agent.state || '');
|
|
2983
|
+
if (minimumLaunchEpoch !== undefined
|
|
2984
|
+
&& agent.launchEpoch < minimumLaunchEpoch)
|
|
2985
|
+
return null;
|
|
2986
|
+
const normalizedState = lastState.toLowerCase();
|
|
2987
|
+
if (desired.has(normalizedState))
|
|
2988
|
+
return agent;
|
|
2989
|
+
if (failures.has(normalizedState)) {
|
|
2990
|
+
// Accepting a lifecycle request publishes the new launch epoch a beat
|
|
2991
|
+
// before the state leaves the previous terminal value, so a single
|
|
2992
|
+
// terminal read under the accepted epoch is not proof of a terminal
|
|
2993
|
+
// launch. Require it to survive one more observation.
|
|
2994
|
+
if (!confirmFailure && pendingFailureState !== normalizedState) {
|
|
2995
|
+
pendingFailureState = normalizedState;
|
|
2996
|
+
return null;
|
|
2997
|
+
}
|
|
2998
|
+
throw new Error(`Agent entered ${lastState} while waiting for ${stateLabel}`);
|
|
2999
|
+
}
|
|
3000
|
+
pendingFailureState = null;
|
|
3001
|
+
return null;
|
|
3002
|
+
};
|
|
3003
|
+
const subscription = this.subscribe((event) => {
|
|
3004
|
+
if (event.type === 'deployment.transition' && event.agent_id === agentId) {
|
|
3005
|
+
wakePending = true;
|
|
3006
|
+
wake?.();
|
|
3007
|
+
}
|
|
3008
|
+
}, {
|
|
3009
|
+
signal: controller.signal,
|
|
3010
|
+
onReady: () => {
|
|
3011
|
+
wakePending = true;
|
|
3012
|
+
wake?.();
|
|
3013
|
+
},
|
|
3014
|
+
}).catch(() => {
|
|
3015
|
+
// Lifecycle events reduce latency, but REST polling remains authoritative.
|
|
3016
|
+
wakePending = true;
|
|
3017
|
+
wake?.();
|
|
3018
|
+
});
|
|
3019
|
+
try {
|
|
3020
|
+
while (Date.now() < deadline) {
|
|
3021
|
+
if (wakePending) {
|
|
3022
|
+
wakePending = false;
|
|
3023
|
+
const agent = await refresh();
|
|
3024
|
+
if (agent)
|
|
3025
|
+
return agent;
|
|
3026
|
+
continue;
|
|
3027
|
+
}
|
|
3028
|
+
const remaining = deadline - Date.now();
|
|
3029
|
+
if (remaining <= 0)
|
|
3030
|
+
break;
|
|
3031
|
+
await Promise.race([
|
|
3032
|
+
new Promise((resolve) => { wake = resolve; }),
|
|
3033
|
+
sleep(Math.min(remaining, effectivePollIntervalMs)).then(() => {
|
|
3034
|
+
wakePending = true;
|
|
3035
|
+
}),
|
|
3036
|
+
]);
|
|
3037
|
+
wake = null;
|
|
3038
|
+
}
|
|
3039
|
+
const finalAgent = await refresh(true);
|
|
3040
|
+
if (finalAgent)
|
|
3041
|
+
return finalAgent;
|
|
3042
|
+
}
|
|
3043
|
+
finally {
|
|
3044
|
+
controller.abort();
|
|
3045
|
+
await subscription;
|
|
3046
|
+
}
|
|
3047
|
+
throw new Error(`Timed out waiting for agent ${agentId} to reach ${stateLabel} (last=${lastState || 'unknown'})`);
|
|
3048
|
+
}
|
|
3049
|
+
async waitRunning(agentIdOrName, timeoutMs = 300_000, pollIntervalMs = 5_000, minimumLaunchEpoch) {
|
|
3050
|
+
return this.waitForState(agentIdOrName, ['RUNNING'], timeoutMs, ['STOPPED', 'ARCHIVED', 'DELETED', 'FAILED', 'NO_NAMESPACE'], minimumLaunchEpoch, pollIntervalMs);
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Read back every launch secret value the projection refuses to return.
|
|
3054
|
+
*
|
|
3055
|
+
* Agent projections list secret *names* and expose values only through the
|
|
3056
|
+
* per-secret retrieval endpoint, so a complete `secrets` mapping has to be
|
|
3057
|
+
* reassembled one key at a time. Every response is checked against
|
|
3058
|
+
* `launchEpoch` so a rebuild never silently mixes values from an older
|
|
3059
|
+
* launch generation into a new one.
|
|
3060
|
+
*/
|
|
3061
|
+
async recoverRedactedSecrets(agentId, launchEpoch) {
|
|
3062
|
+
const namesData = await this.secretNames(agentId);
|
|
3063
|
+
if (Number(namesData.launch_epoch ?? 0) < launchEpoch) {
|
|
3064
|
+
throw new Error('agent secret names belong to an older launch epoch');
|
|
3065
|
+
}
|
|
3066
|
+
const secrets = {};
|
|
3067
|
+
for (const name of namesData.names ?? []) {
|
|
3068
|
+
const secretData = await this.secret(agentId, String(name));
|
|
3069
|
+
if (Number(secretData.launch_epoch ?? 0) < launchEpoch) {
|
|
3070
|
+
throw new Error('agent secret belongs to an older launch epoch');
|
|
3071
|
+
}
|
|
3072
|
+
secrets[String(name)] = String(secretData.value ?? '');
|
|
3073
|
+
}
|
|
3074
|
+
return secrets;
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Restore the two launch_config keys an Agent projection redacts.
|
|
3078
|
+
*
|
|
3079
|
+
* WHY THIS EXISTS — do not delete it as redundant validation sugar. The
|
|
3080
|
+
* Backend's owner-facing Agent projection deliberately strips `secrets` and
|
|
3081
|
+
* `registry_auth` before returning an Agent to a user-scoped caller
|
|
3082
|
+
* (`hydrate_managed_agent` pops both), and this SDK's own hydrator drops
|
|
3083
|
+
* `secrets` again. A caller that writes launch_config before start still
|
|
3084
|
+
* needs a complete replacement object. Without this step the obvious round
|
|
3085
|
+
* trip can never succeed, because the read side is structurally incapable of
|
|
3086
|
+
* returning what the write side requires:
|
|
3087
|
+
*
|
|
3088
|
+
* ```ts
|
|
3089
|
+
* const agent = await client.deployments.get(agentId);
|
|
3090
|
+
* await client.deployments.update(agentId, { launchConfig: agent.launchConfig });
|
|
3091
|
+
* // Error: launchConfig is incomplete; missing: secrets, registry_auth
|
|
3092
|
+
* ```
|
|
3093
|
+
*
|
|
3094
|
+
* The fix is to complete the object honestly, never to weaken the
|
|
3095
|
+
* completeness contract — launch_config writes are replacements, not merges.
|
|
3096
|
+
*
|
|
3097
|
+
* Only keys that are genuinely ABSENT are rebuilt. A caller-supplied
|
|
3098
|
+
* `secrets` or `registry_auth` is honoured verbatim, including an explicit
|
|
3099
|
+
* empty object, so "redacted by the projection" and "deliberately empty"
|
|
3100
|
+
* remain distinguishable.
|
|
3101
|
+
*
|
|
3102
|
+
* `secrets` is recoverable because values can be read back one name at a
|
|
3103
|
+
* time. `registry_auth` is NOT: it is caller-held, write-only, and never
|
|
3104
|
+
* stored server-side. It therefore falls back to an explicitly supplied
|
|
3105
|
+
* `registryAuth`, then to `{}` only when the configuration pulls from no
|
|
3106
|
+
* `registry_url`; when a registry is configured an empty credential would
|
|
3107
|
+
* silently break the image pull, so the caller is told to supply it instead.
|
|
3108
|
+
*/
|
|
3109
|
+
async rehydrateRedactedLaunchConfig(resolvedAgentId, launchConfig, registryAuth) {
|
|
3110
|
+
if (!isPlainRecord(launchConfig)) {
|
|
3111
|
+
throw new Error('launchConfig must be a complete object');
|
|
3112
|
+
}
|
|
3113
|
+
const absent = REQUIRED_START_LAUNCH_CONFIG_KEYS.filter((key) => !Object.prototype.hasOwnProperty.call(launchConfig, key));
|
|
3114
|
+
// Nothing missing, or missing more than the projection ever redacts: in
|
|
3115
|
+
// both cases hand the object straight to the completeness gatekeeper. Only
|
|
3116
|
+
// a config whose *sole* gaps are the two redacted keys is a projection
|
|
3117
|
+
// round trip worth spending API calls to repair.
|
|
3118
|
+
if (absent.length === 0 || absent.some((key) => key !== 'secrets' && key !== 'registry_auth')) {
|
|
3119
|
+
return launchConfig;
|
|
3120
|
+
}
|
|
3121
|
+
const prepared = structuredClone(launchConfig);
|
|
3122
|
+
if (absent.includes('secrets')) {
|
|
3123
|
+
const agent = await this.getById(resolvedAgentId);
|
|
3124
|
+
prepared.secrets = await this.recoverRedactedSecrets(resolvedAgentId, agent.launchEpoch);
|
|
3125
|
+
}
|
|
3126
|
+
if (absent.includes('registry_auth')) {
|
|
3127
|
+
const registryUrl = String(prepared.registry_url ?? '').trim();
|
|
3128
|
+
if (registryAuth) {
|
|
3129
|
+
prepared.registry_auth = structuredClone(registryAuth);
|
|
3130
|
+
}
|
|
3131
|
+
else if (registryUrl) {
|
|
3132
|
+
throw new Error(`Agent ${resolvedAgentId} pulls from registry_url ${JSON.stringify(registryUrl)} but `
|
|
3133
|
+
+ 'launchConfig carries no registry_auth; registry_auth is caller-held and write-only, '
|
|
3134
|
+
+ 'so the owner-facing projection can never return it and the SDK will not substitute '
|
|
3135
|
+
+ 'an empty credential that would break the private-registry pull — pass registryAuth '
|
|
3136
|
+
+ 'with the launch_config update');
|
|
3137
|
+
}
|
|
3138
|
+
else {
|
|
3139
|
+
prepared.registry_auth = {};
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return prepared;
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Rebuild a complete replacement launch_config from
|
|
3146
|
+
* nothing but the Agent's stored projection.
|
|
3147
|
+
*
|
|
3148
|
+
* This SDK starts stored config without reading it. This method is retained
|
|
3149
|
+
* for callers that want to explicitly update stored launch_config;
|
|
3150
|
+
* it is also the only place that canonicalizes the two legacy projection
|
|
3151
|
+
* shapes the inline repair never sees, because the repair fills absent keys
|
|
3152
|
+
* and touches nothing else:
|
|
3153
|
+
* nullable `restart`, and a projection carrying both or neither sync policy.
|
|
3154
|
+
*
|
|
3155
|
+
* Reads the stored Agent projection, rehydrates redacted secrets through the
|
|
3156
|
+
* per-secret retrieval endpoint, requires caller-held registry_auth whenever
|
|
3157
|
+
* the stored config references a registry_url, normalizes those legacy
|
|
3158
|
+
* shapes, and self-checks through the completeness gatekeeper.
|
|
3159
|
+
*/
|
|
3160
|
+
async storedLaunchConfig(agentIdOrName, options = {}) {
|
|
3161
|
+
const agent = await this.get(agentIdOrName);
|
|
3162
|
+
if (!isPlainRecord(agent.launchConfig)) {
|
|
3163
|
+
throw new Error(`Agent ${agent.id} has no stored launch_config projection`);
|
|
3164
|
+
}
|
|
3165
|
+
const launchConfig = structuredClone(agent.launchConfig);
|
|
3166
|
+
// Legacy projections may still carry the old nullable restart
|
|
3167
|
+
// representation; replacement writes receive one explicit boolean.
|
|
3168
|
+
if ('restart' in launchConfig && launchConfig.restart === null) {
|
|
3169
|
+
launchConfig.restart = false;
|
|
3170
|
+
}
|
|
3171
|
+
launchConfig.secrets = await this.recoverRedactedSecrets(agent.id, agent.launchEpoch);
|
|
3172
|
+
const registryUrl = String(launchConfig.registry_url ?? '').trim();
|
|
3173
|
+
if (registryUrl && !options.registryAuth) {
|
|
3174
|
+
throw new Error(`Agent ${agent.id} pulls from registry_url ${JSON.stringify(registryUrl)}; `
|
|
3175
|
+
+ 'registry_auth is caller-held and never stored server-side, so it must '
|
|
3176
|
+
+ 'be supplied to rebuild a complete launch_config replacement');
|
|
3177
|
+
}
|
|
3178
|
+
launchConfig.registry_auth = options.registryAuth ? structuredClone(options.registryAuth) : {};
|
|
3179
|
+
// Replacement writes require exactly one sync policy. Includes win when a legacy
|
|
3180
|
+
// projection carries both; carrying neither canonicalizes to the
|
|
3181
|
+
// explicit sync-everything exclusion list.
|
|
3182
|
+
if (Object.prototype.hasOwnProperty.call(launchConfig, 'sync_include')) {
|
|
3183
|
+
delete launchConfig.sync_exclude;
|
|
3184
|
+
}
|
|
3185
|
+
else if (!Object.prototype.hasOwnProperty.call(launchConfig, 'sync_exclude')) {
|
|
3186
|
+
launchConfig.sync_exclude = [];
|
|
3187
|
+
}
|
|
3188
|
+
return cloneCompleteLaunchConfig(await this.rehydrateRedactedLaunchConfig(agent.id, launchConfig, options.registryAuth));
|
|
3189
|
+
}
|
|
3190
|
+
async start(agentIdOrName, options) {
|
|
3191
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3192
|
+
const suppliedOptions = options;
|
|
3193
|
+
const mutationOptions = suppliedOptions
|
|
3194
|
+
? ['launchConfig', 'trustedProxies', 'registryAuth'].filter((key) => Object.prototype.hasOwnProperty.call(suppliedOptions, key))
|
|
3195
|
+
: [];
|
|
3196
|
+
if (mutationOptions.length > 0) {
|
|
3197
|
+
throw new Error(`start no longer accepts launch mutation options: ${mutationOptions.join(', ')}; `
|
|
3198
|
+
+ 'update launchConfig before starting');
|
|
3199
|
+
}
|
|
3200
|
+
const body = {};
|
|
3201
|
+
if (options?.dryRun)
|
|
3202
|
+
body.dry_run = true;
|
|
3203
|
+
const data = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/start`, Object.keys(body).length ? body : undefined, { retries: 1 });
|
|
3204
|
+
return this.hydrateAgent(data);
|
|
3205
|
+
}
|
|
3206
|
+
async update(agentIdOrName, options = {}) {
|
|
3207
|
+
// Only fields the backend UpdateAgentRequest accepts (it is extra="forbid"):
|
|
3208
|
+
// name, handle, size, launch_config, runtime, reset_image, ui, integrations.
|
|
3209
|
+
// refresh_from_lagoon/error are rejected.
|
|
3210
|
+
const body = {};
|
|
3211
|
+
if (options.name !== undefined)
|
|
3212
|
+
body.name = options.name;
|
|
3213
|
+
if (options.handle !== undefined)
|
|
3214
|
+
body.handle = options.handle;
|
|
3215
|
+
if (options.size !== undefined)
|
|
3216
|
+
body.size = options.size;
|
|
3217
|
+
if (options.launchConfig !== undefined)
|
|
3218
|
+
body.launch_config = options.launchConfig;
|
|
3219
|
+
if (options.runtime !== undefined)
|
|
3220
|
+
body.runtime = options.runtime;
|
|
3221
|
+
if (options.resetImage !== undefined)
|
|
3222
|
+
body.reset_image = options.resetImage;
|
|
3223
|
+
if (options.ui !== undefined)
|
|
3224
|
+
body.ui = options.ui;
|
|
3225
|
+
if (options.integrations !== undefined)
|
|
3226
|
+
body.integrations = options.integrations;
|
|
3227
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3228
|
+
const data = await this.agentHttp.patch(`${DEPLOYMENTS_API_PREFIX}/${agentId}`, body);
|
|
3229
|
+
return this.hydrateAgent(data);
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Reset a deployment back to this runtime's platform defaults.
|
|
3233
|
+
*
|
|
3234
|
+
* The empty launch_config is intentional: Backend treats `{}` as "clear every
|
|
3235
|
+
* mutable launch setting" while preserving protected launch identity and
|
|
3236
|
+
* retained-storage fields. Runtime-specific files are deleted best-effort so
|
|
3237
|
+
* the image can reseed its own config on the next boot.
|
|
3238
|
+
*/
|
|
3239
|
+
async resetRuntimeDefaults(agentIdOrName, options) {
|
|
3240
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3241
|
+
const resetData = await this.agentHttp.patch(`${DEPLOYMENTS_API_PREFIX}/${agentId}`, {
|
|
3242
|
+
runtime: options.runtime,
|
|
3243
|
+
reset_image: true,
|
|
3244
|
+
launch_config: {},
|
|
3245
|
+
});
|
|
3246
|
+
const droppedLaunchKeys = droppedLaunchConfigKeys(resetData);
|
|
3247
|
+
const family = options.runtime === 'generic' ? undefined : ACP_RUNTIME_TABLE[options.runtime];
|
|
3248
|
+
if (family?.openclawControlUi) {
|
|
3249
|
+
await this.setEnv(agentId, 'OPENCLAW_CONTROL_UI_ALLOWED_ORIGIN', '*');
|
|
3250
|
+
}
|
|
3251
|
+
for (const file of family?.resetFiles ?? []) {
|
|
3252
|
+
await this.fileDelete(agentId, file).catch(() => undefined);
|
|
3253
|
+
}
|
|
3254
|
+
for (const dir of family?.resetDirs ?? []) {
|
|
3255
|
+
// Runner transport forbids recursive deletes; on reef it lands, elsewhere best-effort.
|
|
3256
|
+
await this.fileDelete(agentId, dir, { recursive: true }).catch(() => undefined);
|
|
3257
|
+
}
|
|
3258
|
+
return { agent: await this.get(agentId), droppedLaunchKeys };
|
|
3259
|
+
}
|
|
3260
|
+
async uploadProfileImage(agentId, content, contentType) {
|
|
3261
|
+
const resolvedAgentId = await this.resolveAgentId(agentId);
|
|
3262
|
+
const resolvedContentType = contentType || (content instanceof Blob ? content.type : '') || 'image/png';
|
|
3263
|
+
return this.agentHttp.postRaw(`${DEPLOYMENTS_API_PREFIX}/${resolvedAgentId}/profile-image`, content, resolvedContentType);
|
|
3264
|
+
}
|
|
3265
|
+
async deleteProfileImage(agentId) {
|
|
3266
|
+
const resolvedAgentId = await this.resolveAgentId(agentId);
|
|
3267
|
+
return this.agentHttp.delete(`${DEPLOYMENTS_API_PREFIX}/${resolvedAgentId}/profile-image`);
|
|
3268
|
+
}
|
|
3269
|
+
async resize(agentId, options) {
|
|
3270
|
+
return this.update(agentId, options);
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Request an agent stop.
|
|
3274
|
+
*
|
|
3275
|
+
* The returned agent remains `STOPPING` while runtime cleanup is in
|
|
3276
|
+
* progress. Fetch it again until it becomes `STOPPED` before treating its
|
|
3277
|
+
* deployment slot as released.
|
|
3278
|
+
*
|
|
3279
|
+
* With `dryRun: true` the Backend only validates the request and returns
|
|
3280
|
+
* the current Agent unchanged -- nothing mutates and no stop is started.
|
|
3281
|
+
*/
|
|
3282
|
+
async stop(agentIdOrName, options) {
|
|
3283
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3284
|
+
const body = {};
|
|
3285
|
+
if (options?.dryRun)
|
|
3286
|
+
body.dry_run = true;
|
|
3287
|
+
const data = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/stop`, Object.keys(body).length ? body : undefined, { retries: 1 });
|
|
3288
|
+
return this.hydrateAgent(data);
|
|
3289
|
+
}
|
|
3290
|
+
/**
|
|
3291
|
+
* Archive durable storage without launching the agent.
|
|
3292
|
+
*
|
|
3293
|
+
* With `dryRun: true` the Backend only validates the request and returns
|
|
3294
|
+
* the current Agent unchanged -- nothing mutates and no archive is started.
|
|
3295
|
+
*/
|
|
3296
|
+
async archive(agentIdOrName, options) {
|
|
3297
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3298
|
+
const body = {};
|
|
3299
|
+
if (options?.dryRun)
|
|
3300
|
+
body.dry_run = true;
|
|
3301
|
+
const data = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/archive`, Object.keys(body).length ? body : undefined, { retries: 1 });
|
|
3302
|
+
return this.hydrateAgent(data);
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Restore durable storage. The accepted snapshot is `RESTORING`; completion is `STOPPED`.
|
|
3306
|
+
*
|
|
3307
|
+
* With `dryRun: true` the Backend only validates the request and returns
|
|
3308
|
+
* the current Agent unchanged -- nothing mutates and no restore is started.
|
|
3309
|
+
*/
|
|
3310
|
+
async restore(agentIdOrName, options) {
|
|
3311
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3312
|
+
const body = {};
|
|
3313
|
+
if (options?.dryRun)
|
|
3314
|
+
body.dry_run = true;
|
|
3315
|
+
const data = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/restore`, Object.keys(body).length ? body : undefined, { retries: 1 });
|
|
3316
|
+
return this.hydrateAgent(data);
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* Resolve who the presented credential is, per the Backend
|
|
3320
|
+
* (`GET /deployments/auth/me`).
|
|
3321
|
+
*
|
|
3322
|
+
* Answers the three questions a credential should be able to ask about
|
|
3323
|
+
* itself: which Agent it is (`agentId`, set only for an Agent runtime key),
|
|
3324
|
+
* which account owns it (`userId`, `teamId`, `planId`), and what it may do
|
|
3325
|
+
* (`tags`, `capabilities`). It returns only what the credential already
|
|
3326
|
+
* carries, so it is unscoped and safe for any caller.
|
|
3327
|
+
*
|
|
3328
|
+
* Distinct from the product-wide `client.user.authMe()`: this is the agent
|
|
3329
|
+
* product's own introspection and is the only one that reports `agentId`.
|
|
3330
|
+
*/
|
|
3331
|
+
async accessIdentity(requestOptions = {}) {
|
|
3332
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/auth/me`;
|
|
3333
|
+
const data = Object.keys(requestOptions).length === 0
|
|
3334
|
+
? await this.agentHttp.get(path)
|
|
3335
|
+
: await this.agentHttp.get(path, undefined, requestOptions);
|
|
3336
|
+
return agentAccessIdentityFromData(data);
|
|
3337
|
+
}
|
|
3338
|
+
async getRoutes(agentIdOrName, options = {}) {
|
|
3339
|
+
const agentId = await this.routesTarget(agentIdOrName);
|
|
3340
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}/routes`;
|
|
3341
|
+
const data = Object.keys(options).length > 0
|
|
3342
|
+
? await this.agentHttp.get(path, undefined, options)
|
|
3343
|
+
: await this.agentHttp.get(path);
|
|
3344
|
+
return agentRoutesStateFromData(data);
|
|
3345
|
+
}
|
|
3346
|
+
async setRoutes(agentIdOrName, routes, options = {}) {
|
|
3347
|
+
const agentId = await this.routesTarget(agentIdOrName);
|
|
3348
|
+
const body = {
|
|
3349
|
+
routes: Object.fromEntries(Object.entries(routes).map(([name, route]) => [name, routeConfigBody(route)])),
|
|
3350
|
+
};
|
|
3351
|
+
if (Object.prototype.hasOwnProperty.call(options, 'cors') && options.cors !== undefined) {
|
|
3352
|
+
body.cors = options.cors === null ? null : structuredClone(options.cors);
|
|
3353
|
+
}
|
|
3354
|
+
const data = await this.agentHttp.put(`${DEPLOYMENTS_API_PREFIX}/${agentId}/routes`, body);
|
|
3355
|
+
return agentRoutesStateFromData(data);
|
|
3356
|
+
}
|
|
3357
|
+
async setRoute(agentIdOrName, name, route) {
|
|
3358
|
+
const agentId = await this.routesTarget(agentIdOrName);
|
|
3359
|
+
const body = routeConfigBody(route);
|
|
3360
|
+
const data = await this.agentHttp.put(`${DEPLOYMENTS_API_PREFIX}/${agentId}/routes/${encodeURIComponent(name)}`, body);
|
|
3361
|
+
return agentRoutesStateFromData(data);
|
|
3362
|
+
}
|
|
3363
|
+
async removeRoute(agentIdOrName, name) {
|
|
3364
|
+
const agentId = await this.routesTarget(agentIdOrName);
|
|
3365
|
+
const data = await this.agentHttp.delete(`${DEPLOYMENTS_API_PREFIX}/${agentId}/routes/${encodeURIComponent(name)}`);
|
|
3366
|
+
return agentRoutesStateFromData(data);
|
|
3367
|
+
}
|
|
3368
|
+
/**
|
|
3369
|
+
* Delete a deployment.
|
|
3370
|
+
*
|
|
3371
|
+
* With `dryRun: true` the Backend only validates the request and returns
|
|
3372
|
+
* the current Agent record unchanged -- nothing mutates and the deployment
|
|
3373
|
+
* is not deleted.
|
|
3374
|
+
*/
|
|
3375
|
+
async delete(agentIdOrName, options) {
|
|
3376
|
+
// HTTP 200 accepts the durable soft delete. Cluster-local cleanup continues
|
|
3377
|
+
// in the background and is not proven complete by this response.
|
|
3378
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3379
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}`;
|
|
3380
|
+
const result = options?.dryRun
|
|
3381
|
+
? await this.agentHttp.delete(path, { dry_run: true })
|
|
3382
|
+
: await this.agentHttp.delete(path);
|
|
3383
|
+
return result;
|
|
3384
|
+
}
|
|
3385
|
+
async refreshToken(agentIdOrName) {
|
|
3386
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3387
|
+
return this.agentHttp.get(`${DEPLOYMENTS_API_PREFIX}/${agentId}/token`);
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Signed URL for the agent's VNC desktop. Requires a running agent with the
|
|
3391
|
+
* `desktop` route enabled; throws otherwise.
|
|
3392
|
+
*/
|
|
3393
|
+
async desktopUrl(agentIdOrName, options = {}) {
|
|
3394
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3395
|
+
const [token, agent] = await Promise.all([
|
|
3396
|
+
this.refreshToken(agentId),
|
|
3397
|
+
this.getById(agentId),
|
|
3398
|
+
]);
|
|
3399
|
+
const jwt = (token.token ?? '').trim();
|
|
3400
|
+
if (!jwt)
|
|
3401
|
+
throw new Error('Desktop token is missing');
|
|
3402
|
+
if (!agent.isRunning)
|
|
3403
|
+
throw new Error('Start the agent to open its desktop');
|
|
3404
|
+
const base = agent.desktopUrl;
|
|
3405
|
+
if (!base)
|
|
3406
|
+
throw new Error('Desktop route is not enabled for this agent');
|
|
3407
|
+
const url = buildBrowserDesktopUrl(base, jwt, {
|
|
3408
|
+
redirect: 'vnc_lite.html',
|
|
3409
|
+
resize: 'scale',
|
|
3410
|
+
...options,
|
|
3411
|
+
});
|
|
3412
|
+
return { url, expiresAt: parseDate(token.expires_at), viewport: token.desktop_viewport ?? null };
|
|
3413
|
+
}
|
|
3414
|
+
async createScopedKey(agentIdOrName, name) {
|
|
3415
|
+
const payload = {};
|
|
3416
|
+
if (name)
|
|
3417
|
+
payload.name = name;
|
|
3418
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3419
|
+
return this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/keys`, Object.keys(payload).length ? payload : undefined);
|
|
3420
|
+
}
|
|
3421
|
+
async webSearch(query, options = {}) {
|
|
3422
|
+
const params = {
|
|
3423
|
+
q: query,
|
|
3424
|
+
count: options.count ?? 5,
|
|
3425
|
+
};
|
|
3426
|
+
if (options.country)
|
|
3427
|
+
params.country = options.country;
|
|
3428
|
+
if (options.searchLang)
|
|
3429
|
+
params.search_lang = options.searchLang;
|
|
3430
|
+
if (options.uiLang)
|
|
3431
|
+
params.ui_lang = options.uiLang;
|
|
3432
|
+
if (options.freshness)
|
|
3433
|
+
params.freshness = options.freshness;
|
|
3434
|
+
const headers = { 'X-Subscription-Token': this.apiKey };
|
|
3435
|
+
if (this.agentHttp instanceof HTTPClient) {
|
|
3436
|
+
return this.agentHttp.getWithHeaders('/brave/res/v1/web/search', params, headers);
|
|
3437
|
+
}
|
|
3438
|
+
const response = await this.fetchRaw(`/brave/res/v1/web/search?${new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)]))).toString()}`, { headers });
|
|
3439
|
+
return (await response.json());
|
|
3440
|
+
}
|
|
3441
|
+
async logsToken(agentIdOrName) {
|
|
3442
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3443
|
+
return validateAgentLogsToken(await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/logs/token`));
|
|
3444
|
+
}
|
|
3445
|
+
async env(agentIdOrName, requestOptions = {}) {
|
|
3446
|
+
const agentId = await this.resolveAgentId(agentIdOrName, requestOptions);
|
|
3447
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}/env`;
|
|
3448
|
+
return Object.keys(requestOptions).length === 0
|
|
3449
|
+
? this.agentHttp.get(path)
|
|
3450
|
+
: this.agentHttp.get(path, undefined, requestOptions);
|
|
3451
|
+
}
|
|
3452
|
+
async setEnv(agentIdOrName, key, value) {
|
|
3453
|
+
if (!key)
|
|
3454
|
+
throw new Error('env key is required');
|
|
3455
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3456
|
+
return this.agentHttp.patch(`${DEPLOYMENTS_API_PREFIX}/${agentId}/env/${encodeURIComponent(key)}`, { value });
|
|
3457
|
+
}
|
|
3458
|
+
async deleteEnv(agentIdOrName, key) {
|
|
3459
|
+
if (!key)
|
|
3460
|
+
throw new Error('env key is required');
|
|
3461
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3462
|
+
return this.agentHttp.delete(`${DEPLOYMENTS_API_PREFIX}/${agentId}/env/${encodeURIComponent(key)}`);
|
|
3463
|
+
}
|
|
3464
|
+
async secretNames(agentIdOrName, requestOptions = {}) {
|
|
3465
|
+
const agentId = await this.resolveAgentId(agentIdOrName, requestOptions);
|
|
3466
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}/secrets`;
|
|
3467
|
+
return Object.keys(requestOptions).length === 0
|
|
3468
|
+
? this.agentHttp.get(path)
|
|
3469
|
+
: this.agentHttp.get(path, undefined, requestOptions);
|
|
3470
|
+
}
|
|
3471
|
+
async secret(agentIdOrName, key, requestOptions = {}) {
|
|
3472
|
+
const agentId = await this.resolveAgentId(agentIdOrName, requestOptions);
|
|
3473
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}/secrets/${encodeURIComponent(key)}`;
|
|
3474
|
+
return Object.keys(requestOptions).length === 0
|
|
3475
|
+
? this.agentHttp.get(path)
|
|
3476
|
+
: this.agentHttp.get(path, undefined, requestOptions);
|
|
3477
|
+
}
|
|
3478
|
+
async setSecret(agentIdOrName, key, value) {
|
|
3479
|
+
if (!key)
|
|
3480
|
+
throw new Error('secret key is required');
|
|
3481
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3482
|
+
const result = await this.agentHttp.patch(`${DEPLOYMENTS_API_PREFIX}/${agentId}/secrets/${encodeURIComponent(key)}`, { value });
|
|
3483
|
+
return result;
|
|
3484
|
+
}
|
|
3485
|
+
async deleteSecret(agentIdOrName, key) {
|
|
3486
|
+
if (!key)
|
|
3487
|
+
throw new Error('secret key is required');
|
|
3488
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3489
|
+
const result = await this.agentHttp.delete(`${DEPLOYMENTS_API_PREFIX}/${agentId}/secrets/${encodeURIComponent(key)}`);
|
|
3490
|
+
return result;
|
|
3491
|
+
}
|
|
3492
|
+
async exec(target, command, options = {}) {
|
|
3493
|
+
if (!Array.isArray(command)
|
|
3494
|
+
|| command.length < 1
|
|
3495
|
+
|| command.some((argument) => typeof argument !== 'string')
|
|
3496
|
+
|| command[0].length === 0
|
|
3497
|
+
|| command.some((argument) => argument.includes('\0'))
|
|
3498
|
+
|| command.reduce((size, argument) => size + encodeUtf8(argument).byteLength, 0) > 65_536) {
|
|
3499
|
+
throw new Error('command must be a nonempty argv list of strings with a nonempty executable, at most 65536 UTF-8 bytes, and no NUL');
|
|
3500
|
+
}
|
|
3501
|
+
command = [...command];
|
|
3502
|
+
const timeout = options.timeout ?? 30;
|
|
3503
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300) {
|
|
3504
|
+
throw new Error('timeout must be an integer from 1 through 300');
|
|
3505
|
+
}
|
|
3506
|
+
let stdin;
|
|
3507
|
+
if (options.stdin !== undefined) {
|
|
3508
|
+
const bytes = typeof options.stdin === 'string'
|
|
3509
|
+
? encodeUtf8(options.stdin)
|
|
3510
|
+
: options.stdin;
|
|
3511
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength > AGENT_EXEC_STDIN_MAX_BYTES) {
|
|
3512
|
+
throw new Error(`stdin must be a string or Uint8Array of at most ${AGENT_EXEC_STDIN_MAX_BYTES} bytes`);
|
|
3513
|
+
}
|
|
3514
|
+
stdin = encodeBase64(bytes);
|
|
3515
|
+
}
|
|
3516
|
+
const agentId = await this.agentIdFor(target);
|
|
3517
|
+
const payload = {
|
|
3518
|
+
command,
|
|
3519
|
+
timeout,
|
|
3520
|
+
dry_run: options.dryRun ?? false,
|
|
3521
|
+
...(stdin !== undefined ? { stdin } : {}),
|
|
3522
|
+
};
|
|
3523
|
+
return validateAgentExecResult(await this.oneShotAgentWebSocket(agentId, 'exec', payload, (timeout + 10) * 1_000));
|
|
3524
|
+
}
|
|
3525
|
+
/**
|
|
3526
|
+
* Wait until the Agent file API is serving. Native assignments use a bounded
|
|
3527
|
+
* file read; hosted assignments use the Reef readiness checks below.
|
|
3528
|
+
*
|
|
3529
|
+
* Probing the Agent hostname alone cannot answer this. The Agent domain is a
|
|
3530
|
+
* wildcard, so a host with no route still resolves and the edge answers a
|
|
3531
|
+
* plain-text `404 page not found` — byte for byte what a route that has not
|
|
3532
|
+
* converged yet returns. A caller polling the hostname therefore cannot tell
|
|
3533
|
+
* "not ready" from "never will be", and will retry until its deadline against
|
|
3534
|
+
* a host that was never going to work.
|
|
3535
|
+
*
|
|
3536
|
+
* So ask the API for the authoritative Agent state first: a deleted or failed
|
|
3537
|
+
* Agent rejects immediately with that state rather than timing out. Then
|
|
3538
|
+
* require consecutive successful reads, because one success only proves the
|
|
3539
|
+
* route answered once — the next request can still 404 while the edge settles.
|
|
3540
|
+
*/
|
|
3541
|
+
async waitForFileApiReady(target, options = {}) {
|
|
3542
|
+
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
3543
|
+
const consecutive = options.consecutive ?? 2;
|
|
3544
|
+
const pollMs = options.pollMs ?? 1_000;
|
|
3545
|
+
const agentId = await this.agentIdFor(target);
|
|
3546
|
+
const deadline = Date.now() + timeoutMs;
|
|
3547
|
+
let streak = 0;
|
|
3548
|
+
let lastError = null;
|
|
3549
|
+
let lastState = '';
|
|
3550
|
+
for (;;) {
|
|
3551
|
+
const agent = await this.get(agentId);
|
|
3552
|
+
lastState = String(agent.state ?? '').toUpperCase();
|
|
3553
|
+
if (lastState === 'DELETED' || lastState === 'FAILED' || lastState === 'NO_NAMESPACE') {
|
|
3554
|
+
throw new Error(`Agent ${agentId} is ${lastState}; its Reef file API will not serve. Waiting longer cannot help.`);
|
|
3555
|
+
}
|
|
3556
|
+
try {
|
|
3557
|
+
await this.filesList(agentId, '');
|
|
3558
|
+
streak += 1;
|
|
3559
|
+
if (streak >= consecutive)
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
catch (error) {
|
|
3563
|
+
if (error instanceof APIError && error.statusCode === 501)
|
|
3564
|
+
throw error;
|
|
3565
|
+
lastError = error;
|
|
3566
|
+
streak = 0;
|
|
3567
|
+
}
|
|
3568
|
+
if (Date.now() >= deadline) {
|
|
3569
|
+
throw new Error(`Agent ${agentId} Reef file API did not serve ${consecutive} consecutive reads within ` +
|
|
3570
|
+
`${Math.round(timeoutMs / 1000)}s (agent state=${lastState || 'unknown'}, last error=${String(lastError)})`);
|
|
3571
|
+
}
|
|
3572
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
async filesList(target, path = '') {
|
|
3576
|
+
const resolvedPath = resolveSyncRootFilePath(path);
|
|
3577
|
+
const agentId = await this.agentIdFor(target);
|
|
3578
|
+
const access = await this.fileAccess(agentId);
|
|
3579
|
+
let payload;
|
|
3580
|
+
if ('transport' in access) {
|
|
3581
|
+
payload = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/files/list`, { path: resolvedPath ? nativeFilePath(resolvedPath) : '' }, { redirect: 'error' });
|
|
3582
|
+
}
|
|
3583
|
+
else {
|
|
3584
|
+
const suffix = resolvedPath ? `/${encodeFilePath(resolvedPath)}` : '';
|
|
3585
|
+
const response = await this.fetchReef(access, `/directories${suffix}`);
|
|
3586
|
+
payload = (await response.json());
|
|
3587
|
+
}
|
|
3588
|
+
if (!isDirectoryListingPayload(payload)) {
|
|
3589
|
+
throw new Error('Reef returned an invalid directory listing');
|
|
3590
|
+
}
|
|
3591
|
+
return [...(payload.directories ?? []), ...(payload.files ?? [])];
|
|
3592
|
+
}
|
|
3593
|
+
async fileReadBytesWithMetadata(target, path, options) {
|
|
3594
|
+
// Preserve absolute runtime addresses for the existing runner read command.
|
|
3595
|
+
// Relative callers retain workspace semantics; hosted reads remain sync-root relative.
|
|
3596
|
+
const absolute = path.startsWith('/') || /^[a-z]:[\\/]/i.test(path) || path.startsWith('\\\\');
|
|
3597
|
+
const resolvedPath = absolute ? path : resolveSyncRootFilePath(path);
|
|
3598
|
+
if (!resolvedPath)
|
|
3599
|
+
throw new Error('agent file path is required');
|
|
3600
|
+
const agentId = await this.agentIdFor(target);
|
|
3601
|
+
const access = await this.fileAccess(agentId, options?.signal);
|
|
3602
|
+
if ('transport' in access) {
|
|
3603
|
+
const maxBytes = Math.min(options?.maxBytes ?? access.maxBytes, access.maxBytes);
|
|
3604
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 0)
|
|
3605
|
+
throw new Error('maxBytes must be a nonnegative integer');
|
|
3606
|
+
const query = new URLSearchParams({ path: absolute ? resolvedPath : nativeFilePath(resolvedPath), max_bytes: String(maxBytes) });
|
|
3607
|
+
return this.agentHttp.getStream(`${DEPLOYMENTS_API_PREFIX}/${agentId}/files/stream?${query}`, async (response) => {
|
|
3608
|
+
const content = await readResponseBytes(response, path, maxBytes);
|
|
3609
|
+
const length = response.headers.get('content-length');
|
|
3610
|
+
if (length === null || Number(length) !== content.byteLength)
|
|
3611
|
+
throw new Error('Incomplete runner file stream');
|
|
3612
|
+
return { content };
|
|
3613
|
+
}, { signal: options?.signal });
|
|
3614
|
+
}
|
|
3615
|
+
if (absolute)
|
|
3616
|
+
resolveSyncRootFilePath(path); // Hosted transport has no runtime-absolute address space.
|
|
3617
|
+
const response = await this.fetchReef(access, `/files/${encodeFilePath(resolvedPath)}`, {
|
|
3618
|
+
signal: options?.signal,
|
|
3619
|
+
});
|
|
3620
|
+
const maxBytes = options?.maxBytes === undefined
|
|
3621
|
+
? AGENT_FILE_MAX_BYTES
|
|
3622
|
+
: Math.min(options.maxBytes, AGENT_FILE_MAX_BYTES);
|
|
3623
|
+
const bytes = await readResponseBytes(response, path, maxBytes);
|
|
3624
|
+
const contentType = response.headers.get('content-type') || '';
|
|
3625
|
+
return { content: bytes, mimeType: contentType || undefined };
|
|
3626
|
+
}
|
|
3627
|
+
async fileReadBytes(target, path, options) {
|
|
3628
|
+
return (await this.fileReadBytesWithMetadata(target, path, options)).content;
|
|
3629
|
+
}
|
|
3630
|
+
async fileRead(target, path, options) {
|
|
3631
|
+
// ignoreBOM means treat the BOM as content, matching Python/Rust UTF-8 readers.
|
|
3632
|
+
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(await this.fileReadBytes(target, path, options));
|
|
3633
|
+
}
|
|
3634
|
+
/**
|
|
3635
|
+
* Write bytes to a relative path through Reef or the native runner transport.
|
|
3636
|
+
*
|
|
3637
|
+
* Per-file writes are limited to 100 MiB (`AGENT_FILE_WRITE_MAX_BYTES`,
|
|
3638
|
+
* the Cloudflare edge request-body cap on the agent hostname). Larger data
|
|
3639
|
+
* should be split across files or synced via the agent's own tooling.
|
|
3640
|
+
* Native runner files are limited to 100 MiB under the retained assignment root.
|
|
3641
|
+
*/
|
|
3642
|
+
async fileWriteBytes(target, path, content, options = {}) {
|
|
3643
|
+
path = normalizeWritableBackendFilePath(path);
|
|
3644
|
+
if (!path)
|
|
3645
|
+
throw new Error('agent file path is required');
|
|
3646
|
+
const encodedPath = encodeFilePath(path);
|
|
3647
|
+
const bytes = toUint8Array(content);
|
|
3648
|
+
if (bytes.byteLength > AGENT_FILE_WRITE_MAX_BYTES) {
|
|
3649
|
+
throw new Error(`Agent file writes are limited to ${AGENT_FILE_WRITE_MAX_BYTES / 1024 / 1024} MiB `
|
|
3650
|
+
+ '(Cloudflare request-body cap on the agent hostname); '
|
|
3651
|
+
+ 'split larger data or sync it via the agent\'s own tooling');
|
|
3652
|
+
}
|
|
3653
|
+
const agentId = await this.agentIdFor(target);
|
|
3654
|
+
const access = await this.fileAccess(agentId, options.signal);
|
|
3655
|
+
if ('transport' in access) {
|
|
3656
|
+
if (bytes.byteLength > access.maxBytes)
|
|
3657
|
+
throw new Error(`Runner files are limited to ${access.maxBytes} bytes by this backend`);
|
|
3658
|
+
const runnerPath = nativeFilePath(path);
|
|
3659
|
+
const transferId = crypto.randomUUID();
|
|
3660
|
+
let offset = 0;
|
|
3661
|
+
const command = async (action, body = new Uint8Array(0), signal = options.signal) => {
|
|
3662
|
+
const query = new URLSearchParams({ transfer_id: transferId, action, total: String(bytes.byteLength),
|
|
3663
|
+
offset: String(offset), path: action === 'open_write' ? runnerPath : '' });
|
|
3664
|
+
const receipt = await this.agentHttp.postRaw(`${DEPLOYMENTS_API_PREFIX}/${agentId}/files/transfer?${query}`, body, 'application/octet-stream', { signal, retries: 1, redirect: 'error', ...(action === 'abort' ? { timeout: 1000 } : {}) });
|
|
3665
|
+
const expected = offset + body.byteLength;
|
|
3666
|
+
if (receipt.ok !== true || receipt.transfer_id !== transferId || receipt.offset !== expected || receipt.total !== bytes.byteLength) {
|
|
3667
|
+
throw new Error('Invalid runner file receipt');
|
|
3668
|
+
}
|
|
3669
|
+
offset = expected;
|
|
3670
|
+
return receipt;
|
|
3671
|
+
};
|
|
3672
|
+
try {
|
|
3673
|
+
await command('open_write');
|
|
3674
|
+
while (offset < bytes.byteLength) {
|
|
3675
|
+
await command('write', bytes.subarray(offset, Math.min(offset + access.chunkBytes, bytes.byteLength)));
|
|
3676
|
+
}
|
|
3677
|
+
await command('commit');
|
|
3678
|
+
return { ok: true };
|
|
3679
|
+
}
|
|
3680
|
+
catch (error) {
|
|
3681
|
+
// Abort is cleanup, never replay of an uncertain write or commit.
|
|
3682
|
+
await command('abort', new Uint8Array(0), new AbortController().signal).catch(() => undefined);
|
|
3683
|
+
throw error;
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
const response = await this.fetchReef(access, `/files/${encodedPath}`, {
|
|
3687
|
+
method: 'PUT',
|
|
3688
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
3689
|
+
body: bytes,
|
|
3690
|
+
});
|
|
3691
|
+
return (await response.json());
|
|
3692
|
+
}
|
|
3693
|
+
/**
|
|
3694
|
+
* Write a UTF-8 text file to an agent.
|
|
3695
|
+
*
|
|
3696
|
+
* Subject to the 100 MiB per-file write limit; see `fileWriteBytes`.
|
|
3697
|
+
*/
|
|
3698
|
+
async fileWrite(target, path, content) {
|
|
3699
|
+
return this.fileWriteBytes(target, path, content);
|
|
3700
|
+
}
|
|
3701
|
+
async fileDelete(target, path, options = {}) {
|
|
3702
|
+
path = normalizeWritableBackendFilePath(path);
|
|
3703
|
+
if (!path)
|
|
3704
|
+
throw new Error('agent file path is required');
|
|
3705
|
+
const encodedPath = encodeFilePath(path);
|
|
3706
|
+
const params = new URLSearchParams();
|
|
3707
|
+
if (options.recursive)
|
|
3708
|
+
params.set('recursive', 'true');
|
|
3709
|
+
const suffix = params.toString() ? `?${params.toString()}` : '';
|
|
3710
|
+
const agentId = await this.agentIdFor(target);
|
|
3711
|
+
const access = await this.fileAccess(agentId);
|
|
3712
|
+
if ('transport' in access) {
|
|
3713
|
+
if (options.recursive)
|
|
3714
|
+
throw new Error('Runner file deletion is never recursive');
|
|
3715
|
+
// Backend POST performs exactly one attempt; an uncertain delete is never replayed.
|
|
3716
|
+
const receipt = await this.agentHttp.post(`${DEPLOYMENTS_API_PREFIX}/${agentId}/files/delete`, { path: nativeFilePath(path) }, { redirect: 'error' });
|
|
3717
|
+
if (receipt?.status !== 'deleted' || receipt?.path !== path)
|
|
3718
|
+
throw new Error('Invalid runner file receipt');
|
|
3719
|
+
return receipt;
|
|
3720
|
+
}
|
|
3721
|
+
const response = await this.fetchReef(access, `/files/${encodedPath}${suffix}`, { method: 'DELETE' });
|
|
3722
|
+
return (await response.json());
|
|
3723
|
+
}
|
|
3724
|
+
async cpTo(target, localPath, remotePath) {
|
|
3725
|
+
const fs = await getFsPromises();
|
|
3726
|
+
const content = await fs.readFile(localPath);
|
|
3727
|
+
return this.fileWriteBytes(target, remotePath, new Uint8Array(content));
|
|
3728
|
+
}
|
|
3729
|
+
async cpFrom(target, remotePath, localPath) {
|
|
3730
|
+
const fs = await getFsPromises();
|
|
3731
|
+
const content = await this.fileReadBytes(target, remotePath);
|
|
3732
|
+
const destination = new URL(`file://${localPath}`).pathname;
|
|
3733
|
+
const parts = destination.split('/');
|
|
3734
|
+
parts.pop();
|
|
3735
|
+
const parent = parts.join('/') || '/';
|
|
3736
|
+
await fs.mkdir(parent, { recursive: true });
|
|
3737
|
+
await fs.writeFile(destination, content);
|
|
3738
|
+
return destination;
|
|
3739
|
+
}
|
|
3740
|
+
/** One bounded display/capture read. All-unavailable capture errors retain their metadata. */
|
|
3741
|
+
async getLogs(agentIdOrName, options = {}) {
|
|
3742
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3743
|
+
try {
|
|
3744
|
+
return await this.agentHttp.get(`${DEPLOYMENTS_API_PREFIX}/${agentId}/logs`, agentLogQuery(options));
|
|
3745
|
+
}
|
|
3746
|
+
catch (error) {
|
|
3747
|
+
if (error instanceof APIError) {
|
|
3748
|
+
let snapshot;
|
|
3749
|
+
try {
|
|
3750
|
+
snapshot = JSON.parse(error.detail);
|
|
3751
|
+
}
|
|
3752
|
+
catch { /* Ordinary HTTP error. */ }
|
|
3753
|
+
if (isCapturedLogsSnapshot(snapshot))
|
|
3754
|
+
throw new AgentCapturedLogsError(error, snapshot);
|
|
3755
|
+
}
|
|
3756
|
+
throw error;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
async logsConnect(agentIdOrName, options = {}) {
|
|
3760
|
+
return this.connectLogs(agentIdOrName, options);
|
|
3761
|
+
}
|
|
3762
|
+
async connectLogs(agentIdOrName, options, onConnected) {
|
|
3763
|
+
options.signal?.throwIfAborted();
|
|
3764
|
+
const agentId = await this.resolveAgentId(agentIdOrName);
|
|
3765
|
+
const tokenData = await this.logsToken(agentId);
|
|
3766
|
+
options.signal?.throwIfAborted();
|
|
3767
|
+
const container = options.container ?? 'reef';
|
|
3768
|
+
const tailLines = options.tailLines ?? 100;
|
|
3769
|
+
const wsUrl = `${this.agentsWsUrl}/logs/${agentId}` +
|
|
3770
|
+
`?token=${encodeURIComponent(tokenData.token)}` +
|
|
3771
|
+
`&container=${encodeURIComponent(container)}` +
|
|
3772
|
+
`&tail_lines=${encodeURIComponent(String(tailLines))}`;
|
|
3773
|
+
const url = new URL(wsUrl);
|
|
3774
|
+
for (const [name, value] of Object.entries(agentLogQuery(options)))
|
|
3775
|
+
url.searchParams.set(name, String(value));
|
|
3776
|
+
const Socket = preferredWebSocket();
|
|
3777
|
+
const ws = new Socket(url.toString());
|
|
3778
|
+
return await new Promise((resolve, reject) => {
|
|
3779
|
+
let settled = false;
|
|
3780
|
+
const cleanup = () => {
|
|
3781
|
+
clearTimeout(timer);
|
|
3782
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
3783
|
+
ws.onopen = null;
|
|
3784
|
+
ws.onerror = null;
|
|
3785
|
+
ws.onclose = null;
|
|
3786
|
+
};
|
|
3787
|
+
const fail = (error) => {
|
|
3788
|
+
if (settled)
|
|
3789
|
+
return;
|
|
3790
|
+
settled = true;
|
|
3791
|
+
cleanup();
|
|
3792
|
+
// Node ws emits an error when close aborts an unfinished handshake.
|
|
3793
|
+
// Keep that owned teardown error handled until the socket closes.
|
|
3794
|
+
ws.onerror = () => { };
|
|
3795
|
+
ws.onclose = () => { ws.onerror = null; ws.onclose = null; };
|
|
3796
|
+
try {
|
|
3797
|
+
ws.close();
|
|
3798
|
+
}
|
|
3799
|
+
catch { /* Already closed. */ }
|
|
3800
|
+
reject(error);
|
|
3801
|
+
};
|
|
3802
|
+
const onAbort = () => fail(new Error('Agent logs connection aborted'));
|
|
3803
|
+
const timer = setTimeout(() => fail(new Error('Agent logs connection timed out')), 30_000);
|
|
3804
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
3805
|
+
ws.onopen = () => {
|
|
3806
|
+
if (settled)
|
|
3807
|
+
return;
|
|
3808
|
+
cleanup();
|
|
3809
|
+
try {
|
|
3810
|
+
// Install subscription handlers in the open callback, before the first
|
|
3811
|
+
// backend snapshot can arrive (not after an await in subscribeLogs).
|
|
3812
|
+
onConnected?.(ws);
|
|
3813
|
+
settled = true;
|
|
3814
|
+
resolve(ws);
|
|
3815
|
+
}
|
|
3816
|
+
catch (error) {
|
|
3817
|
+
fail(error instanceof Error ? error : new Error('Agent logs setup failed'));
|
|
3818
|
+
}
|
|
3819
|
+
};
|
|
3820
|
+
ws.onerror = () => fail(new Error('WebSocket connection failed'));
|
|
3821
|
+
ws.onclose = (event) => fail(new Error(`Agent logs connection closed (${event.code ?? 1006})`));
|
|
3822
|
+
if (options.signal?.aborted)
|
|
3823
|
+
onAbort();
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Cluster logs replay history then stream lines. Runner logs instead deliver
|
|
3828
|
+
* bounded replacement snapshots to onCapturedLogs, without history_end.
|
|
3829
|
+
*
|
|
3830
|
+
* One mechanism rather than a REST read plus a separate subscribe. The two-step
|
|
3831
|
+
* form leaves a seam between the two calls that no server change can close,
|
|
3832
|
+
* because the fetch and the subscribe share no lock; the backend takes the
|
|
3833
|
+
* history snapshot and registers the subscriber under a single lock, so a line
|
|
3834
|
+
* arriving mid-replay is delivered exactly once.
|
|
3835
|
+
*
|
|
3836
|
+
* Deliberately does not reconnect. A reconnect replays history again, which
|
|
3837
|
+
* would duplicate lines into a consumer that has already rendered them;
|
|
3838
|
+
* reconnect policy belongs to the caller, which knows whether it is resuming
|
|
3839
|
+
* or restarting the view.
|
|
3840
|
+
*/
|
|
3841
|
+
async subscribeLogs(agentIdOrName, handler, options = {}) {
|
|
3842
|
+
const follow = options.follow ?? true;
|
|
3843
|
+
return await new Promise((resolve, reject) => {
|
|
3844
|
+
void this.connectLogs(agentIdOrName, options, (ws) => {
|
|
3845
|
+
let settled = false;
|
|
3846
|
+
const finish = (error) => {
|
|
3847
|
+
if (settled)
|
|
3848
|
+
return;
|
|
3849
|
+
settled = true;
|
|
3850
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
3851
|
+
ws.onmessage = null;
|
|
3852
|
+
ws.onclose = null;
|
|
3853
|
+
ws.onerror = null;
|
|
3854
|
+
try {
|
|
3855
|
+
ws.close();
|
|
3856
|
+
}
|
|
3857
|
+
catch { /* Already closed by the peer. */ }
|
|
3858
|
+
if (error)
|
|
3859
|
+
reject(error);
|
|
3860
|
+
else
|
|
3861
|
+
resolve();
|
|
3862
|
+
};
|
|
3863
|
+
function onAbort() { finish(); }
|
|
3864
|
+
if (options.signal?.aborted) {
|
|
3865
|
+
finish();
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
3869
|
+
let queue = Promise.resolve();
|
|
3870
|
+
const enqueue = (callback) => {
|
|
3871
|
+
queue = queue.then(() => { if (!settled)
|
|
3872
|
+
return callback(); }).catch(error => {
|
|
3873
|
+
finish(error instanceof Error ? error : new Error('Agent logs callback failed'));
|
|
3874
|
+
});
|
|
3875
|
+
};
|
|
3876
|
+
enqueue(() => options.onReady?.());
|
|
3877
|
+
ws.onmessage = (event) => {
|
|
3878
|
+
const raw = typeof event.data === 'string' ? event.data : Buffer.isBuffer(event.data) ? event.data.toString() : '';
|
|
3879
|
+
if (!raw)
|
|
3880
|
+
return;
|
|
3881
|
+
const frame = parseAgentLogFrame(raw);
|
|
3882
|
+
enqueue(async () => {
|
|
3883
|
+
switch (frame.kind) {
|
|
3884
|
+
case 'log':
|
|
3885
|
+
await handler(frame.line);
|
|
3886
|
+
return;
|
|
3887
|
+
case 'capturedLogs':
|
|
3888
|
+
if (!options.onCapturedLogs)
|
|
3889
|
+
throw new Error('Runner log snapshots require an onCapturedLogs replacement handler');
|
|
3890
|
+
await options.onCapturedLogs(frame.snapshot);
|
|
3891
|
+
if (!follow)
|
|
3892
|
+
finish();
|
|
3893
|
+
return;
|
|
3894
|
+
case 'historyEnd':
|
|
3895
|
+
await options.onHistoryEnd?.();
|
|
3896
|
+
if (!follow)
|
|
3897
|
+
finish();
|
|
3898
|
+
return;
|
|
3899
|
+
case 'error':
|
|
3900
|
+
finish(new Error(frame.detail));
|
|
3901
|
+
return;
|
|
3902
|
+
default:
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
});
|
|
3906
|
+
};
|
|
3907
|
+
ws.onclose = (event) => {
|
|
3908
|
+
enqueue(async () => {
|
|
3909
|
+
await options.onClose?.({ code: event?.code ?? 1006, reason: event?.reason ?? '' });
|
|
3910
|
+
finish();
|
|
3911
|
+
});
|
|
3912
|
+
};
|
|
3913
|
+
ws.onerror = () => finish(new Error('WebSocket connection failed'));
|
|
3914
|
+
}).catch(reject);
|
|
3915
|
+
});
|
|
3916
|
+
}
|
|
3917
|
+
/**
|
|
3918
|
+
* Buzz-backed agents publish observer telemetry to a Nostr relay (kind
|
|
3919
|
+
* 24200, NIP-44-v2-encrypted to the owner). This subscribes the relay
|
|
3920
|
+
* directly and decrypts locally; see {@link subscribeBuzzActivity}.
|
|
3921
|
+
*/
|
|
3922
|
+
async subscribeBuzzActivity(agentIdOrName, handlers) {
|
|
3923
|
+
return subscribeBuzzActivity(this, agentIdOrName, handlers);
|
|
3924
|
+
}
|
|
3925
|
+
/**
|
|
3926
|
+
* Legacy Buzz activity through an agent `hyper-acp` WS route. Hosted Buzz
|
|
3927
|
+
* launches no longer provision this route; the raw ACP path is outbound
|
|
3928
|
+
* `HYPER_ACP_WS_URL`. See {@link subscribeBuzzActivityRoute}.
|
|
3929
|
+
*/
|
|
3930
|
+
async subscribeBuzzActivityRoute(agentIdOrName, handlers) {
|
|
3931
|
+
return subscribeBuzzActivityRoute(this, agentIdOrName, handlers);
|
|
3932
|
+
}
|
|
3933
|
+
async shellToken(agentIdOrName, shell, requestOptions = {}) {
|
|
3934
|
+
const selectedShell = shell ?? '/bin/bash';
|
|
3935
|
+
const agentId = await this.resolveAgentId(agentIdOrName, requestOptions);
|
|
3936
|
+
const path = `${DEPLOYMENTS_API_PREFIX}/${agentId}/shell/token`;
|
|
3937
|
+
const rawToken = Object.keys(requestOptions).length === 0
|
|
3938
|
+
? await this.agentHttp.post(path, { shell: selectedShell })
|
|
3939
|
+
: await this.agentHttp.post(path, { shell: selectedShell }, requestOptions);
|
|
3940
|
+
return validateAgentWsToken(rawToken, agentId, 'shell', selectedShell);
|
|
3941
|
+
}
|
|
3942
|
+
async shellConnect(agentIdOrName, shell, options = {}) {
|
|
3943
|
+
if (options.signal?.aborted)
|
|
3944
|
+
throw shellAbortError();
|
|
3945
|
+
const tokenTimeoutMs = options.tokenTimeoutMs ?? 15_000;
|
|
3946
|
+
const openTimeoutMs = options.openTimeoutMs ?? 10_000;
|
|
3947
|
+
const tokenDeadline = Date.now() + tokenTimeoutMs;
|
|
3948
|
+
const remainingTokenTime = () => Math.max(0, tokenDeadline - Date.now());
|
|
3949
|
+
const runBeforeTokenDeadline = (operation) => {
|
|
3950
|
+
const remaining = remainingTokenTime();
|
|
3951
|
+
if (remaining <= 0)
|
|
3952
|
+
return Promise.reject(new Error('Shell token request timed out'));
|
|
3953
|
+
return runShellOperation(operation, options.signal, remaining, 'Shell token request timed out');
|
|
3954
|
+
};
|
|
3955
|
+
const agentId = await runBeforeTokenDeadline((signal) => this.resolveAgentId(agentIdOrName, { signal }));
|
|
3956
|
+
const connectWithShell = async (requestedShell) => {
|
|
3957
|
+
const remaining = remainingTokenTime();
|
|
3958
|
+
if (remaining <= 0)
|
|
3959
|
+
throw new Error('Shell token request timed out');
|
|
3960
|
+
const requestOptions = {
|
|
3961
|
+
retries: 3,
|
|
3962
|
+
timeout: Math.max(500, Math.floor((tokenTimeoutMs - 3_000) / 3)),
|
|
3963
|
+
retryStatuses: [429, 502, 503, 504],
|
|
3964
|
+
};
|
|
3965
|
+
const tokenData = await runBeforeTokenDeadline((signal) => {
|
|
3966
|
+
requestOptions.signal = signal;
|
|
3967
|
+
return this.shellToken(agentId, requestedShell, requestOptions);
|
|
3968
|
+
});
|
|
3969
|
+
const parsed = new URL(tokenData.ws_url);
|
|
3970
|
+
parsed.searchParams.set('token', tokenData.token);
|
|
3971
|
+
parsed.searchParams.set('shell', tokenData.shell);
|
|
3972
|
+
const WebSocketImpl = preferredWebSocket();
|
|
3973
|
+
const ws = new WebSocketImpl(parsed.toString());
|
|
3974
|
+
ws.binaryType = 'arraybuffer';
|
|
3975
|
+
return await new Promise((resolve, reject) => {
|
|
3976
|
+
let settled = false;
|
|
3977
|
+
const abortConnection = () => finish(shellAbortError());
|
|
3978
|
+
const openTimer = setTimeout(() => finish(new Error('Shell connection timed out')), openTimeoutMs);
|
|
3979
|
+
const cleanup = () => {
|
|
3980
|
+
clearTimeout(openTimer);
|
|
3981
|
+
options.signal?.removeEventListener('abort', abortConnection);
|
|
3982
|
+
};
|
|
3983
|
+
const finish = (error) => {
|
|
3984
|
+
if (settled)
|
|
3985
|
+
return;
|
|
3986
|
+
settled = true;
|
|
3987
|
+
cleanup();
|
|
3988
|
+
if (error) {
|
|
3989
|
+
try {
|
|
3990
|
+
ws.close();
|
|
3991
|
+
}
|
|
3992
|
+
catch {
|
|
3993
|
+
// The browser may have already finalized a failed socket.
|
|
3994
|
+
}
|
|
3995
|
+
reject(error);
|
|
3996
|
+
}
|
|
3997
|
+
else {
|
|
3998
|
+
resolve(ws);
|
|
3999
|
+
}
|
|
4000
|
+
};
|
|
4001
|
+
if (options.signal?.aborted) {
|
|
4002
|
+
finish(shellAbortError());
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
options.signal?.addEventListener('abort', abortConnection, { once: true });
|
|
4006
|
+
ws.onopen = () => {
|
|
4007
|
+
finish();
|
|
4008
|
+
};
|
|
4009
|
+
ws.onerror = () => undefined;
|
|
4010
|
+
ws.onclose = (event) => {
|
|
4011
|
+
const reason = event.reason ? `: ${event.reason}` : '';
|
|
4012
|
+
const error = new Error(`WebSocket closed before opening${reason}`);
|
|
4013
|
+
error.closeCode = event.code;
|
|
4014
|
+
error.closeReason = event.reason;
|
|
4015
|
+
finish(error);
|
|
4016
|
+
};
|
|
4017
|
+
});
|
|
4018
|
+
};
|
|
4019
|
+
return connectWithShell(shell ?? '/bin/bash');
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
//# sourceMappingURL=agents.js.map
|