@vellumai/cli 0.6.2 → 0.6.4
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 +12 -2
- package/README.md +3 -3
- package/bunfig.toml +6 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +172 -0
- package/src/__tests__/multi-local.test.ts +61 -14
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -57
- package/src/commands/backup.ts +8 -0
- package/src/commands/hatch.ts +5 -28
- package/src/commands/login.ts +178 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +124 -12
- package/src/commands/retire.ts +17 -3
- package/src/commands/rollback.ts +32 -33
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh-apple-container.ts +162 -0
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +307 -3
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +21 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +78 -0
- package/src/lib/assistant-config.ts +54 -87
- package/src/lib/aws.ts +12 -1
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +73 -4
- package/src/lib/environments/__tests__/paths.test.ts +234 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/paths.ts +110 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +46 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +8 -10
- package/src/lib/hatch-local.ts +30 -35
- package/src/lib/local.ts +46 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +261 -25
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
package/AGENTS.md
CHANGED
|
@@ -51,10 +51,20 @@ For example, the signing key used for JWT auth between the daemon and gateway is
|
|
|
51
51
|
|
|
52
52
|
The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
|
|
53
53
|
|
|
54
|
-
**Volume creation** (`hatch`): Creates
|
|
54
|
+
**Volume creation** (`hatch`): Creates five volumes per instance — workspace, gateway-security, ces-security, socket, and dockerd-data (the last backs the inner Docker engine used for Meet; see below). The legacy data volume is no longer created.
|
|
55
55
|
|
|
56
56
|
**Volume migration** (`wake`/`hatch`): On startup, existing instances that still have a legacy data volume are migrated. `migrateGatewaySecurityFiles()` and `migrateCesSecurityFiles()` in `lib/docker.ts` copy security files from the data volume to their respective security volumes. Migrations are idempotent and non-fatal.
|
|
57
57
|
|
|
58
58
|
**Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
|
|
59
59
|
|
|
60
|
-
**Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
|
|
60
|
+
**Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only. The `dockerd-data` volume is mounted only on the assistant container.
|
|
61
|
+
|
|
62
|
+
**Meet Docker-in-Docker support** (assistant container only): The assistant container runs an inner `dockerd` that hosts the Meet-bot containers as nested children. The CLI supports this by:
|
|
63
|
+
|
|
64
|
+
- Creating a dedicated `<name>-dockerd-data` volume mounted at `/var/lib/docker` so pulled images and container state persist across assistant restarts.
|
|
65
|
+
- Running the assistant container with `--privileged` (or `CAP_SYS_ADMIN` + `CAP_NET_ADMIN`) so the inner dockerd can configure cgroups, overlay mounts, and container networking.
|
|
66
|
+
- No longer bind-mounting the host's `/var/run/docker.sock`; Meet-bot spawning happens entirely inside the assistant container.
|
|
67
|
+
|
|
68
|
+
Both are wired in `serviceDockerRunArgs()` in `lib/docker.ts`.
|
|
69
|
+
|
|
70
|
+
The privileged assistant container is acceptable for single-user local deployments. Managed/multi-tenant mode needs a different spawn model (e.g. a Kubernetes job runner) and is out of scope for this CLI.
|
package/README.md
CHANGED
|
@@ -105,12 +105,12 @@ Delete a provisioned assistant instance. The cloud provider and connection detai
|
|
|
105
105
|
vellum retire <name>
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
The CLI looks up the instance by name in `~/.vellum.lock.json`
|
|
108
|
+
The CLI looks up the instance by name in the production lockfile (`~/.vellum.lock.json`) or the env-scoped lockfile under `$XDG_CONFIG_HOME/vellum-<env>/lockfile.json` for non-production environments, then determines how to retire it based on the saved `cloud` field:
|
|
109
109
|
|
|
110
110
|
- **`gcp`** -- Deletes the GCP Compute Engine instance via `gcloud compute instances delete`.
|
|
111
111
|
- **`aws`** -- Terminates the AWS EC2 instance by looking up the instance ID from its Name tag.
|
|
112
|
-
- **`local`** -- Stops the local assistant (`vellum sleep`) and removes the `~/.vellum
|
|
113
|
-
- **`custom`** -- SSHs to the remote host to stop the assistant/gateway and remove the `~/.vellum` directory.
|
|
112
|
+
- **`local`** -- Stops the local assistant (`vellum sleep`) and removes the assistant's instance directory (`resources.instanceDir` in the lockfile; typically `~/.local/share/vellum/assistants/<name>/` for new hatches, or `~/.vellum/` for legacy entries).
|
|
113
|
+
- **`custom`** -- SSHs to the remote host to stop the assistant/gateway and remove the remote `~/.vellum` directory.
|
|
114
114
|
|
|
115
115
|
#### Examples
|
|
116
116
|
|
package/bunfig.toml
ADDED
package/package.json
CHANGED
|
@@ -474,3 +474,127 @@ describe("legacy migration via loadAllAssistants", () => {
|
|
|
474
474
|
);
|
|
475
475
|
});
|
|
476
476
|
});
|
|
477
|
+
|
|
478
|
+
describe("env-scoped lockfile and migration", () => {
|
|
479
|
+
test("migrateLegacyEntry uses env-scoped multi-instance dir in non-prod", () => {
|
|
480
|
+
// GIVEN VELLUM_ENVIRONMENT=dev and an XDG_DATA_HOME override
|
|
481
|
+
const prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
482
|
+
const prevXdg = process.env.XDG_DATA_HOME;
|
|
483
|
+
const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-xdg-data-"));
|
|
484
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
485
|
+
process.env.XDG_DATA_HOME = xdgDataHome;
|
|
486
|
+
try {
|
|
487
|
+
// AND a legacy local entry with no resources
|
|
488
|
+
const entry: Record<string, unknown> = {
|
|
489
|
+
assistantId: "dev-bot",
|
|
490
|
+
runtimeUrl: "http://localhost:7830",
|
|
491
|
+
cloud: "local",
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// WHEN we migrate it
|
|
495
|
+
const changed = migrateLegacyEntry(entry);
|
|
496
|
+
|
|
497
|
+
// THEN resources.instanceDir points at the env-scoped multi-instance dir
|
|
498
|
+
expect(changed).toBe(true);
|
|
499
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
500
|
+
expect(resources.instanceDir).toBe(
|
|
501
|
+
join(xdgDataHome, "vellum-dev", "assistants", "dev-bot"),
|
|
502
|
+
);
|
|
503
|
+
} finally {
|
|
504
|
+
if (prevEnv !== undefined) {
|
|
505
|
+
process.env.VELLUM_ENVIRONMENT = prevEnv;
|
|
506
|
+
} else {
|
|
507
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
508
|
+
}
|
|
509
|
+
if (prevXdg !== undefined) {
|
|
510
|
+
process.env.XDG_DATA_HOME = prevXdg;
|
|
511
|
+
} else {
|
|
512
|
+
delete process.env.XDG_DATA_HOME;
|
|
513
|
+
}
|
|
514
|
+
rmSync(xdgDataHome, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("readLockfile/writeLockfile use $XDG_CONFIG_HOME/vellum-<env>/lockfile.json in non-prod", () => {
|
|
519
|
+
// The env package's xdgConfigHome() reads process.env.XDG_CONFIG_HOME
|
|
520
|
+
// fresh on every call, so redirecting via that env var works without
|
|
521
|
+
// mocking `os`. We temporarily unset VELLUM_LOCKFILE_DIR so the env
|
|
522
|
+
// package falls through to getConfigDir(env).
|
|
523
|
+
const prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
524
|
+
const prevXdgConfig = process.env.XDG_CONFIG_HOME;
|
|
525
|
+
const prevLockDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
526
|
+
const xdgConfigHome = mkdtempSync(join(tmpdir(), "cli-xdg-config-"));
|
|
527
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
528
|
+
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
529
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
530
|
+
try {
|
|
531
|
+
// WHEN we save an assistant entry (which triggers writeLockfile)
|
|
532
|
+
saveAssistantEntry({
|
|
533
|
+
assistantId: "dev-env-bot",
|
|
534
|
+
runtimeUrl: "http://localhost:7830",
|
|
535
|
+
cloud: "local",
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// THEN the lockfile lives at the env-scoped XDG config path
|
|
539
|
+
const expectedPath = join(xdgConfigHome, "vellum-dev", "lockfile.json");
|
|
540
|
+
const raw = JSON.parse(readFileSync(expectedPath, "utf-8"));
|
|
541
|
+
expect(raw.assistants).toHaveLength(1);
|
|
542
|
+
expect(raw.assistants[0].assistantId).toBe("dev-env-bot");
|
|
543
|
+
|
|
544
|
+
// AND readLockfile reads it back via loadAllAssistants()
|
|
545
|
+
const all = loadAllAssistants();
|
|
546
|
+
expect(all).toHaveLength(1);
|
|
547
|
+
expect(all[0].assistantId).toBe("dev-env-bot");
|
|
548
|
+
} finally {
|
|
549
|
+
if (prevEnv !== undefined) {
|
|
550
|
+
process.env.VELLUM_ENVIRONMENT = prevEnv;
|
|
551
|
+
} else {
|
|
552
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
553
|
+
}
|
|
554
|
+
if (prevXdgConfig !== undefined) {
|
|
555
|
+
process.env.XDG_CONFIG_HOME = prevXdgConfig;
|
|
556
|
+
} else {
|
|
557
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
558
|
+
}
|
|
559
|
+
if (prevLockDir !== undefined) {
|
|
560
|
+
process.env.VELLUM_LOCKFILE_DIR = prevLockDir;
|
|
561
|
+
}
|
|
562
|
+
rmSync(xdgConfigHome, { recursive: true, force: true });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("production lockfile path is unchanged — uses .vellum.lock.json", () => {
|
|
567
|
+
// With VELLUM_ENVIRONMENT unset, the env package resolves production,
|
|
568
|
+
// whose canonical lockfile filename is `.vellum.lock.json`. This test
|
|
569
|
+
// uses the existing VELLUM_LOCKFILE_DIR=testDir override to route the
|
|
570
|
+
// lockfile to a scratch directory but verifies the FILENAME matches
|
|
571
|
+
// the production convention (not the non-prod "lockfile.json").
|
|
572
|
+
const prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
573
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
574
|
+
try {
|
|
575
|
+
// Clear any existing lockfile from earlier tests
|
|
576
|
+
try {
|
|
577
|
+
rmSync(join(testDir, ".vellum.lock.json"));
|
|
578
|
+
} catch {
|
|
579
|
+
// ignore
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
saveAssistantEntry({
|
|
583
|
+
assistantId: "prod-bot",
|
|
584
|
+
runtimeUrl: "http://localhost:7830",
|
|
585
|
+
cloud: "local",
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// The file should land at testDir/.vellum.lock.json — the production
|
|
589
|
+
// filename — rather than testDir/lockfile.json.
|
|
590
|
+
const prodPath = join(testDir, ".vellum.lock.json");
|
|
591
|
+
const raw = JSON.parse(readFileSync(prodPath, "utf-8"));
|
|
592
|
+
expect(raw.assistants).toHaveLength(1);
|
|
593
|
+
expect(raw.assistants[0].assistantId).toBe("prod-bot");
|
|
594
|
+
} finally {
|
|
595
|
+
if (prevEnv !== undefined) {
|
|
596
|
+
process.env.VELLUM_ENVIRONMENT = prevEnv;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { SEEDS } from "../lib/environments/seeds.js";
|
|
6
|
+
|
|
7
|
+
// Drift guard for the three TypeScript sites that each hardcode the set of
|
|
8
|
+
// known environment names:
|
|
9
|
+
//
|
|
10
|
+
// 1. cli/src/lib/environments/seeds.ts — SEEDS record (source of truth)
|
|
11
|
+
// 2. assistant/src/util/platform.ts — KNOWN_ENVIRONMENTS set
|
|
12
|
+
// 3. clients/chrome-extension/native-host/
|
|
13
|
+
// src/lockfile.ts — NON_PRODUCTION_ENVIRONMENTS set
|
|
14
|
+
//
|
|
15
|
+
// Cross-package relative imports don't work here: assistant's tsconfig
|
|
16
|
+
// restricts `include` to its own src tree, and the native host is a
|
|
17
|
+
// standalone TS project with `rootDir: ./src`. So this test parses the
|
|
18
|
+
// literal sets out of the two external files and asserts they agree with
|
|
19
|
+
// CLI's SEEDS.
|
|
20
|
+
//
|
|
21
|
+
// FOLLOW-UP: split the env name list into a shared `packages/environments`
|
|
22
|
+
// package (mirroring `packages/ces-contracts`, `credential-storage`) so
|
|
23
|
+
// all three sites can `import { KNOWN_ENVIRONMENTS }` from one place and
|
|
24
|
+
// this drift guard becomes a compile-time check. Planned alongside CLI-
|
|
25
|
+
// driven context support — see the "Environments" design doc.
|
|
26
|
+
|
|
27
|
+
const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
|
|
28
|
+
const ASSISTANT_PLATFORM = join(
|
|
29
|
+
REPO_ROOT,
|
|
30
|
+
"assistant",
|
|
31
|
+
"src",
|
|
32
|
+
"util",
|
|
33
|
+
"platform.ts",
|
|
34
|
+
);
|
|
35
|
+
const NATIVE_HOST_LOCKFILE = join(
|
|
36
|
+
REPO_ROOT,
|
|
37
|
+
"clients",
|
|
38
|
+
"chrome-extension",
|
|
39
|
+
"native-host",
|
|
40
|
+
"src",
|
|
41
|
+
"lockfile.ts",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract the string literals from a Set constructor body in a TS source
|
|
46
|
+
* file. Looks for `<setName>: ReadonlySet<string> = new Set([ ... ])` and
|
|
47
|
+
* pulls out every `"..."` entry within the array. The match is anchored to
|
|
48
|
+
* the `setName` to avoid picking up unrelated sets that happen to live in
|
|
49
|
+
* the same file.
|
|
50
|
+
*/
|
|
51
|
+
function extractSetLiterals(source: string, setName: string): string[] {
|
|
52
|
+
const pattern = new RegExp(
|
|
53
|
+
`${setName}\\s*:\\s*ReadonlySet<string>\\s*=\\s*new Set\\(\\[([^\\]]*)\\]`,
|
|
54
|
+
"m",
|
|
55
|
+
);
|
|
56
|
+
const match = source.match(pattern);
|
|
57
|
+
if (!match) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Could not find Set literal for ${setName}. Update the drift-guard regex in env-drift.test.ts.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const body = match[1];
|
|
63
|
+
const literals = body.match(/"([^"]+)"/g) ?? [];
|
|
64
|
+
return literals.map((lit) => lit.slice(1, -1));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("KNOWN_ENVIRONMENTS drift guard (TS-side)", () => {
|
|
68
|
+
const seedNames = new Set(Object.keys(SEEDS));
|
|
69
|
+
|
|
70
|
+
test("assistant/src/util/platform.ts KNOWN_ENVIRONMENTS matches CLI SEEDS", () => {
|
|
71
|
+
const source = readFileSync(ASSISTANT_PLATFORM, "utf8");
|
|
72
|
+
const assistantNames = new Set(
|
|
73
|
+
extractSetLiterals(source, "KNOWN_ENVIRONMENTS"),
|
|
74
|
+
);
|
|
75
|
+
expect([...assistantNames].sort()).toEqual([...seedNames].sort());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("native-host/src/lockfile.ts NON_PRODUCTION_ENVIRONMENTS matches CLI SEEDS minus production", () => {
|
|
79
|
+
const source = readFileSync(NATIVE_HOST_LOCKFILE, "utf8");
|
|
80
|
+
const nativeNames = new Set(
|
|
81
|
+
extractSetLiterals(source, "NON_PRODUCTION_ENVIRONMENTS"),
|
|
82
|
+
);
|
|
83
|
+
const expected = new Set(seedNames);
|
|
84
|
+
expected.delete("production");
|
|
85
|
+
expect([...nativeNames].sort()).toEqual([...expected].sort());
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getOrCreatePersistedDeviceId,
|
|
8
|
+
loadGuardianToken,
|
|
9
|
+
saveGuardianToken,
|
|
10
|
+
type GuardianTokenData,
|
|
11
|
+
} from "../lib/guardian-token.js";
|
|
12
|
+
|
|
13
|
+
function makeTokenData(suffix: string): GuardianTokenData {
|
|
14
|
+
const now = new Date().toISOString();
|
|
15
|
+
return {
|
|
16
|
+
guardianPrincipalId: `principal-${suffix}`,
|
|
17
|
+
accessToken: `access-${suffix}`,
|
|
18
|
+
accessTokenExpiresAt: now,
|
|
19
|
+
refreshToken: `refresh-${suffix}`,
|
|
20
|
+
refreshTokenExpiresAt: now,
|
|
21
|
+
refreshAfter: now,
|
|
22
|
+
isNew: true,
|
|
23
|
+
deviceId: `device-${suffix}`,
|
|
24
|
+
leasedAt: now,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("guardian-token paths are env-scoped", () => {
|
|
29
|
+
let tempHome: string;
|
|
30
|
+
let savedXdg: string | undefined;
|
|
31
|
+
let savedEnv: string | undefined;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
savedXdg = process.env.XDG_CONFIG_HOME;
|
|
35
|
+
savedEnv = process.env.VELLUM_ENVIRONMENT;
|
|
36
|
+
tempHome = mkdtempSync(join(tmpdir(), "cli-guardian-token-test-"));
|
|
37
|
+
process.env.XDG_CONFIG_HOME = tempHome;
|
|
38
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (savedXdg === undefined) {
|
|
43
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
44
|
+
} else {
|
|
45
|
+
process.env.XDG_CONFIG_HOME = savedXdg;
|
|
46
|
+
}
|
|
47
|
+
if (savedEnv === undefined) {
|
|
48
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
49
|
+
} else {
|
|
50
|
+
process.env.VELLUM_ENVIRONMENT = savedEnv;
|
|
51
|
+
}
|
|
52
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("prod: guardian token lands at $XDG_CONFIG_HOME/vellum/assistants/<id>/guardian-token.json", () => {
|
|
56
|
+
const data = makeTokenData("prod");
|
|
57
|
+
saveGuardianToken("alpha", data);
|
|
58
|
+
|
|
59
|
+
const prodPath = join(
|
|
60
|
+
tempHome,
|
|
61
|
+
"vellum",
|
|
62
|
+
"assistants",
|
|
63
|
+
"alpha",
|
|
64
|
+
"guardian-token.json",
|
|
65
|
+
);
|
|
66
|
+
expect(existsSync(prodPath)).toBe(true);
|
|
67
|
+
const parsed = JSON.parse(readFileSync(prodPath, "utf-8"));
|
|
68
|
+
expect(parsed.guardianPrincipalId).toBe("principal-prod");
|
|
69
|
+
|
|
70
|
+
const loaded = loadGuardianToken("alpha");
|
|
71
|
+
expect(loaded).not.toBeNull();
|
|
72
|
+
expect(loaded!.guardianPrincipalId).toBe("principal-prod");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("dev: guardian token lands at $XDG_CONFIG_HOME/vellum-dev/assistants/<id>/guardian-token.json", () => {
|
|
76
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
77
|
+
const data = makeTokenData("dev");
|
|
78
|
+
saveGuardianToken("alpha", data);
|
|
79
|
+
|
|
80
|
+
const devPath = join(
|
|
81
|
+
tempHome,
|
|
82
|
+
"vellum-dev",
|
|
83
|
+
"assistants",
|
|
84
|
+
"alpha",
|
|
85
|
+
"guardian-token.json",
|
|
86
|
+
);
|
|
87
|
+
expect(existsSync(devPath)).toBe(true);
|
|
88
|
+
|
|
89
|
+
// Prod directory must NOT have this token
|
|
90
|
+
const prodPath = join(
|
|
91
|
+
tempHome,
|
|
92
|
+
"vellum",
|
|
93
|
+
"assistants",
|
|
94
|
+
"alpha",
|
|
95
|
+
"guardian-token.json",
|
|
96
|
+
);
|
|
97
|
+
expect(existsSync(prodPath)).toBe(false);
|
|
98
|
+
|
|
99
|
+
const loaded = loadGuardianToken("alpha");
|
|
100
|
+
expect(loaded).not.toBeNull();
|
|
101
|
+
expect(loaded!.guardianPrincipalId).toBe("principal-dev");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("same assistant id in prod and dev is isolated on disk", () => {
|
|
105
|
+
// Write prod token for assistant 'alpha'
|
|
106
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
107
|
+
saveGuardianToken("alpha", makeTokenData("prod"));
|
|
108
|
+
|
|
109
|
+
// Write dev token for assistant 'alpha'
|
|
110
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
111
|
+
saveGuardianToken("alpha", makeTokenData("dev"));
|
|
112
|
+
|
|
113
|
+
// Dev load returns dev
|
|
114
|
+
expect(loadGuardianToken("alpha")!.guardianPrincipalId).toBe(
|
|
115
|
+
"principal-dev",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Back to prod — prod token is unchanged
|
|
119
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
120
|
+
expect(loadGuardianToken("alpha")!.guardianPrincipalId).toBe(
|
|
121
|
+
"principal-prod",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Both files exist at distinct paths
|
|
125
|
+
const prodPath = join(
|
|
126
|
+
tempHome,
|
|
127
|
+
"vellum",
|
|
128
|
+
"assistants",
|
|
129
|
+
"alpha",
|
|
130
|
+
"guardian-token.json",
|
|
131
|
+
);
|
|
132
|
+
const devPath = join(
|
|
133
|
+
tempHome,
|
|
134
|
+
"vellum-dev",
|
|
135
|
+
"assistants",
|
|
136
|
+
"alpha",
|
|
137
|
+
"guardian-token.json",
|
|
138
|
+
);
|
|
139
|
+
expect(existsSync(prodPath)).toBe(true);
|
|
140
|
+
expect(existsSync(devPath)).toBe(true);
|
|
141
|
+
expect(prodPath).not.toBe(devPath);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("prod: persisted device id lands at $XDG_CONFIG_HOME/vellum/device-id", () => {
|
|
145
|
+
const id = getOrCreatePersistedDeviceId();
|
|
146
|
+
expect(id.length).toBeGreaterThan(0);
|
|
147
|
+
|
|
148
|
+
const prodPath = join(tempHome, "vellum", "device-id");
|
|
149
|
+
expect(existsSync(prodPath)).toBe(true);
|
|
150
|
+
expect(readFileSync(prodPath, "utf-8").trim()).toBe(id);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("dev: persisted device id lands at $XDG_CONFIG_HOME/vellum-dev/device-id", () => {
|
|
154
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
155
|
+
const id = getOrCreatePersistedDeviceId();
|
|
156
|
+
expect(id.length).toBeGreaterThan(0);
|
|
157
|
+
|
|
158
|
+
const devPath = join(tempHome, "vellum-dev", "device-id");
|
|
159
|
+
expect(existsSync(devPath)).toBe(true);
|
|
160
|
+
expect(readFileSync(devPath, "utf-8").trim()).toBe(id);
|
|
161
|
+
|
|
162
|
+
const prodPath = join(tempHome, "vellum", "device-id");
|
|
163
|
+
expect(existsSync(prodPath)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("device id is stable across repeated calls in the same env", () => {
|
|
167
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
168
|
+
const first = getOrCreatePersistedDeviceId();
|
|
169
|
+
const second = getOrCreatePersistedDeviceId();
|
|
170
|
+
expect(first).toBe(second);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -94,22 +94,69 @@ describe("multi-local", () => {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
97
|
-
test("first instance gets
|
|
98
|
-
// GIVEN
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
test("first instance (prod) gets XDG multi-instance dir with default ports", async () => {
|
|
98
|
+
// GIVEN XDG_DATA_HOME points at a scratch directory and no local
|
|
99
|
+
// assistants exist in the lockfile
|
|
100
|
+
const prevXdg = process.env.XDG_DATA_HOME;
|
|
101
|
+
const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-multi-xdg-data-"));
|
|
102
|
+
process.env.XDG_DATA_HOME = xdgDataHome;
|
|
103
|
+
try {
|
|
104
|
+
// WHEN we allocate resources for the first instance
|
|
105
|
+
const res = await allocateLocalResources("instance-a");
|
|
106
|
+
|
|
107
|
+
// THEN it lands under the XDG multi-instance dir (no "first = home"
|
|
108
|
+
// special case anymore)
|
|
109
|
+
expect(res.instanceDir).toBe(
|
|
110
|
+
join(xdgDataHome, "vellum", "assistants", "instance-a"),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// AND it gets the default ports since no other instances exist
|
|
114
|
+
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
115
|
+
expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
|
|
116
|
+
expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
|
|
117
|
+
|
|
118
|
+
// AND the PID file is under the instance's .vellum/
|
|
119
|
+
expect(res.pidFile).toBe(
|
|
120
|
+
join(res.instanceDir, ".vellum", "vellum.pid"),
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
if (prevXdg !== undefined) {
|
|
124
|
+
process.env.XDG_DATA_HOME = prevXdg;
|
|
125
|
+
} else {
|
|
126
|
+
delete process.env.XDG_DATA_HOME;
|
|
127
|
+
}
|
|
128
|
+
rmSync(xdgDataHome, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
105
131
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
132
|
+
test("first instance (dev) uses env-scoped multi-instance dir", async () => {
|
|
133
|
+
// GIVEN VELLUM_ENVIRONMENT=dev and XDG_DATA_HOME set to scratch
|
|
134
|
+
const prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
135
|
+
const prevXdg = process.env.XDG_DATA_HOME;
|
|
136
|
+
const xdgDataHome = mkdtempSync(join(tmpdir(), "cli-multi-xdg-dev-"));
|
|
137
|
+
process.env.VELLUM_ENVIRONMENT = "dev";
|
|
138
|
+
process.env.XDG_DATA_HOME = xdgDataHome;
|
|
139
|
+
try {
|
|
140
|
+
// WHEN we allocate resources for the first instance
|
|
141
|
+
const res = await allocateLocalResources("instance-a");
|
|
110
142
|
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
// THEN it lands under the env-scoped multi-instance dir
|
|
144
|
+
expect(res.instanceDir).toBe(
|
|
145
|
+
join(xdgDataHome, "vellum-dev", "assistants", "instance-a"),
|
|
146
|
+
);
|
|
147
|
+
} finally {
|
|
148
|
+
if (prevEnv !== undefined) {
|
|
149
|
+
process.env.VELLUM_ENVIRONMENT = prevEnv;
|
|
150
|
+
} else {
|
|
151
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
152
|
+
}
|
|
153
|
+
if (prevXdg !== undefined) {
|
|
154
|
+
process.env.XDG_DATA_HOME = prevXdg;
|
|
155
|
+
} else {
|
|
156
|
+
delete process.env.XDG_DATA_HOME;
|
|
157
|
+
}
|
|
158
|
+
rmSync(xdgDataHome, { recursive: true, force: true });
|
|
159
|
+
}
|
|
113
160
|
});
|
|
114
161
|
|
|
115
162
|
test("second instance gets distinct ports and dir when first instance is saved", async () => {
|