@vellumai/cli 0.6.5 → 0.7.0
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/AGENTS.md +8 -2
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/config-utils.test.ts +159 -0
- package/src/__tests__/env-drift.test.ts +10 -32
- package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
- package/src/__tests__/multi-local.test.ts +0 -5
- package/src/__tests__/sleep.test.ts +1 -2
- package/src/__tests__/teleport.test.ts +919 -1255
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +40 -8
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +89 -6
- package/src/commands/ps.ts +104 -20
- package/src/commands/retire.ts +23 -0
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +15 -2
- package/src/commands/teleport.ts +447 -583
- package/src/commands/terminal.ts +225 -0
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +304 -152
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +50 -74
- package/src/lib/__tests__/job-polling.test.ts +278 -0
- package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/assistant-config.ts +12 -8
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/config-utils.ts +97 -1
- package/src/lib/docker.ts +73 -75
- package/src/lib/environments/__tests__/paths.test.ts +2 -0
- package/src/lib/environments/resolve.ts +89 -7
- package/src/lib/environments/seeds.ts +8 -5
- package/src/lib/environments/types.ts +10 -0
- package/src/lib/hatch-local.ts +15 -120
- package/src/lib/health-check.ts +98 -0
- package/src/lib/job-polling.ts +195 -0
- package/src/lib/local-runtime-client.ts +178 -0
- package/src/lib/local.ts +139 -15
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +215 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/terminal-client.ts +177 -0
- package/src/lib/terminal-session.ts +457 -0
- package/src/shared/provider-env-vars.ts +2 -3
- package/src/__tests__/orphan-detection.test.ts +0 -214
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform terminal API client.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Django terminal session endpoints that proxy through vembda to
|
|
5
|
+
* open K8s exec streams into managed assistant containers. Same transport
|
|
6
|
+
* the web UI's xterm.js terminal uses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { authHeaders, getPlatformUrl } from "./platform-client.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Create / Close
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export async function createTerminalSession(
|
|
16
|
+
token: string,
|
|
17
|
+
assistantId: string,
|
|
18
|
+
cols: number,
|
|
19
|
+
rows: number,
|
|
20
|
+
platformUrl?: string,
|
|
21
|
+
): Promise<{ session_id: string }> {
|
|
22
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
23
|
+
const response = await fetch(
|
|
24
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
|
|
25
|
+
{
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: await authHeaders(token, platformUrl),
|
|
28
|
+
body: JSON.stringify({ cols, rows }),
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const detail = await response.text().catch(() => "");
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Failed to create terminal session (${response.status}): ${detail || response.statusText}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return (await response.json()) as { session_id: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function closeTerminalSession(
|
|
41
|
+
token: string,
|
|
42
|
+
assistantId: string,
|
|
43
|
+
sessionId: string,
|
|
44
|
+
platformUrl?: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
47
|
+
const response = await fetch(
|
|
48
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/`,
|
|
49
|
+
{
|
|
50
|
+
method: "DELETE",
|
|
51
|
+
headers: await authHeaders(token, platformUrl),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
// 404 = already closed, treat as success
|
|
55
|
+
if (!response.ok && response.status !== 404) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Failed to close terminal session (${response.status}): ${response.statusText}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Input / Resize
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export async function sendTerminalInput(
|
|
67
|
+
token: string,
|
|
68
|
+
assistantId: string,
|
|
69
|
+
sessionId: string,
|
|
70
|
+
data: string,
|
|
71
|
+
platformUrl?: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
74
|
+
const response = await fetch(
|
|
75
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/input/`,
|
|
76
|
+
{
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: await authHeaders(token, platformUrl),
|
|
79
|
+
body: JSON.stringify({ data }),
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Failed to send terminal input (${response.status}): ${response.statusText}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function resizeTerminalSession(
|
|
90
|
+
token: string,
|
|
91
|
+
assistantId: string,
|
|
92
|
+
sessionId: string,
|
|
93
|
+
cols: number,
|
|
94
|
+
rows: number,
|
|
95
|
+
platformUrl?: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
98
|
+
const response = await fetch(
|
|
99
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/resize/`,
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: await authHeaders(token, platformUrl),
|
|
103
|
+
body: JSON.stringify({ cols, rows }),
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Failed to resize terminal (${response.status}): ${response.statusText}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// SSE event stream
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface TerminalOutputEvent {
|
|
118
|
+
seq: number;
|
|
119
|
+
/** Base64-encoded PTY output bytes. */
|
|
120
|
+
data: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Subscribe to the terminal output SSE stream. Yields parsed events as they
|
|
125
|
+
* arrive. The generator completes when the stream ends or is aborted.
|
|
126
|
+
*/
|
|
127
|
+
export async function* subscribeTerminalEvents(
|
|
128
|
+
token: string,
|
|
129
|
+
assistantId: string,
|
|
130
|
+
sessionId: string,
|
|
131
|
+
platformUrl?: string,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): AsyncGenerator<TerminalOutputEvent> {
|
|
134
|
+
const baseUrl = platformUrl || getPlatformUrl();
|
|
135
|
+
const response = await fetch(
|
|
136
|
+
`${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/events/`,
|
|
137
|
+
{
|
|
138
|
+
headers: await authHeaders(token, platformUrl),
|
|
139
|
+
signal,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!response.ok || !response.body) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`SSE connection failed (${response.status}): ${response.statusText}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const reader = response.body.getReader();
|
|
150
|
+
const decoder = new TextDecoder();
|
|
151
|
+
let buffer = "";
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
while (true) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
|
|
158
|
+
buffer += decoder.decode(value, { stream: true });
|
|
159
|
+
const lines = buffer.split("\n");
|
|
160
|
+
// Keep the last incomplete line in the buffer
|
|
161
|
+
buffer = lines.pop() ?? "";
|
|
162
|
+
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const trimmed = line.trimEnd();
|
|
165
|
+
if (trimmed.startsWith("data: ")) {
|
|
166
|
+
try {
|
|
167
|
+
yield JSON.parse(trimmed.slice(6)) as TerminalOutputEvent;
|
|
168
|
+
} catch {
|
|
169
|
+
// Skip malformed SSE frames
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
reader.releaseLock();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared terminal session primitives for managed (cloud-hosted) assistants.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from commands/terminal.ts so that ssh.ts, exec.ts, and
|
|
5
|
+
* terminal.ts can all use the same interactive session and assistant
|
|
6
|
+
* resolver without cross-importing commands (per cli/CONTRIBUTING.md).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
findAssistantByName,
|
|
11
|
+
loadLatestAssistant,
|
|
12
|
+
resolveCloud,
|
|
13
|
+
} from "./assistant-config.js";
|
|
14
|
+
import { getPlatformUrl, readPlatformToken } from "./platform-client.js";
|
|
15
|
+
import {
|
|
16
|
+
closeTerminalSession,
|
|
17
|
+
createTerminalSession,
|
|
18
|
+
resizeTerminalSession,
|
|
19
|
+
sendTerminalInput,
|
|
20
|
+
subscribeTerminalEvents,
|
|
21
|
+
} from "./terminal-client.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface ResolvedManagedAssistant {
|
|
28
|
+
assistantId: string;
|
|
29
|
+
token: string;
|
|
30
|
+
platformUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Resolver
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a managed (cloud-hosted) assistant from the lockfile. Exits with
|
|
39
|
+
* an error if the assistant is not found, not managed, or the user isn't
|
|
40
|
+
* logged in.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveManagedAssistant(
|
|
43
|
+
nameArg?: string,
|
|
44
|
+
): ResolvedManagedAssistant {
|
|
45
|
+
const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
|
|
46
|
+
|
|
47
|
+
if (!entry) {
|
|
48
|
+
if (nameArg) {
|
|
49
|
+
console.error(`No assistant instance found with name '${nameArg}'.`);
|
|
50
|
+
} else {
|
|
51
|
+
console.error("No assistant instance found. Run `vellum hatch` first.");
|
|
52
|
+
}
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cloud = resolveCloud(entry);
|
|
57
|
+
if (cloud !== "vellum") {
|
|
58
|
+
if (cloud === "local") {
|
|
59
|
+
console.error(
|
|
60
|
+
"This assistant runs locally on your machine. You can access it directly.",
|
|
61
|
+
);
|
|
62
|
+
} else if (cloud === "docker") {
|
|
63
|
+
console.error(
|
|
64
|
+
`Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
console.error(
|
|
68
|
+
`'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const token = readPlatformToken();
|
|
75
|
+
if (!token) {
|
|
76
|
+
console.error(
|
|
77
|
+
"Not logged in. Run `vellum login` first to authenticate with the platform.",
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
assistantId: entry.assistantId,
|
|
84
|
+
token,
|
|
85
|
+
platformUrl: getPlatformUrl(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Interactive session
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Open an interactive raw-tty terminal session to a managed assistant.
|
|
95
|
+
* Bridges local stdin/stdout to the platform terminal session API.
|
|
96
|
+
*/
|
|
97
|
+
export async function interactiveSession(
|
|
98
|
+
assistant: ResolvedManagedAssistant,
|
|
99
|
+
initialCommand?: string,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const cols = process.stdout.columns || 80;
|
|
102
|
+
const rows = process.stdout.rows || 24;
|
|
103
|
+
|
|
104
|
+
console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
|
|
105
|
+
|
|
106
|
+
const { session_id: sessionId } = await createTerminalSession(
|
|
107
|
+
assistant.token,
|
|
108
|
+
assistant.assistantId,
|
|
109
|
+
cols,
|
|
110
|
+
rows,
|
|
111
|
+
assistant.platformUrl,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// --- TTY raw mode setup ---
|
|
115
|
+
const wasRaw = process.stdin.isRaw;
|
|
116
|
+
if (process.stdin.isTTY) {
|
|
117
|
+
process.stdin.setRawMode(true);
|
|
118
|
+
}
|
|
119
|
+
process.stdin.resume();
|
|
120
|
+
process.stdin.setEncoding("utf-8");
|
|
121
|
+
|
|
122
|
+
// Abort controller for the SSE stream
|
|
123
|
+
const abortController = new AbortController();
|
|
124
|
+
let exiting = false;
|
|
125
|
+
|
|
126
|
+
// --- Cleanup function (idempotent) ---
|
|
127
|
+
async function cleanup(): Promise<void> {
|
|
128
|
+
if (exiting) return;
|
|
129
|
+
exiting = true;
|
|
130
|
+
|
|
131
|
+
// Restore tty
|
|
132
|
+
if (process.stdin.isTTY) {
|
|
133
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
134
|
+
}
|
|
135
|
+
process.stdin.pause();
|
|
136
|
+
|
|
137
|
+
// Abort SSE stream
|
|
138
|
+
abortController.abort();
|
|
139
|
+
|
|
140
|
+
// Close remote session (best-effort)
|
|
141
|
+
try {
|
|
142
|
+
await closeTerminalSession(
|
|
143
|
+
assistant.token,
|
|
144
|
+
assistant.assistantId,
|
|
145
|
+
sessionId,
|
|
146
|
+
assistant.platformUrl,
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
// Best-effort cleanup
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Signal handlers ---
|
|
154
|
+
const onSigInt = () => {
|
|
155
|
+
cleanup().then(() => process.exit(0));
|
|
156
|
+
};
|
|
157
|
+
const onSigTerm = () => {
|
|
158
|
+
cleanup().then(() => process.exit(0));
|
|
159
|
+
};
|
|
160
|
+
process.on("SIGINT", onSigInt);
|
|
161
|
+
process.on("SIGTERM", onSigTerm);
|
|
162
|
+
|
|
163
|
+
// --- SIGWINCH (terminal resize) ---
|
|
164
|
+
const onResize = () => {
|
|
165
|
+
const newCols = process.stdout.columns || 80;
|
|
166
|
+
const newRows = process.stdout.rows || 24;
|
|
167
|
+
resizeTerminalSession(
|
|
168
|
+
assistant.token,
|
|
169
|
+
assistant.assistantId,
|
|
170
|
+
sessionId,
|
|
171
|
+
newCols,
|
|
172
|
+
newRows,
|
|
173
|
+
assistant.platformUrl,
|
|
174
|
+
).catch(() => {
|
|
175
|
+
// Resize failures are non-fatal
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
process.stdout.on("resize", onResize);
|
|
179
|
+
|
|
180
|
+
// --- Input: stdin → remote ---
|
|
181
|
+
let inputBuffer = "";
|
|
182
|
+
let inputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
183
|
+
const INPUT_DEBOUNCE_MS = 30;
|
|
184
|
+
|
|
185
|
+
function flushInput(): void {
|
|
186
|
+
if (inputBuffer.length === 0) return;
|
|
187
|
+
const data = inputBuffer;
|
|
188
|
+
inputBuffer = "";
|
|
189
|
+
sendTerminalInput(
|
|
190
|
+
assistant.token,
|
|
191
|
+
assistant.assistantId,
|
|
192
|
+
sessionId,
|
|
193
|
+
data,
|
|
194
|
+
assistant.platformUrl,
|
|
195
|
+
).catch((err) => {
|
|
196
|
+
if (!exiting) {
|
|
197
|
+
console.error(`\r\nInput error: ${err.message}\r\n`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
process.stdin.on("data", (chunk: string) => {
|
|
203
|
+
if (exiting) return;
|
|
204
|
+
inputBuffer += chunk;
|
|
205
|
+
if (inputTimer) clearTimeout(inputTimer);
|
|
206
|
+
inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Send initial command (for `attach` subcommand) ---
|
|
210
|
+
if (initialCommand) {
|
|
211
|
+
// Brief delay to let the shell initialize
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
213
|
+
await sendTerminalInput(
|
|
214
|
+
assistant.token,
|
|
215
|
+
assistant.assistantId,
|
|
216
|
+
sessionId,
|
|
217
|
+
initialCommand + "\r",
|
|
218
|
+
assistant.platformUrl,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Output: remote SSE → stdout ---
|
|
223
|
+
try {
|
|
224
|
+
for await (const event of subscribeTerminalEvents(
|
|
225
|
+
assistant.token,
|
|
226
|
+
assistant.assistantId,
|
|
227
|
+
sessionId,
|
|
228
|
+
assistant.platformUrl,
|
|
229
|
+
abortController.signal,
|
|
230
|
+
)) {
|
|
231
|
+
if (exiting) break;
|
|
232
|
+
// Decode base64 output and write raw bytes to stdout
|
|
233
|
+
const bytes = Buffer.from(event.data, "base64");
|
|
234
|
+
process.stdout.write(bytes);
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (!exiting) {
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
239
|
+
// AbortError is expected on cleanup
|
|
240
|
+
if (!msg.includes("abort")) {
|
|
241
|
+
console.error(`\r\nConnection lost: ${msg}\r\n`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
await cleanup();
|
|
246
|
+
|
|
247
|
+
// Remove listeners
|
|
248
|
+
process.off("SIGINT", onSigInt);
|
|
249
|
+
process.off("SIGTERM", onSigTerm);
|
|
250
|
+
process.stdout.off("resize", onResize);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Shell escape helper
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Shell-escape an array of command arguments for safe transmission to a
|
|
260
|
+
* remote shell. Each arg is wrapped in single quotes with internal single
|
|
261
|
+
* quotes escaped.
|
|
262
|
+
*/
|
|
263
|
+
export function shellEscapeArgs(args: string[]): string {
|
|
264
|
+
return args
|
|
265
|
+
.map((c) => c.replace(/'/g, "'\\''"))
|
|
266
|
+
.map((c) => `'${c}'`)
|
|
267
|
+
.join(" ");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Non-interactive exec
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run a command non-interactively in a managed assistant container. Creates
|
|
276
|
+
* an ephemeral terminal session, sends the command wrapped in sentinels for
|
|
277
|
+
* reliable output extraction, captures the result, and exits with the
|
|
278
|
+
* remote command's exit code.
|
|
279
|
+
*/
|
|
280
|
+
export interface NonInteractiveExecOptions {
|
|
281
|
+
verbose?: boolean;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function nonInteractiveExec(
|
|
285
|
+
assistant: ResolvedManagedAssistant,
|
|
286
|
+
command: string[],
|
|
287
|
+
options?: NonInteractiveExecOptions,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const verbose = options?.verbose ?? false;
|
|
290
|
+
const dbg = verbose
|
|
291
|
+
? (msg: string) => console.error(`\x1b[2m[exec] ${msg}\x1b[0m`)
|
|
292
|
+
: (_msg: string) => {};
|
|
293
|
+
|
|
294
|
+
dbg(`creating terminal session (cols=120, rows=24)`);
|
|
295
|
+
|
|
296
|
+
const { session_id: sessionId } = await createTerminalSession(
|
|
297
|
+
assistant.token,
|
|
298
|
+
assistant.assistantId,
|
|
299
|
+
120,
|
|
300
|
+
24,
|
|
301
|
+
assistant.platformUrl,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
dbg(`session created: ${sessionId}`);
|
|
305
|
+
|
|
306
|
+
const abortController = new AbortController();
|
|
307
|
+
const output: Buffer[] = [];
|
|
308
|
+
let commandSent = false;
|
|
309
|
+
let eventCount = 0;
|
|
310
|
+
|
|
311
|
+
// Unique sentinels to delimit command output
|
|
312
|
+
const startSentinel = `__VELLUM_EXEC_START_${Date.now()}__`;
|
|
313
|
+
const endSentinel = `__VELLUM_EXEC_END_${Date.now()}__`;
|
|
314
|
+
const exitCodeSentinel = `__VELLUM_EXIT_`;
|
|
315
|
+
|
|
316
|
+
dbg(`sentinels: start=${startSentinel} end=${endSentinel}`);
|
|
317
|
+
|
|
318
|
+
const timeout = setTimeout(() => {
|
|
319
|
+
dbg(`30s timeout reached — aborting`);
|
|
320
|
+
abortController.abort();
|
|
321
|
+
}, 30_000);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
for await (const event of subscribeTerminalEvents(
|
|
325
|
+
assistant.token,
|
|
326
|
+
assistant.assistantId,
|
|
327
|
+
sessionId,
|
|
328
|
+
assistant.platformUrl,
|
|
329
|
+
abortController.signal,
|
|
330
|
+
)) {
|
|
331
|
+
eventCount++;
|
|
332
|
+
const bytes = Buffer.from(event.data, "base64");
|
|
333
|
+
output.push(bytes);
|
|
334
|
+
|
|
335
|
+
if (verbose) {
|
|
336
|
+
const text = bytes.toString("utf-8");
|
|
337
|
+
dbg(`SSE event #${eventCount} (seq=${event.seq}, ${bytes.length}B): ${JSON.stringify(text)}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Wait for shell prompt before sending command
|
|
341
|
+
if (!commandSent) {
|
|
342
|
+
const joined = Buffer.concat(output).toString("utf-8");
|
|
343
|
+
if (
|
|
344
|
+
joined.includes("$") ||
|
|
345
|
+
joined.includes("#") ||
|
|
346
|
+
joined.includes("%")
|
|
347
|
+
) {
|
|
348
|
+
commandSent = true;
|
|
349
|
+
const shellCmd = shellEscapeArgs(command);
|
|
350
|
+
const fullCmd = `echo '${startSentinel}'; ${shellCmd}; __ec=$?; echo '${endSentinel}'; echo '${exitCodeSentinel}'$__ec; exit $__ec\r`;
|
|
351
|
+
dbg(`prompt detected — sending command`);
|
|
352
|
+
if (verbose) {
|
|
353
|
+
dbg(`full command: ${JSON.stringify(fullCmd)}`);
|
|
354
|
+
}
|
|
355
|
+
// Wrap command: print start sentinel, run command, capture exit
|
|
356
|
+
// code, print end sentinel with exit code, then exit the shell
|
|
357
|
+
await sendTerminalInput(
|
|
358
|
+
assistant.token,
|
|
359
|
+
assistant.assistantId,
|
|
360
|
+
sessionId,
|
|
361
|
+
fullCmd,
|
|
362
|
+
assistant.platformUrl,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Check for end sentinel in accumulated output
|
|
368
|
+
if (commandSent) {
|
|
369
|
+
const accumulated = Buffer.concat(output).toString("utf-8");
|
|
370
|
+
if (accumulated.includes(exitCodeSentinel)) {
|
|
371
|
+
dbg(`exit code sentinel detected — waiting 500ms for final output`);
|
|
372
|
+
// Give a moment for final output to arrive
|
|
373
|
+
setTimeout(() => abortController.abort(), 500);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch {
|
|
378
|
+
// Expected: abort on timeout or sentinel detection
|
|
379
|
+
} finally {
|
|
380
|
+
clearTimeout(timeout);
|
|
381
|
+
dbg(`stream ended after ${eventCount} events — closing session`);
|
|
382
|
+
await closeTerminalSession(
|
|
383
|
+
assistant.token,
|
|
384
|
+
assistant.assistantId,
|
|
385
|
+
sessionId,
|
|
386
|
+
assistant.platformUrl,
|
|
387
|
+
).catch(() => {});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Parse output between sentinels
|
|
391
|
+
const raw = Buffer.concat(output).toString("utf-8");
|
|
392
|
+
|
|
393
|
+
if (verbose) {
|
|
394
|
+
dbg(`--- raw output (${raw.length} chars) ---`);
|
|
395
|
+
console.error(raw);
|
|
396
|
+
dbg(`--- end raw output ---`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Strip ANSI escapes
|
|
400
|
+
const clean = raw.replace(
|
|
401
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
|
|
402
|
+
/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
|
|
403
|
+
"",
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
if (verbose) {
|
|
407
|
+
dbg(`--- cleaned output (${clean.length} chars) ---`);
|
|
408
|
+
console.error(clean);
|
|
409
|
+
dbg(`--- end cleaned output ---`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const lines = clean.split("\n");
|
|
413
|
+
|
|
414
|
+
// Find output between sentinels. Search backwards because each sentinel
|
|
415
|
+
// string appears twice: once in the shell command echo and once in the
|
|
416
|
+
// actual output. We want the last occurrence (the output line).
|
|
417
|
+
let startIdx = -1;
|
|
418
|
+
let endIdx = -1;
|
|
419
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
420
|
+
if (endIdx < 0 && lines[i].includes(endSentinel)) {
|
|
421
|
+
endIdx = i;
|
|
422
|
+
}
|
|
423
|
+
if (startIdx < 0 && lines[i].includes(startSentinel)) {
|
|
424
|
+
startIdx = i;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
dbg(`sentinel indices: startLine=${startIdx} endLine=${endIdx} (of ${lines.length} lines)`);
|
|
429
|
+
|
|
430
|
+
const start = startIdx >= 0 ? startIdx + 1 : 0;
|
|
431
|
+
const end = endIdx >= 0 ? endIdx : lines.length;
|
|
432
|
+
const result = lines.slice(start, end).join("\n").trim();
|
|
433
|
+
|
|
434
|
+
dbg(`extracted result: ${result.length} chars`);
|
|
435
|
+
|
|
436
|
+
if (result) {
|
|
437
|
+
process.stdout.write(result + "\n");
|
|
438
|
+
} else {
|
|
439
|
+
dbg(`no output extracted between sentinels`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Extract exit code from sentinel (also search backwards)
|
|
443
|
+
let exitCode = 0;
|
|
444
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
445
|
+
if (lines[i].includes(exitCodeSentinel)) {
|
|
446
|
+
const match = lines[i].match(/__VELLUM_EXIT_(\d+)/);
|
|
447
|
+
if (match) {
|
|
448
|
+
exitCode = parseInt(match[1], 10);
|
|
449
|
+
}
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
dbg(`exit code: ${exitCode}`);
|
|
455
|
+
|
|
456
|
+
process.exit(exitCode);
|
|
457
|
+
}
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two sources are merged into a single combined map:
|
|
5
5
|
*
|
|
6
|
-
* 1. Search-provider env vars —
|
|
7
|
-
* (single source of truth, also bundled into the macOS client).
|
|
6
|
+
* 1. Search-provider env vars — hardcoded below (Brave, Perplexity).
|
|
8
7
|
* 2. LLM-provider env vars — sourced from `PROVIDER_CATALOG` in
|
|
9
8
|
* `assistant/src/providers/model-catalog.ts` via a locally-maintained
|
|
10
9
|
* mirror (the CLI does not import from `assistant/src/`; drift is caught
|
|
@@ -26,7 +25,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
|
26
25
|
openrouter: "OPENROUTER_API_KEY",
|
|
27
26
|
};
|
|
28
27
|
|
|
29
|
-
/** Search-provider env var names.
|
|
28
|
+
/** Search-provider env var names. */
|
|
30
29
|
export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
31
30
|
brave: "BRAVE_API_KEY",
|
|
32
31
|
perplexity: "PERPLEXITY_API_KEY",
|