@vellumai/cli 0.7.2 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2201,6 +2201,9 @@ describe("platform credential injection", () => {
2201
2201
  "device-id-123",
2202
2202
  "my-local",
2203
2203
  "cli",
2204
+ undefined, // assistantVersion (gateway unreachable in test)
2205
+ expect.any(String), // platformUrl from getPlatformUrl()
2206
+ undefined, // ingressUrl (gateway unreachable in test)
2204
2207
  );
2205
2208
  expect(injectCredentialsIntoAssistantMock).toHaveBeenCalledWith({
2206
2209
  gatewayUrl: "http://localhost:7821",
@@ -12,12 +12,19 @@ import {
12
12
  } from "../lib/constants";
13
13
  import { loadGuardianToken } from "../lib/guardian-token";
14
14
  import { getLocalLanIPv4 } from "../lib/local";
15
+ import {
16
+ CLI_INTERFACE_ID,
17
+ getClientRegistrationHeaders,
18
+ } from "../lib/client-identity";
15
19
  import {
16
20
  fetchOrganizationId,
17
21
  readPlatformToken,
18
22
  } from "../lib/platform-client";
19
23
  import { tuiLog } from "../lib/tui-log";
20
24
 
25
+ const SUPPORTED_INTERFACES = ["cli"] as const;
26
+ type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
27
+
21
28
  const ANSI = {
22
29
  reset: "\x1b[0m",
23
30
  bold: "\x1b[1m",
@@ -36,6 +43,8 @@ interface ParsedArgs {
36
43
  platformToken?: string;
37
44
  /** Guardian JWT (Authorization: Bearer), set for local assistants. */
38
45
  bearerToken?: string;
46
+ /** Interface identifier sent as X-Vellum-Interface-Id on all requests. */
47
+ interfaceId: SupportedInterface;
39
48
  project?: string;
40
49
  zone?: string;
41
50
  }
@@ -54,7 +63,9 @@ function parseArgs(): ParsedArgs {
54
63
  (arg === "--url" ||
55
64
  arg === "-u" ||
56
65
  arg === "--assistant-id" ||
57
- arg === "-a") &&
66
+ arg === "-a" ||
67
+ arg === "--interface" ||
68
+ arg === "-i") &&
58
69
  args[i + 1]
59
70
  ) {
60
71
  flagArgs.push(arg, args[++i]);
@@ -109,6 +120,8 @@ function parseArgs(): ParsedArgs {
109
120
  ? undefined
110
121
  : (loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined);
111
122
 
123
+ let interfaceId: SupportedInterface = CLI_INTERFACE_ID;
124
+
112
125
  for (let i = 0; i < flagArgs.length; i++) {
113
126
  const flag = flagArgs[i];
114
127
  if ((flag === "--url" || flag === "-u") && flagArgs[i + 1]) {
@@ -118,6 +131,21 @@ function parseArgs(): ParsedArgs {
118
131
  flagArgs[i + 1]
119
132
  ) {
120
133
  assistantId = flagArgs[++i];
134
+ } else if ((flag === "--interface" || flag === "-i") && flagArgs[i + 1]) {
135
+ const value = flagArgs[++i];
136
+ if (value === "web") {
137
+ console.error(
138
+ `--interface web is not yet supported. Coming soon.`,
139
+ );
140
+ process.exit(1);
141
+ }
142
+ if (!(SUPPORTED_INTERFACES as readonly string[]).includes(value)) {
143
+ console.error(
144
+ `Unknown interface '${value}'. Supported: ${SUPPORTED_INTERFACES.join(", ")}.`,
145
+ );
146
+ process.exit(1);
147
+ }
148
+ interfaceId = value as SupportedInterface;
121
149
  }
122
150
  }
123
151
 
@@ -128,6 +156,7 @@ function parseArgs(): ParsedArgs {
128
156
  cloud,
129
157
  platformToken,
130
158
  bearerToken,
159
+ interfaceId,
131
160
  project: entry?.project,
132
161
  zone: entry?.zone,
133
162
  };
@@ -184,6 +213,7 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
184
213
  ${ANSI.bold}OPTIONS:${ANSI.reset}
185
214
  -u, --url <url> Runtime URL
186
215
  -a, --assistant-id <id> Assistant ID
216
+ -i, --interface <id> Interface identifier (default: cli)
187
217
  -h, --help Show this help message
188
218
 
189
219
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -206,14 +236,22 @@ export async function client(): Promise<void> {
206
236
  cloud,
207
237
  platformToken,
208
238
  bearerToken,
239
+ interfaceId,
209
240
  project,
210
241
  zone,
211
242
  } = parseArgs();
212
243
 
213
244
  tuiLog.init();
214
- tuiLog.info("session start", { runtimeUrl, assistantId, species, cloud });
245
+ tuiLog.info("session start", {
246
+ runtimeUrl,
247
+ assistantId,
248
+ species,
249
+ cloud,
250
+ interfaceId,
251
+ });
215
252
 
216
- // Build pre-constructed auth headers so all fetch sites share a single object.
253
+ // Build pre-constructed request headers merged from auth + client registration.
254
+ // Spreading into every fetch site ensures consistency across REST and SSE endpoints.
217
255
  let auth: Record<string, string> | undefined;
218
256
  if (cloud === "vellum" && platformToken) {
219
257
  const orgId = await fetchOrganizationId(platformToken).catch((err) => {
@@ -223,9 +261,13 @@ export async function client(): Promise<void> {
223
261
  auth = {
224
262
  "X-Session-Token": platformToken,
225
263
  ...(orgId ? { "Vellum-Organization-Id": orgId } : {}),
264
+ ...getClientRegistrationHeaders(interfaceId),
265
+ };
266
+ } else {
267
+ auth = {
268
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
269
+ ...getClientRegistrationHeaders(interfaceId),
226
270
  };
227
- } else if (bearerToken) {
228
- auth = { Authorization: `Bearer ${bearerToken}` };
229
271
  }
230
272
 
231
273
  const { renderChatApp } = await import("../components/DefaultMainScreen");
@@ -139,9 +139,6 @@ export async function events(): Promise<void> {
139
139
  query,
140
140
  headers: getClientRegistrationHeaders(),
141
141
  })) {
142
- if (!event.message) {
143
- continue;
144
- }
145
142
  if (jsonOutput) {
146
143
  console.log(JSON.stringify(event));
147
144
  } else {
@@ -10,6 +10,10 @@ import {
10
10
  setActiveAssistant,
11
11
  } from "../lib/assistant-config";
12
12
  import { computeDeviceId } from "../lib/guardian-token";
13
+ import {
14
+ fetchAssistantIngressUrl,
15
+ fetchCurrentVersion,
16
+ } from "../lib/upgrade-lifecycle.js";
13
17
  import {
14
18
  clearPlatformToken,
15
19
  ensureSelfHostedLocalRegistration,
@@ -210,12 +214,19 @@ export async function login(): Promise<void> {
210
214
  if (entry && entry.cloud !== "vellum") {
211
215
  const orgId = await fetchOrganizationId(token);
212
216
  const clientInstallationId = computeDeviceId();
217
+ const [assistantVersion, ingressUrl] = await Promise.all([
218
+ fetchCurrentVersion(entry.runtimeUrl),
219
+ fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
220
+ ]);
213
221
  const registration = await ensureSelfHostedLocalRegistration(
214
222
  token,
215
223
  orgId,
216
224
  clientInstallationId,
217
225
  entry.assistantId,
218
226
  "cli",
227
+ assistantVersion,
228
+ getPlatformUrl(),
229
+ ingressUrl,
219
230
  );
220
231
  console.log(
221
232
  `Registered assistant: ${registration.assistant.name} (${registration.assistant.id})`,
@@ -340,7 +340,7 @@ export async function rollback(): Promise<void> {
340
340
  const signingKey =
341
341
  capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
342
342
 
343
- // Build extra env vars, excluding keys managed by serviceDockerRunArgs
343
+ // Build extra env vars, excluding keys managed by buildServiceRunArgs
344
344
  const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
345
345
  for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
346
346
  if (process.env[envVar]) {
@@ -47,7 +47,10 @@ import { hatchLocal } from "../lib/hatch-local.js";
47
47
  import { retireLocal } from "../lib/retire-local.js";
48
48
  import { validateAssistantName } from "../lib/retire-archive.js";
49
49
  import { stopProcessByPidFile } from "../lib/process.js";
50
- import { fetchCurrentVersion } from "../lib/upgrade-lifecycle.js";
50
+ import {
51
+ fetchAssistantIngressUrl,
52
+ fetchCurrentVersion,
53
+ } from "../lib/upgrade-lifecycle.js";
51
54
  import { compareVersions } from "../lib/version-compat.js";
52
55
  import { join } from "node:path";
53
56
 
@@ -1066,12 +1069,19 @@ async function tryInjectPlatformCredentials(
1066
1069
  const user = await fetchCurrentUser(token);
1067
1070
  const orgId = await fetchOrganizationId(token);
1068
1071
  const clientInstallationId = computeDeviceId();
1072
+ const [assistantVersion, ingressUrl] = await Promise.all([
1073
+ fetchCurrentVersion(entry.runtimeUrl),
1074
+ fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
1075
+ ]);
1069
1076
  const registration = await ensureSelfHostedLocalRegistration(
1070
1077
  token,
1071
1078
  orgId,
1072
1079
  clientInstallationId,
1073
1080
  entry.assistantId,
1074
1081
  "cli",
1082
+ assistantVersion,
1083
+ getPlatformUrl(),
1084
+ ingressUrl,
1075
1085
  );
1076
1086
 
1077
1087
  // Resolve the API key: 1) fresh from registration, 2) existing from
@@ -429,9 +429,9 @@ async function upgradeDocker(
429
429
 
430
430
  // Build the set of extra env vars to replay on the new assistant container.
431
431
  // Captured env vars serve as the base; keys already managed by
432
- // serviceDockerRunArgs are excluded to avoid duplicates.
432
+ // buildServiceRunArgs are excluded to avoid duplicates.
433
433
  const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
434
- // Only exclude keys that serviceDockerRunArgs will actually set
434
+ // Only exclude keys that buildServiceRunArgs will actually set
435
435
  for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
436
436
  if (process.env[envVar]) {
437
437
  envKeysSetByRunArgs.add(envVar);
@@ -12,7 +12,7 @@ import {
12
12
  import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
13
13
 
14
14
  import { removeAssistantEntry } from "../lib/assistant-config";
15
- import { getClientRegistrationHeaders } from "../lib/client-identity";
15
+
16
16
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
17
17
  import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
18
18
  import { checkHealth } from "../lib/health-check";
@@ -352,13 +352,11 @@ async function* streamEvents(
352
352
  ): AsyncGenerator<SseEvent> {
353
353
  const params = new URLSearchParams({ conversationKey });
354
354
  const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
355
- const clientHeaders = getClientRegistrationHeaders();
356
- tuiLog.info("sse connect", { url, clientHeaders });
355
+ tuiLog.info("sse connect", { url, authHeaders: Object.keys(auth ?? {}) });
357
356
  const response = await fetch(url, {
358
357
  headers: {
359
358
  Accept: "text/event-stream",
360
359
  ...auth,
361
- ...clientHeaders,
362
360
  },
363
361
  signal,
364
362
  });
@@ -4,9 +4,9 @@ import {
4
4
  AVATAR_DEVICE_ENV_VAR,
5
5
  dockerResourceNames,
6
6
  resolveAvatarDevicePath,
7
- serviceDockerRunArgs,
8
7
  type ServiceName,
9
8
  } from "../docker.js";
9
+ import { buildServiceRunArgs } from "../statefulset.js";
10
10
 
11
11
  const instanceName = "test-instance";
12
12
  const imageTags: Record<ServiceName, string> = {
@@ -16,10 +16,10 @@ const imageTags: Record<ServiceName, string> = {
16
16
  };
17
17
 
18
18
  function buildAssistantArgs(
19
- overrides: Partial<Parameters<typeof serviceDockerRunArgs>[0]> = {},
19
+ overrides: Partial<Parameters<typeof buildServiceRunArgs>[0]> = {},
20
20
  ): string[] {
21
21
  const res = dockerResourceNames(instanceName);
22
- const builders = serviceDockerRunArgs({
22
+ const builders = buildServiceRunArgs({
23
23
  gatewayPort: 7830,
24
24
  imageTags,
25
25
  instanceName,
@@ -30,10 +30,10 @@ function buildAssistantArgs(
30
30
  }
31
31
 
32
32
  function buildGatewayArgs(
33
- overrides: Partial<Parameters<typeof serviceDockerRunArgs>[0]> = {},
33
+ overrides: Partial<Parameters<typeof buildServiceRunArgs>[0]> = {},
34
34
  ): string[] {
35
35
  const res = dockerResourceNames(instanceName);
36
- const builders = serviceDockerRunArgs({
36
+ const builders = buildServiceRunArgs({
37
37
  gatewayPort: 7830,
38
38
  imageTags,
39
39
  instanceName,
@@ -43,7 +43,7 @@ function buildGatewayArgs(
43
43
  return builders.gateway();
44
44
  }
45
45
 
46
- describe("serviceDockerRunArgs — assistant", () => {
46
+ describe("buildServiceRunArgs — assistant", () => {
47
47
  test("does not grant elevated capabilities or disable security profiles", () => {
48
48
  const args = buildAssistantArgs();
49
49
  expect(args).not.toContain("--privileged");
@@ -103,7 +103,7 @@ describe("serviceDockerRunArgs — assistant", () => {
103
103
  });
104
104
  });
105
105
 
106
- describe("serviceDockerRunArgs — gateway", () => {
106
+ describe("buildServiceRunArgs — gateway", () => {
107
107
  const savedVelayBaseUrl = process.env.VELAY_BASE_URL;
108
108
 
109
109
  beforeEach(() => {
@@ -11,7 +11,7 @@ import { randomUUID } from "crypto";
11
11
  import { homedir } from "os";
12
12
  import { join } from "path";
13
13
 
14
- const CLI_INTERFACE_ID = "cli";
14
+ export const CLI_INTERFACE_ID = "cli";
15
15
 
16
16
  let cached: string | null = null;
17
17
 
@@ -56,12 +56,16 @@ export function getClientId(): string {
56
56
 
57
57
  /**
58
58
  * Headers that identify this CLI client to the assistant daemon.
59
- * Attach to SSE streaming connections so the ClientRegistry can
60
- * track connected clients and their capabilities.
59
+ * Attach to all requests so the ClientRegistry can track connected
60
+ * clients and their capabilities.
61
+ *
62
+ * @param interfaceId - Override the interface ID (default: "cli").
61
63
  */
62
- export function getClientRegistrationHeaders(): Record<string, string> {
64
+ export function getClientRegistrationHeaders(
65
+ interfaceId: string = CLI_INTERFACE_ID,
66
+ ): Record<string, string> {
63
67
  return {
64
68
  "X-Vellum-Client-Id": getClientId(),
65
- "X-Vellum-Interface-Id": CLI_INTERFACE_ID,
69
+ "X-Vellum-Interface-Id": interfaceId,
66
70
  };
67
71
  }
package/src/lib/docker.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "./assistant-config";
14
14
  import type { AssistantEntry } from "./assistant-config";
15
15
  import { writeInitialConfig } from "./config-utils";
16
- import { buildServiceRunArgs } from "./docker-statefulset.js";
16
+ import { buildServiceRunArgs } from "./statefulset.js";
17
17
  import type { Species } from "./constants";
18
18
  import { getDefaultPorts } from "./environments/paths.js";
19
19
  import { getCurrentEnvironment } from "./environments/resolve.js";
@@ -39,9 +39,8 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
39
39
  gateway: `${DOCKERHUB_ORG}/vellum-gateway`,
40
40
  };
41
41
 
42
- /** Internal ports exposed by each service's Dockerfile. */
43
- export const ASSISTANT_INTERNAL_PORT = 7821;
44
- export const GATEWAY_INTERNAL_PORT = 7830;
42
+ /** Internal ports exposed by each service's Dockerfile. Re-exported from environments/paths.ts. */
43
+ export { ASSISTANT_INTERNAL_PORT, GATEWAY_INTERNAL_PORT } from "./environments/paths.js";
45
44
 
46
45
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
47
46
  export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
@@ -560,28 +559,6 @@ async function buildAllImages(
560
559
  );
561
560
  }
562
561
 
563
- /**
564
- * Build `docker run` argument arrays for each service in the StatefulSet.
565
- *
566
- * Delegates to `buildServiceRunArgs` from `docker-statefulset.ts`, which owns
567
- * the declarative container / volume / env spec. Signature preserved for
568
- * backward compatibility with callers throughout this file.
569
- */
570
- export function serviceDockerRunArgs(opts: {
571
- signingKey?: string;
572
- bootstrapSecret?: string;
573
- cesServiceToken?: string;
574
- extraAssistantEnv?: Record<string, string>;
575
- gatewayPort: number;
576
- imageTags: Record<ServiceName, string>;
577
- defaultWorkspaceConfigPath?: string;
578
- instanceName: string;
579
- res: ReturnType<typeof dockerResourceNames>;
580
- }): Record<ServiceName, () => string[]> {
581
- const avatarDevice = resolveAvatarDevicePath();
582
- return buildServiceRunArgs({ ...opts, avatarDevicePath: avatarDevice });
583
- }
584
-
585
562
  /** The order in which services must be started. */
586
563
  export const SERVICE_START_ORDER: ServiceName[] = [
587
564
  "assistant",
@@ -604,7 +581,7 @@ export async function startContainers(
604
581
  },
605
582
  log: (msg: string) => void,
606
583
  ): Promise<void> {
607
- const runArgs = serviceDockerRunArgs(opts);
584
+ const runArgs = buildServiceRunArgs({ ...opts, avatarDevicePath: resolveAvatarDevicePath() });
608
585
  for (const service of SERVICE_START_ORDER) {
609
586
  log(`🚀 Starting ${service} container...`);
610
587
  await exec("docker", runArgs[service]());
@@ -782,7 +759,7 @@ function startFileWatcher(opts: {
782
759
  let rebuilding = false;
783
760
 
784
761
  const configs = serviceImageConfigs(repoRoot, imageTags);
785
- const runArgs = serviceDockerRunArgs({
762
+ const runArgs = buildServiceRunArgs({
786
763
  signingKey: opts.signingKey,
787
764
  bootstrapSecret: opts.bootstrapSecret,
788
765
  cesServiceToken: opts.cesServiceToken,
@@ -790,6 +767,7 @@ function startFileWatcher(opts: {
790
767
  imageTags,
791
768
  instanceName,
792
769
  res,
770
+ avatarDevicePath: resolveAvatarDevicePath(),
793
771
  });
794
772
  const containerForService: Record<ServiceName, string> = {
795
773
  assistant: res.assistantContainer,
@@ -98,6 +98,26 @@ export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
98
98
  };
99
99
  }
100
100
 
101
+ /**
102
+ * Runtime state directory for an environment (upgrade logs, etc.).
103
+ * Production uses `~/.local/share/vellum/`; non-production environments
104
+ * use `~/.local/share/vellum-<env>/`.
105
+ */
106
+ export function getStateDir(env: EnvironmentDefinition): string {
107
+ if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
108
+ return join(xdgDataHome(), "vellum");
109
+ }
110
+ return join(xdgDataHome(), `vellum-${env.name}`);
111
+ }
112
+
113
+ /**
114
+ * Named port constants derived from `DEFAULT_PORTS`.
115
+ * These are the ports the assistant and gateway services bind to *inside*
116
+ * their container (or process). They are stable across environments.
117
+ */
118
+ export const ASSISTANT_INTERNAL_PORT = DEFAULT_PORTS.daemon;
119
+ export const GATEWAY_INTERNAL_PORT = DEFAULT_PORTS.gateway;
120
+
101
121
  function xdgDataHome(): string {
102
122
  return (
103
123
  process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
package/src/lib/local.ts CHANGED
@@ -840,6 +840,42 @@ export function isGatewayWatchModeAvailable(): boolean {
840
840
  }
841
841
  }
842
842
 
843
+ /**
844
+ * Write (or overwrite) a shell wrapper at `<workspace>/bin/assistant` that
845
+ * pre-injects the three instance-specific env vars before exec-ing the real
846
+ * assistant binary from the app bundle.
847
+ *
848
+ * This lets developers invoke `<workspace>/bin/assistant <command>` directly
849
+ * from the terminal without manually setting env vars. Only created when a
850
+ * compiled `assistant` binary is present adjacent to the CLI executable (i.e.
851
+ * inside a desktop app bundle) — a no-op in source/watch mode.
852
+ *
853
+ * The wrapper is idempotent: safe to call on every daemon wake.
854
+ */
855
+ function writeAssistantWrapper(resources: LocalInstanceResources): void {
856
+ const assistantBinary = join(dirname(process.execPath), "assistant");
857
+ if (!existsSync(assistantBinary)) return;
858
+
859
+ const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
860
+ const protectedDir = join(resources.instanceDir, ".vellum", "protected");
861
+ const binDir = join(workspaceDir, "bin");
862
+
863
+ mkdirSync(binDir, { recursive: true });
864
+ const wrapperPath = join(binDir, "assistant");
865
+ writeFileSync(
866
+ wrapperPath,
867
+ [
868
+ "#!/bin/sh",
869
+ `export VELLUM_WORKSPACE_DIR="${workspaceDir}"`,
870
+ `export CREDENTIAL_SECURITY_DIR="${protectedDir}"`,
871
+ `export GATEWAY_SECURITY_DIR="${protectedDir}"`,
872
+ `exec "${assistantBinary}" "$@"`,
873
+ "",
874
+ ].join("\n"),
875
+ { mode: 0o755 },
876
+ );
877
+ }
878
+
843
879
  // NOTE: startLocalDaemon() is the CLI-side daemon lifecycle manager.
844
880
  // It should eventually converge with
845
881
  // assistant/src/daemon/daemon-control.ts::startDaemon which is the
@@ -850,6 +886,7 @@ export async function startLocalDaemon(
850
886
  options?: DaemonStartOptions,
851
887
  ): Promise<void> {
852
888
  warnIfLegacyWorkspaceFallbackDetected(resources);
889
+ writeAssistantWrapper(resources);
853
890
 
854
891
  const foreground = options?.foreground ?? false;
855
892
  // Check for a compiled daemon binary adjacent to the CLI executable.
@@ -213,6 +213,7 @@ export async function ensureSelfHostedLocalRegistration(
213
213
  clientPlatform: string,
214
214
  assistantVersion?: string,
215
215
  platformUrl?: string,
216
+ publicBaseUrl?: string,
216
217
  ): Promise<EnsureRegistrationResponse> {
217
218
  const resolvedUrl = platformUrl || getPlatformUrl();
218
219
  const body: Record<string, string> = {
@@ -223,6 +224,9 @@ export async function ensureSelfHostedLocalRegistration(
223
224
  if (assistantVersion) {
224
225
  body.assistant_version = assistantVersion;
225
226
  }
227
+ if (publicBaseUrl) {
228
+ body.public_ingress_url = publicBaseUrl;
229
+ }
226
230
 
227
231
  const response = await fetch(
228
232
  `${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
@@ -1,25 +1,19 @@
1
1
  /**
2
- * Declarative StatefulSet spec for the Docker service group.
2
+ * Declarative StatefulSet spec for the assistant service group.
3
3
  *
4
- * Mirrors the schema of the platform's `stateful_template.yaml` (vembda).
5
- * Container names, volume names, and env var names are kept in sync with
6
- * the K8s template so the two topologies are auditable side-by-side.
7
- *
8
- * This file is self-contained — it does not import from `docker.ts` to
9
- * avoid a circular dependency. Constants are inlined from their definitions
10
- * in `docker.ts` and must be kept in sync if those change.
4
+ * Defines the static topology of all three containers (assistant, gateway,
5
+ * credential-executor), their volumes, ports, and env vars. Used by both
6
+ * the hatch and upgrade flows to build `docker run` argument arrays.
11
7
  */
12
8
 
13
9
  import { existsSync } from "fs";
14
10
 
11
+ import {
12
+ ASSISTANT_INTERNAL_PORT,
13
+ GATEWAY_INTERNAL_PORT,
14
+ } from "./environments/paths.js";
15
15
  import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
16
16
 
17
- // ---------------------------------------------------------------------------
18
- // Constants (mirrored from docker.ts — keep in sync)
19
- // ---------------------------------------------------------------------------
20
-
21
- const GATEWAY_INTERNAL_PORT = 7830;
22
- const ASSISTANT_INTERNAL_PORT = 7821;
23
17
  const AVATAR_DEVICE_ENV_VAR = "VELLUM_AVATAR_DEVICE";
24
18
 
25
19
  /** Logical service name used throughout the CLI. */
@@ -1,7 +1,6 @@
1
1
  import { randomBytes } from "crypto";
2
2
  import { spawnSync } from "child_process";
3
3
  import { existsSync, mkdirSync, writeFileSync } from "fs";
4
- import { homedir } from "os";
5
4
  import { join } from "path";
6
5
 
7
6
  import type { AssistantEntry } from "./assistant-config.js";
@@ -16,6 +15,8 @@ import {
16
15
  startContainers,
17
16
  stopContainers,
18
17
  } from "./docker.js";
18
+ import { getStateDir } from "./environments/paths.js";
19
+ import { getCurrentEnvironment } from "./environments/resolve.js";
19
20
  import { loadGuardianToken } from "./guardian-token.js";
20
21
  import { getPlatformUrl } from "./platform-client.js";
21
22
  import { resolveImageRefs } from "./platform-releases.js";
@@ -26,11 +27,9 @@ import { compareVersions } from "./version-compat.js";
26
27
  // Failure log capture
27
28
  // ---------------------------------------------------------------------------
28
29
 
29
- /** XDG-compliant directory for upgrade failure logs */
30
+ /** XDG-compliant directory for upgrade failure logs, scoped to the current environment. */
30
31
  function getUpgradeLogsDir(): string {
31
- const stateHome =
32
- process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state");
33
- return join(stateHome, "vellum", "upgrade-logs");
32
+ return join(getStateDir(getCurrentEnvironment()), "upgrade-logs");
34
33
  }
35
34
 
36
35
  /**
@@ -207,6 +206,38 @@ export async function fetchCurrentVersion(
207
206
  return undefined;
208
207
  }
209
208
 
209
+ /**
210
+ * Best-effort fetch of the assistant's configured public ingress URL from the
211
+ * gateway `integrations/ingress/config` endpoint. Returns `undefined` when
212
+ * the gateway is unreachable, the bearer token is missing, or no public URL
213
+ * is configured.
214
+ */
215
+ export async function fetchAssistantIngressUrl(
216
+ runtimeUrl: string,
217
+ bearerToken?: string,
218
+ ): Promise<string | undefined> {
219
+ if (!bearerToken) return undefined;
220
+ try {
221
+ const resp = await fetch(`${runtimeUrl}/integrations/ingress/config`, {
222
+ headers: { Authorization: `Bearer ${bearerToken}` },
223
+ signal: AbortSignal.timeout(5000),
224
+ });
225
+ if (resp.ok) {
226
+ const body = (await resp.json()) as {
227
+ publicBaseUrl?: string;
228
+ managedCallbacks?: boolean;
229
+ };
230
+ // Ignore managed-callback URLs — those belong to the platform, not the
231
+ // self-hosted assistant's own ingress.
232
+ if (body.managedCallbacks) return undefined;
233
+ return body.publicBaseUrl || undefined;
234
+ }
235
+ } catch {
236
+ // Best-effort
237
+ }
238
+ return undefined;
239
+ }
240
+
210
241
  /**
211
242
  * Determine the version that was running before the current one.
212
243
  *
@@ -605,7 +636,7 @@ export async function performDockerRollback(
605
636
  const signingKey =
606
637
  capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
607
638
 
608
- // Build extra env vars, excluding keys managed by serviceDockerRunArgs
639
+ // Build extra env vars, excluding keys managed by buildServiceRunArgs
609
640
  const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
610
641
  for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
611
642
  if (process.env[envVar]) {