@vellumai/cli 0.8.5 → 0.8.7
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 +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +21 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +471 -30
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/ps.ts +41 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +3 -23
- package/src/commands/rollback.ts +2 -14
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +68 -45
- package/src/components/DefaultMainScreen.tsx +16 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-config.ts +16 -3
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/docker.ts +57 -7
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +2 -5
- package/src/lib/guardian-token.ts +12 -5
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +173 -292
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
package/src/commands/retire.ts
CHANGED
|
@@ -2,14 +2,16 @@ import { existsSync, unlinkSync } from "fs";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
extractHostFromUrl,
|
|
5
6
|
formatAssistantLookupError,
|
|
6
7
|
formatAssistantReference,
|
|
7
8
|
getAssistantDisplayName,
|
|
8
9
|
loadAllAssistants,
|
|
9
10
|
lookupAssistantByIdentifier,
|
|
10
11
|
removeAssistantEntry,
|
|
12
|
+
resolveCloud,
|
|
13
|
+
type AssistantEntry,
|
|
11
14
|
} from "../lib/assistant-config.js";
|
|
12
|
-
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
13
15
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
14
16
|
import { getConfigDir } from "../lib/environments/paths.js";
|
|
15
17
|
import { getCurrentEnvironment } from "../lib/environments/resolve.js";
|
|
@@ -31,28 +33,6 @@ import {
|
|
|
31
33
|
writeToLogFile,
|
|
32
34
|
} from "../lib/xdg-log.js";
|
|
33
35
|
|
|
34
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
35
|
-
if (entry.cloud) {
|
|
36
|
-
return entry.cloud;
|
|
37
|
-
}
|
|
38
|
-
if (entry.project) {
|
|
39
|
-
return "gcp";
|
|
40
|
-
}
|
|
41
|
-
if (entry.sshUser) {
|
|
42
|
-
return "custom";
|
|
43
|
-
}
|
|
44
|
-
return "local";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function extractHostFromUrl(url: string): string {
|
|
48
|
-
try {
|
|
49
|
-
const parsed = new URL(url);
|
|
50
|
-
return parsed.hostname;
|
|
51
|
-
} catch {
|
|
52
|
-
return url.replace(/^https?:\/\//, "").split(":")[0];
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
36
|
export { retireLocal };
|
|
57
37
|
|
|
58
38
|
interface RetireArgs {
|
package/src/commands/rollback.ts
CHANGED
|
@@ -4,9 +4,10 @@ import {
|
|
|
4
4
|
findAssistantByName,
|
|
5
5
|
getActiveAssistant,
|
|
6
6
|
loadAllAssistants,
|
|
7
|
+
resolveCloud,
|
|
7
8
|
saveAssistantEntry,
|
|
9
|
+
type AssistantEntry,
|
|
8
10
|
} from "../lib/assistant-config";
|
|
9
|
-
import type { AssistantEntry } from "../lib/assistant-config";
|
|
10
11
|
import {
|
|
11
12
|
captureImageRefs,
|
|
12
13
|
GATEWAY_INTERNAL_PORT,
|
|
@@ -90,19 +91,6 @@ function parseArgs(): { name: string | null; version: string | null } {
|
|
|
90
91
|
return { name, version };
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
94
|
-
if (entry.cloud) {
|
|
95
|
-
return entry.cloud;
|
|
96
|
-
}
|
|
97
|
-
if (entry.project) {
|
|
98
|
-
return "gcp";
|
|
99
|
-
}
|
|
100
|
-
if (entry.sshUser) {
|
|
101
|
-
return "custom";
|
|
102
|
-
}
|
|
103
|
-
return "local";
|
|
104
|
-
}
|
|
105
|
-
|
|
106
94
|
/**
|
|
107
95
|
* Resolve which assistant to target for the rollback command. Priority:
|
|
108
96
|
* 1. Explicit name argument
|
package/src/commands/ssh.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
extractHostFromUrl,
|
|
5
|
+
resolveAssistant,
|
|
6
|
+
resolveCloud,
|
|
7
|
+
} from "../lib/assistant-config";
|
|
5
8
|
import { dockerResourceNames } from "../lib/docker";
|
|
6
9
|
import { getPlatformUrl, readPlatformToken } from "../lib/platform-client";
|
|
7
10
|
import { sshAppleContainer } from "../lib/ssh-apple-container";
|
|
@@ -18,28 +21,6 @@ const SSH_OPTS = [
|
|
|
18
21
|
"LogLevel=ERROR",
|
|
19
22
|
];
|
|
20
23
|
|
|
21
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
22
|
-
if (entry.cloud) {
|
|
23
|
-
return entry.cloud;
|
|
24
|
-
}
|
|
25
|
-
if (entry.project) {
|
|
26
|
-
return "gcp";
|
|
27
|
-
}
|
|
28
|
-
if (entry.sshUser) {
|
|
29
|
-
return "custom";
|
|
30
|
-
}
|
|
31
|
-
return "local";
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function extractHostFromUrl(url: string): string {
|
|
35
|
-
try {
|
|
36
|
-
const parsed = new URL(url);
|
|
37
|
-
return parsed.hostname;
|
|
38
|
-
} catch {
|
|
39
|
-
return url.replace(/^https?:\/\//, "").split(":")[0];
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
24
|
export async function ssh(): Promise<void> {
|
|
44
25
|
const args = process.argv.slice(3);
|
|
45
26
|
if (args.includes("--help") || args.includes("-h")) {
|
package/src/commands/teleport.ts
CHANGED
|
@@ -3,10 +3,11 @@ import {
|
|
|
3
3
|
loadAllAssistants,
|
|
4
4
|
getDaemonPidPath,
|
|
5
5
|
removeAssistantEntry,
|
|
6
|
+
resolveCloud,
|
|
6
7
|
saveAssistantEntry,
|
|
7
8
|
setActiveAssistant,
|
|
9
|
+
type AssistantEntry,
|
|
8
10
|
} from "../lib/assistant-config.js";
|
|
9
|
-
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
10
11
|
import {
|
|
11
12
|
loadGuardianToken,
|
|
12
13
|
leaseGuardianToken,
|
|
@@ -214,12 +215,6 @@ export function parseArgs(argv: string[]): {
|
|
|
214
215
|
return { from, to, targetEnv, targetName, keepSource, dryRun, help };
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
218
|
-
return (
|
|
219
|
-
entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local")
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
218
|
// ---------------------------------------------------------------------------
|
|
224
219
|
// Auth helper — same pattern as restore.ts
|
|
225
220
|
// ---------------------------------------------------------------------------
|
|
@@ -228,7 +223,7 @@ async function getAccessToken(
|
|
|
228
223
|
runtimeUrl: string,
|
|
229
224
|
assistantId: string,
|
|
230
225
|
displayName: string,
|
|
231
|
-
options?: { forceRefresh?: boolean },
|
|
226
|
+
options?: { forceRefresh?: boolean; bootstrapSecret?: string },
|
|
232
227
|
): Promise<string> {
|
|
233
228
|
// When forceRefresh is set (e.g. after a runtime 401 on the cached token)
|
|
234
229
|
// we skip the cache and lease a brand-new token from the gateway, so a
|
|
@@ -242,7 +237,11 @@ async function getAccessToken(
|
|
|
242
237
|
}
|
|
243
238
|
|
|
244
239
|
try {
|
|
245
|
-
const freshToken = await leaseGuardianToken(
|
|
240
|
+
const freshToken = await leaseGuardianToken(
|
|
241
|
+
runtimeUrl,
|
|
242
|
+
assistantId,
|
|
243
|
+
options?.bootstrapSecret,
|
|
244
|
+
);
|
|
246
245
|
return freshToken.accessToken;
|
|
247
246
|
} catch (err) {
|
|
248
247
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -281,11 +280,15 @@ function isRuntime401(err: unknown): boolean {
|
|
|
281
280
|
* — propagates to the caller.
|
|
282
281
|
*/
|
|
283
282
|
async function callRuntimeWithAuthRetry<T>(
|
|
284
|
-
|
|
285
|
-
assistantId: string,
|
|
283
|
+
entry: AssistantEntry,
|
|
286
284
|
fn: (token: string) => Promise<T>,
|
|
287
285
|
): Promise<T> {
|
|
288
|
-
const firstToken = await getAccessToken(
|
|
286
|
+
const firstToken = await getAccessToken(
|
|
287
|
+
entry.runtimeUrl,
|
|
288
|
+
entry.assistantId,
|
|
289
|
+
entry.assistantId,
|
|
290
|
+
{ bootstrapSecret: entry.guardianBootstrapSecret },
|
|
291
|
+
);
|
|
289
292
|
try {
|
|
290
293
|
return await fn(firstToken);
|
|
291
294
|
} catch (err) {
|
|
@@ -293,10 +296,13 @@ async function callRuntimeWithAuthRetry<T>(
|
|
|
293
296
|
throw err;
|
|
294
297
|
}
|
|
295
298
|
const refreshedToken = await getAccessToken(
|
|
296
|
-
runtimeUrl,
|
|
297
|
-
assistantId,
|
|
298
|
-
assistantId,
|
|
299
|
-
{
|
|
299
|
+
entry.runtimeUrl,
|
|
300
|
+
entry.assistantId,
|
|
301
|
+
entry.assistantId,
|
|
302
|
+
{
|
|
303
|
+
forceRefresh: true,
|
|
304
|
+
bootstrapSecret: entry.guardianBootstrapSecret,
|
|
305
|
+
},
|
|
300
306
|
);
|
|
301
307
|
return await fn(refreshedToken);
|
|
302
308
|
}
|
|
@@ -386,8 +392,7 @@ async function exportFromAssistant(
|
|
|
386
392
|
let sourceRuntimeVersion: string;
|
|
387
393
|
try {
|
|
388
394
|
const identity = await callRuntimeWithAuthRetry(
|
|
389
|
-
entry
|
|
390
|
-
entry.assistantId,
|
|
395
|
+
entry,
|
|
391
396
|
async (token) => localRuntimeIdentity(entry, token),
|
|
392
397
|
);
|
|
393
398
|
sourceRuntimeVersion = identity.version;
|
|
@@ -423,8 +428,7 @@ async function exportFromAssistant(
|
|
|
423
428
|
let accessToken: string;
|
|
424
429
|
try {
|
|
425
430
|
const result = await callRuntimeWithAuthRetry(
|
|
426
|
-
entry
|
|
427
|
-
entry.assistantId,
|
|
431
|
+
entry,
|
|
428
432
|
async (token) => {
|
|
429
433
|
const r = await localRuntimeExportToGcs(entry, token, {
|
|
430
434
|
uploadUrl,
|
|
@@ -462,7 +466,10 @@ async function exportFromAssistant(
|
|
|
462
466
|
entry.runtimeUrl,
|
|
463
467
|
entry.assistantId,
|
|
464
468
|
entry.assistantId,
|
|
465
|
-
{
|
|
469
|
+
{
|
|
470
|
+
forceRefresh: true,
|
|
471
|
+
bootstrapSecret: entry.guardianBootstrapSecret,
|
|
472
|
+
},
|
|
466
473
|
);
|
|
467
474
|
},
|
|
468
475
|
});
|
|
@@ -728,8 +735,7 @@ async function importToAssistant(
|
|
|
728
735
|
let targetRuntimeVersion: string;
|
|
729
736
|
try {
|
|
730
737
|
const identity = await callRuntimeWithAuthRetry(
|
|
731
|
-
entry
|
|
732
|
-
entry.assistantId,
|
|
738
|
+
entry,
|
|
733
739
|
(token) => localRuntimeIdentity(entry, token),
|
|
734
740
|
);
|
|
735
741
|
targetRuntimeVersion = identity.version;
|
|
@@ -774,8 +780,7 @@ async function importToAssistant(
|
|
|
774
780
|
let accessToken: string;
|
|
775
781
|
try {
|
|
776
782
|
const result = await callRuntimeWithAuthRetry(
|
|
777
|
-
entry
|
|
778
|
-
entry.assistantId,
|
|
783
|
+
entry,
|
|
779
784
|
async (token) => {
|
|
780
785
|
const r = await localRuntimeImportFromGcs(entry, token, {
|
|
781
786
|
bundleUrl,
|
|
@@ -806,7 +811,10 @@ async function importToAssistant(
|
|
|
806
811
|
entry.runtimeUrl,
|
|
807
812
|
entry.assistantId,
|
|
808
813
|
entry.assistantId,
|
|
809
|
-
{
|
|
814
|
+
{
|
|
815
|
+
forceRefresh: true,
|
|
816
|
+
bootstrapSecret: entry.guardianBootstrapSecret,
|
|
817
|
+
},
|
|
810
818
|
);
|
|
811
819
|
},
|
|
812
820
|
});
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
findAssistantByName,
|
|
8
8
|
getActiveAssistant,
|
|
9
9
|
loadAllAssistants,
|
|
10
|
+
resolveCloud,
|
|
10
11
|
saveAssistantEntry,
|
|
12
|
+
type AssistantEntry,
|
|
11
13
|
} from "../lib/assistant-config";
|
|
12
|
-
import type { AssistantEntry } from "../lib/assistant-config";
|
|
13
14
|
import {
|
|
14
15
|
captureImageRefs,
|
|
15
16
|
GATEWAY_INTERNAL_PORT,
|
|
@@ -145,19 +146,6 @@ function parseArgs(): UpgradeArgs {
|
|
|
145
146
|
return { name, version, latest, prepare, finalize };
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
149
|
-
if (entry.cloud) {
|
|
150
|
-
return entry.cloud;
|
|
151
|
-
}
|
|
152
|
-
if (entry.project) {
|
|
153
|
-
return "gcp";
|
|
154
|
-
}
|
|
155
|
-
if (entry.sshUser) {
|
|
156
|
-
return "custom";
|
|
157
|
-
}
|
|
158
|
-
return "local";
|
|
159
|
-
}
|
|
160
|
-
|
|
161
149
|
/**
|
|
162
150
|
* Resolve which assistant to target for the upgrade command. Priority:
|
|
163
151
|
* 1. Explicit name argument
|
|
@@ -512,7 +500,10 @@ async function upgradeDocker(
|
|
|
512
500
|
} else {
|
|
513
501
|
console.error(`\n❌ Containers failed to become ready within the timeout.`);
|
|
514
502
|
|
|
515
|
-
const logDir = await captureUpgradeFailureLogs(
|
|
503
|
+
const logDir = await captureUpgradeFailureLogs(
|
|
504
|
+
res,
|
|
505
|
+
`${instanceName}-upgrade-failure`,
|
|
506
|
+
);
|
|
516
507
|
if (logDir) {
|
|
517
508
|
console.log(`📋 Container logs saved to: ${logDir}`);
|
|
518
509
|
}
|
|
@@ -938,7 +929,8 @@ async function resolveLatestAndMaybeSelfUpdate(
|
|
|
938
929
|
);
|
|
939
930
|
if (installResult.error || installResult.status !== 0) {
|
|
940
931
|
const detail =
|
|
941
|
-
installResult.error?.message ??
|
|
932
|
+
installResult.error?.message ??
|
|
933
|
+
`exited with code ${installResult.status}`;
|
|
942
934
|
console.error(`\n❌ CLI self-update failed: ${detail}`);
|
|
943
935
|
emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
|
|
944
936
|
process.exit(1);
|
package/src/commands/wake.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../lib/assistant-config.js";
|
|
9
9
|
import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
|
|
10
10
|
import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
|
|
11
|
-
import {
|
|
11
|
+
import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
|
|
12
12
|
import {
|
|
13
13
|
generateLocalSigningKey,
|
|
14
14
|
isAssistantWatchModeAvailable,
|
|
@@ -85,36 +85,26 @@ export async function wake(): Promise<void> {
|
|
|
85
85
|
|
|
86
86
|
const pidFile = getDaemonPidPath(resources);
|
|
87
87
|
|
|
88
|
-
// Check if daemon is already running
|
|
89
88
|
let daemonRunning = false;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
await stopProcessByPidFile(pidFile, "assistant");
|
|
111
|
-
daemonRunning = false;
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
console.log(`Assistant already running (pid ${pid}).`);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Process not alive, will start below
|
|
89
|
+
const daemonState = await resolveProcessState(
|
|
90
|
+
pidFile,
|
|
91
|
+
resources.daemonPort,
|
|
92
|
+
"Assistant",
|
|
93
|
+
);
|
|
94
|
+
if (daemonState.status === "healthy") {
|
|
95
|
+
if (watch && isAssistantWatchModeAvailable()) {
|
|
96
|
+
console.log(
|
|
97
|
+
`Assistant running (pid ${daemonState.pid}) — restarting in watch mode...`,
|
|
98
|
+
);
|
|
99
|
+
await stopProcessByPidFile(pidFile, "assistant");
|
|
100
|
+
} else {
|
|
101
|
+
daemonRunning = true;
|
|
102
|
+
if (watch) {
|
|
103
|
+
console.log(
|
|
104
|
+
`Assistant running (pid ${daemonState.pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
console.log(`Assistant already running (pid ${daemonState.pid}).`);
|
|
118
108
|
}
|
|
119
109
|
}
|
|
120
110
|
}
|
|
@@ -153,6 +143,15 @@ export async function wake(): Promise<void> {
|
|
|
153
143
|
saveAssistantEntry(entry);
|
|
154
144
|
}
|
|
155
145
|
|
|
146
|
+
let bootstrapSecret = entry.guardianBootstrapSecret;
|
|
147
|
+
let bootstrapSecretBackfilled = false;
|
|
148
|
+
if (!bootstrapSecret) {
|
|
149
|
+
bootstrapSecret = generateLocalSigningKey();
|
|
150
|
+
entry.guardianBootstrapSecret = bootstrapSecret;
|
|
151
|
+
saveAssistantEntry(entry);
|
|
152
|
+
bootstrapSecretBackfilled = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
156
155
|
if (!daemonRunning) {
|
|
157
156
|
await startLocalDaemon(watch, resources, { foreground, signingKey });
|
|
158
157
|
}
|
|
@@ -161,26 +160,47 @@ export async function wake(): Promise<void> {
|
|
|
161
160
|
{
|
|
162
161
|
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
163
162
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
163
|
+
const gatewayState = await resolveProcessState(
|
|
164
|
+
gatewayPidFile,
|
|
165
|
+
resources.gatewayPort,
|
|
166
|
+
"Gateway",
|
|
167
|
+
);
|
|
168
|
+
const gatewayAlive = gatewayState.status === "healthy";
|
|
169
|
+
const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
|
|
170
|
+
if (needsRestart) {
|
|
171
|
+
const restartWithWatch = watch && isGatewayWatchModeAvailable();
|
|
172
|
+
if (restartWithWatch) {
|
|
173
|
+
console.log(
|
|
174
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting to apply bootstrap secret...`,
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
console.log(
|
|
178
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
182
|
+
await startGateway(restartWithWatch, resources, {
|
|
183
|
+
signingKey,
|
|
184
|
+
bootstrapSecret,
|
|
185
|
+
});
|
|
186
|
+
} else if (gatewayAlive) {
|
|
187
|
+
if (watch && isGatewayWatchModeAvailable()) {
|
|
188
|
+
console.log(
|
|
189
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
|
|
190
|
+
);
|
|
191
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
192
|
+
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
193
|
+
} else {
|
|
194
|
+
if (watch) {
|
|
169
195
|
console.log(
|
|
170
|
-
`Gateway running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
196
|
+
`Gateway running (pid ${gatewayState.pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
171
197
|
);
|
|
172
198
|
} else {
|
|
173
|
-
console.log(
|
|
174
|
-
`Gateway running (pid ${pid}) — restarting in watch mode...`,
|
|
175
|
-
);
|
|
176
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
177
|
-
await startGateway(watch, resources, { signingKey });
|
|
199
|
+
console.log(`Gateway already running (pid ${gatewayState.pid}).`);
|
|
178
200
|
}
|
|
179
|
-
} else {
|
|
180
|
-
console.log(`Gateway already running (pid ${pid}).`);
|
|
181
201
|
}
|
|
182
202
|
} else {
|
|
183
|
-
await startGateway(watch, resources, { signingKey });
|
|
203
|
+
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
206
|
|
|
@@ -196,7 +216,10 @@ export async function wake(): Promise<void> {
|
|
|
196
216
|
|
|
197
217
|
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
198
218
|
const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
|
|
199
|
-
const ngrokChild = await maybeStartNgrokTunnel(
|
|
219
|
+
const ngrokChild = await maybeStartNgrokTunnel(
|
|
220
|
+
resources.gatewayPort,
|
|
221
|
+
workspaceDir,
|
|
222
|
+
);
|
|
200
223
|
if (ngrokChild?.pid) {
|
|
201
224
|
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
202
225
|
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
@@ -13,6 +13,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
|
13
13
|
import { checkHealth } from "../lib/health-check";
|
|
14
14
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
15
15
|
import { tuiLog } from "../lib/tui-log";
|
|
16
|
+
import { segmentsToPlainText } from "../lib/segments-to-plain-text";
|
|
16
17
|
import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
|
|
17
18
|
import {
|
|
18
19
|
getTerminalCapabilities,
|
|
@@ -230,13 +231,20 @@ async function pollMessages(
|
|
|
230
231
|
auth?: Record<string, string>,
|
|
231
232
|
): Promise<ListMessagesResponse> {
|
|
232
233
|
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
233
|
-
|
|
234
|
+
const response = await runtimeRequest<ListMessagesResponse>(
|
|
234
235
|
baseUrl,
|
|
235
236
|
assistantId,
|
|
236
237
|
`/messages?${params.toString()}`,
|
|
237
238
|
undefined,
|
|
238
239
|
auth,
|
|
239
240
|
);
|
|
241
|
+
return {
|
|
242
|
+
...response,
|
|
243
|
+
messages: response.messages.map((msg) => ({
|
|
244
|
+
...msg,
|
|
245
|
+
content: segmentsToPlainText(msg.textSegments),
|
|
246
|
+
})),
|
|
247
|
+
};
|
|
240
248
|
}
|
|
241
249
|
|
|
242
250
|
async function sendMessage(
|
|
@@ -626,6 +634,13 @@ export interface RuntimeMessage {
|
|
|
626
634
|
id: string;
|
|
627
635
|
role: "user" | "assistant";
|
|
628
636
|
content: string;
|
|
637
|
+
/**
|
|
638
|
+
* Ordered text segments from the daemon's history payload, split at
|
|
639
|
+
* tool_use/surface boundaries. The flat `content` body is derived from
|
|
640
|
+
* these (see `segmentsToPlainText`); the daemon no longer sends a
|
|
641
|
+
* redundant flattened `content` field on the wire.
|
|
642
|
+
*/
|
|
643
|
+
textSegments?: string[];
|
|
629
644
|
timestamp: string;
|
|
630
645
|
toolCalls?: ToolCallInfo[];
|
|
631
646
|
label?: string;
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { client } from "./commands/client";
|
|
|
7
7
|
import { env } from "./commands/env";
|
|
8
8
|
import { events } from "./commands/events";
|
|
9
9
|
import { exec } from "./commands/exec";
|
|
10
|
+
import { flags } from "./commands/flags";
|
|
11
|
+
import { gateway } from "./commands/gateway";
|
|
10
12
|
import { hatch } from "./commands/hatch";
|
|
11
13
|
import { login, logout, whoami } from "./commands/login";
|
|
12
14
|
import { logs } from "./commands/logs";
|
|
@@ -37,6 +39,8 @@ const commands = {
|
|
|
37
39
|
env,
|
|
38
40
|
events,
|
|
39
41
|
exec,
|
|
42
|
+
flags,
|
|
43
|
+
gateway,
|
|
40
44
|
hatch,
|
|
41
45
|
login,
|
|
42
46
|
logout,
|
|
@@ -72,6 +76,8 @@ function printHelp(): void {
|
|
|
72
76
|
console.log(" env Manage the default CLI environment");
|
|
73
77
|
console.log(" events Stream events from a running assistant");
|
|
74
78
|
console.log(" exec Execute a command inside an assistant's container");
|
|
79
|
+
console.log(" flags Show and toggle feature flags");
|
|
80
|
+
console.log(" gateway Gateway management commands");
|
|
75
81
|
console.log(" hatch Create a new assistant instance");
|
|
76
82
|
console.log(" logs View logs from an assistant instance");
|
|
77
83
|
console.log(" login Log in to the Vellum platform");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { consoleLifecycleReporter } from "../lifecycle-reporter.js";
|
|
4
|
+
|
|
5
|
+
describe("consoleLifecycleReporter", () => {
|
|
6
|
+
const originalDesktopApp = process.env.VELLUM_DESKTOP_APP;
|
|
7
|
+
let stdoutWriteSpy: ReturnType<typeof spyOn>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
11
|
+
() => true,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
stdoutWriteSpy.mockRestore();
|
|
17
|
+
if (originalDesktopApp === undefined) {
|
|
18
|
+
delete process.env.VELLUM_DESKTOP_APP;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.VELLUM_DESKTOP_APP = originalDesktopApp;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("routes log/warn/error to the matching console methods", () => {
|
|
25
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
26
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
27
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
28
|
+
|
|
29
|
+
consoleLifecycleReporter.log("hello");
|
|
30
|
+
consoleLifecycleReporter.warn("careful");
|
|
31
|
+
consoleLifecycleReporter.error("boom");
|
|
32
|
+
|
|
33
|
+
expect(logSpy).toHaveBeenCalledWith("hello");
|
|
34
|
+
expect(warnSpy).toHaveBeenCalledWith("careful");
|
|
35
|
+
expect(errorSpy).toHaveBeenCalledWith("boom");
|
|
36
|
+
|
|
37
|
+
logSpy.mockRestore();
|
|
38
|
+
warnSpy.mockRestore();
|
|
39
|
+
errorSpy.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => {
|
|
43
|
+
process.env.VELLUM_DESKTOP_APP = "1";
|
|
44
|
+
|
|
45
|
+
consoleLifecycleReporter.progress(3, 6, "Starting assistant...");
|
|
46
|
+
|
|
47
|
+
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
|
48
|
+
`HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("suppresses progress output when not running under the desktop app", () => {
|
|
53
|
+
delete process.env.VELLUM_DESKTOP_APP;
|
|
54
|
+
|
|
55
|
+
consoleLifecycleReporter.progress(1, 6, "Allocating resources...");
|
|
56
|
+
|
|
57
|
+
expect(stdoutWriteSpy).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
1
5
|
import { describe, expect, it } from "bun:test";
|
|
2
6
|
|
|
3
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildExecErrorMessage,
|
|
9
|
+
exec,
|
|
10
|
+
execOutput,
|
|
11
|
+
execWithStdin,
|
|
12
|
+
} from "../step-runner";
|
|
4
13
|
|
|
5
14
|
describe("buildExecErrorMessage", () => {
|
|
6
15
|
it("omits the argv from the header so secrets in args can't leak", () => {
|
|
@@ -63,6 +72,45 @@ describe("exec — secret leak regression", () => {
|
|
|
63
72
|
});
|
|
64
73
|
});
|
|
65
74
|
|
|
75
|
+
describe("execWithStdin — pipes input + no secret leak in errors", () => {
|
|
76
|
+
it("writes the supplied input to the child's stdin", async () => {
|
|
77
|
+
// Use sh `cat > path` to capture stdin to a real file we can inspect.
|
|
78
|
+
// Mirrors the Docker-hatch overlay-staging call site shape.
|
|
79
|
+
const workDir = mkdtempSync(join(tmpdir(), "step-runner-stdin-"));
|
|
80
|
+
const dest = join(workDir, "captured.txt");
|
|
81
|
+
try {
|
|
82
|
+
const payload = '{"hello":"world"}\n';
|
|
83
|
+
await execWithStdin("sh", ["-c", `cat > ${dest}`], payload);
|
|
84
|
+
expect(readFileSync(dest, "utf-8")).toBe(payload);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
91
|
+
const fakeSecret = "sk-anthropic-stdin-canary";
|
|
92
|
+
try {
|
|
93
|
+
await execWithStdin(
|
|
94
|
+
"sh",
|
|
95
|
+
[
|
|
96
|
+
"-c",
|
|
97
|
+
'echo "permission denied while trying to connect to docker daemon" 1>&2 && exit 1',
|
|
98
|
+
"-e",
|
|
99
|
+
`ANTHROPIC_API_KEY=${fakeSecret}`,
|
|
100
|
+
],
|
|
101
|
+
"",
|
|
102
|
+
);
|
|
103
|
+
throw new Error("execWithStdin should have rejected");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
expect(message).not.toContain(fakeSecret);
|
|
107
|
+
expect(message).not.toContain("ANTHROPIC_API_KEY");
|
|
108
|
+
expect(message).toContain("sh exited with code 1");
|
|
109
|
+
expect(message).toContain("permission denied");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
66
114
|
describe("execOutput — secret leak regression", () => {
|
|
67
115
|
it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
|
|
68
116
|
const fakeSecret = "sk-openai-leak-canary";
|