@vellumai/cli 0.11.2 → 0.11.3-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
package/knip.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the environment-to-cloud-hub mapping in one place. Both consumers
|
|
3
|
+
* (the Capacitor shell's `server.url` and the CLI remote-web edge's
|
|
4
|
+
* `hubUrl`) read this helper, so this matrix is the single behavioral pin
|
|
5
|
+
* for the served URLs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import { cloudAssistantHubUrl, SEEDS } from "../index.js";
|
|
11
|
+
|
|
12
|
+
describe("cloudAssistantHubUrl", () => {
|
|
13
|
+
test("cloud environments resolve to their own web origin", () => {
|
|
14
|
+
expect(cloudAssistantHubUrl("production")).toBe(
|
|
15
|
+
"https://www.vellum.ai/assistant",
|
|
16
|
+
);
|
|
17
|
+
expect(cloudAssistantHubUrl("staging")).toBe(
|
|
18
|
+
"https://staging-assistant.vellum.ai/assistant",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("every other environment falls back to the dev SPA", () => {
|
|
23
|
+
for (const name of ["dev", "test", "local", "unknown-env", undefined]) {
|
|
24
|
+
expect(cloudAssistantHubUrl(name)).toBe(
|
|
25
|
+
"https://dev-assistant.vellum.ai/assistant",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("cloud hub URLs derive from the seed web URLs", () => {
|
|
31
|
+
for (const name of ["production", "staging"]) {
|
|
32
|
+
expect(cloudAssistantHubUrl(name)).toBe(
|
|
33
|
+
`${SEEDS[name].webUrl}/assistant`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
expect(cloudAssistantHubUrl("local")).toBe(`${SEEDS.dev.webUrl}/assistant`);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for reading an install layout: telling a repo checkout from an
|
|
3
|
+
* installed package, and locating the `assistant` command that ships with an
|
|
4
|
+
* install.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { findAssistantCommand, isRepoCheckoutPath } from "../index.js";
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
root = mkdtempSync(join(tmpdir(), "install-layout-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Create `<nodeModulesDir>/.bin/assistant` and return its path. */
|
|
25
|
+
function seedCommand(nodeModulesDir: string): string {
|
|
26
|
+
const binDir = join(nodeModulesDir, ".bin");
|
|
27
|
+
mkdirSync(binDir, { recursive: true });
|
|
28
|
+
const command = join(binDir, "assistant");
|
|
29
|
+
writeFileSync(command, "#!/usr/bin/env bun\n");
|
|
30
|
+
return command;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("isRepoCheckoutPath", () => {
|
|
34
|
+
test("an installed package is not a checkout", () => {
|
|
35
|
+
expect(
|
|
36
|
+
isRepoCheckoutPath(
|
|
37
|
+
"/Users/x/Library/Application Support/@vellumai/macos/cli/latest/node_modules/@vellumai/assistant/src/index.ts",
|
|
38
|
+
),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a repo checkout is", () => {
|
|
43
|
+
expect(
|
|
44
|
+
isRepoCheckoutPath("/Users/x/dev/vellum/assistant/src/index.ts"),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a directory merely named node_modules-ish is not enough", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isRepoCheckoutPath("/Users/x/node_modules_backup/a/src/index.ts"),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("findAssistantCommand", () => {
|
|
56
|
+
test("finds the command shipped beside an installed package", () => {
|
|
57
|
+
const nodeModules = join(root, "cli", "latest", "node_modules");
|
|
58
|
+
const command = seedCommand(nodeModules);
|
|
59
|
+
const path = join(nodeModules, "@vellumai", "assistant", "src", "index.ts");
|
|
60
|
+
|
|
61
|
+
expect(findAssistantCommand(path)).toBe(command);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("prefers a nested install over a hoisted one", () => {
|
|
65
|
+
const outer = join(root, "node_modules");
|
|
66
|
+
const outerCommand = seedCommand(outer);
|
|
67
|
+
const inner = join(outer, "vellum", "node_modules");
|
|
68
|
+
const innerCommand = seedCommand(inner);
|
|
69
|
+
const path = join(inner, "@vellumai", "assistant", "src", "index.ts");
|
|
70
|
+
|
|
71
|
+
expect(findAssistantCommand(path)).toBe(innerCommand);
|
|
72
|
+
expect(innerCommand).not.toBe(outerCommand);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("falls back to a hoisted install when the nested one has no command", () => {
|
|
76
|
+
const outer = join(root, "node_modules");
|
|
77
|
+
const outerCommand = seedCommand(outer);
|
|
78
|
+
const inner = join(outer, "vellum", "node_modules");
|
|
79
|
+
mkdirSync(inner, { recursive: true });
|
|
80
|
+
const path = join(inner, "@vellumai", "assistant", "src", "index.ts");
|
|
81
|
+
|
|
82
|
+
expect(findAssistantCommand(path)).toBe(outerCommand);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns null for a repo checkout", () => {
|
|
86
|
+
expect(
|
|
87
|
+
findAssistantCommand(join(root, "assistant", "src", "index.ts")),
|
|
88
|
+
).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("returns null when the install ships no assistant command", () => {
|
|
92
|
+
const nodeModules = join(root, "node_modules");
|
|
93
|
+
mkdirSync(join(nodeModules, ".bin"), { recursive: true });
|
|
94
|
+
const path = join(nodeModules, "@vellumai", "assistant", "src", "index.ts");
|
|
95
|
+
|
|
96
|
+
expect(findAssistantCommand(path)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Package boundary tests for @vellumai/environments.
|
|
3
3
|
*
|
|
4
|
-
* This package is the lowest layer of the shared-packages hierarchy:
|
|
5
|
-
* environment types and
|
|
6
|
-
* stay a leaf so any consumer (CLI, assistant,
|
|
7
|
-
* on it without dragging in a dependency tree.
|
|
4
|
+
* This package is the lowest layer of the shared-packages hierarchy:
|
|
5
|
+
* environment types, constants, and small helpers over node builtins, with no
|
|
6
|
+
* runtime dependencies. It must stay a leaf so any consumer (CLI, assistant,
|
|
7
|
+
* local-mode host) can depend on it without dragging in a dependency tree.
|
|
8
8
|
*
|
|
9
9
|
* Enforces that the package:
|
|
10
10
|
* 1. Imports only node builtins and its own relative modules — no `@vellumai/*`
|
|
@@ -79,7 +79,7 @@ describe("package boundary", () => {
|
|
|
79
79
|
throw new Error(
|
|
80
80
|
`Found ${violations.length} forbidden import(s) in @vellumai/environments:\n` +
|
|
81
81
|
violations.map((v) => ` - ${v}`).join("\n") +
|
|
82
|
-
"\n\n@vellumai/environments is a
|
|
82
|
+
"\n\n@vellumai/environments is a dependency-free leaf and must\n" +
|
|
83
83
|
"import only node builtins and its own relative modules.",
|
|
84
84
|
);
|
|
85
85
|
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @vellumai/environments
|
|
3
|
-
*
|
|
4
|
-
* blocks). Consumed by the CLI, the assistant daemon, and the local-mode
|
|
5
|
-
* host library so the environment list is defined exactly once on the TS
|
|
6
|
-
* side. The Swift client mirrors the same set in `VellumEnvironment.swift`.
|
|
2
|
+
* @vellumai/environments: the single source of truth for how a Vellum install
|
|
3
|
+
* is laid out and which deployment environment it belongs to.
|
|
7
4
|
*
|
|
8
|
-
*
|
|
5
|
+
* Two things live here:
|
|
6
|
+
*
|
|
7
|
+
* - The known deployment environments (names, platform/web URLs, per-service
|
|
8
|
+
* port blocks). The Swift client mirrors the same set in
|
|
9
|
+
* `VellumEnvironment.swift`.
|
|
10
|
+
* - Install layout: whether a runtime is a repo checkout or an installed
|
|
11
|
+
* package, and where the `assistant` command it ships lives.
|
|
12
|
+
*
|
|
13
|
+
* Consumed by the CLI, the assistant daemon, and the local-mode host library
|
|
14
|
+
* so each answer is defined exactly once on the TS side.
|
|
15
|
+
*
|
|
16
|
+
* This is the lowest layer of the shared-packages hierarchy and stays a leaf:
|
|
17
|
+
* types, constants, and small helpers over node builtins, with no runtime
|
|
18
|
+
* dependencies of its own. See `__tests__/package-boundary.test.ts`.
|
|
9
19
|
*/
|
|
10
20
|
export * from "./types.js";
|
|
11
21
|
export * from "./seeds.js";
|
|
22
|
+
export * from "./install-layout.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the layout a Vellum runtime is installed in.
|
|
3
|
+
*
|
|
4
|
+
* The daemon is started either from a developer's repo checkout or from an
|
|
5
|
+
* installed npm package (`<install>/node_modules/@vellumai/assistant/src/
|
|
6
|
+
* index.ts`, which is what the desktop app runs). The CLI needs the layout to
|
|
7
|
+
* decide whether a launch is a dev run and what to put on the daemon's PATH;
|
|
8
|
+
* the daemon needs it to decide what to install as the `assistant` command.
|
|
9
|
+
* Both ask the same two questions, so they ask them here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
const NODE_MODULES_SEGMENT = `${sep}node_modules${sep}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether `path` sits in a developer's repo checkout rather than inside an
|
|
19
|
+
* installed package.
|
|
20
|
+
*/
|
|
21
|
+
export function isRepoCheckoutPath(path: string): boolean {
|
|
22
|
+
return !path.includes(NODE_MODULES_SEGMENT);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `assistant` command shipped with the install that contains `path`, or
|
|
27
|
+
* null when there is none (a repo checkout, or an install whose dependency
|
|
28
|
+
* graph omits the package declaring the bin).
|
|
29
|
+
*
|
|
30
|
+
* Searches innermost `node_modules` outward, since a nested install shadows a
|
|
31
|
+
* hoisted one.
|
|
32
|
+
*/
|
|
33
|
+
export function findAssistantCommand(path: string): string | null {
|
|
34
|
+
let index = path.lastIndexOf(NODE_MODULES_SEGMENT);
|
|
35
|
+
|
|
36
|
+
while (index !== -1) {
|
|
37
|
+
const nodeModulesDir = path.slice(
|
|
38
|
+
0,
|
|
39
|
+
index + NODE_MODULES_SEGMENT.length - 1,
|
|
40
|
+
);
|
|
41
|
+
const command = join(nodeModulesDir, ".bin", "assistant");
|
|
42
|
+
if (existsSync(command)) {
|
|
43
|
+
return command;
|
|
44
|
+
}
|
|
45
|
+
index = path.lastIndexOf(NODE_MODULES_SEGMENT, index - 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -24,6 +24,35 @@ function portBlock(base: number): PortMap {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Base URL of the cloud-hosted assistant SPA (the "hub") for a deployment
|
|
29
|
+
* environment. The Capacitor mobile shell bakes this into native builds as
|
|
30
|
+
* `server.url`, and the CLI's remote-web edge stamps it into the served
|
|
31
|
+
* config as `hubUrl`, so the environment-to-hub mapping lives here exactly
|
|
32
|
+
* once.
|
|
33
|
+
*
|
|
34
|
+
* Only the cloud deployments (production, staging) serve the hosted SPA at
|
|
35
|
+
* their own web origin; every other environment (dev, test, local, unknown)
|
|
36
|
+
* falls back to the dev SPA, whose host serves remote clients that cannot
|
|
37
|
+
* reach a local or per-developer web URL.
|
|
38
|
+
*
|
|
39
|
+
* The `/assistant` suffix is deliberate: booting on the bare host lands on
|
|
40
|
+
* the marketing page, whose CTA redirects to `www.vellum.ai/assistant` and
|
|
41
|
+
* bounces non-prod shells off their own host.
|
|
42
|
+
*
|
|
43
|
+
* NOTE: this module is loaded by Capacitor's single-file TS config loader
|
|
44
|
+
* (via the `@vellumai/environments/seeds` subpath), which cannot follow the
|
|
45
|
+
* package index's `.js`-suffixed runtime re-exports. Keep this file's
|
|
46
|
+
* imports type-only so it stays loadable on its own.
|
|
47
|
+
*/
|
|
48
|
+
export function cloudAssistantHubUrl(envName: string | undefined): string {
|
|
49
|
+
const seed =
|
|
50
|
+
envName === "production" || envName === "staging"
|
|
51
|
+
? SEEDS[envName]
|
|
52
|
+
: SEEDS.dev;
|
|
53
|
+
return `${seed.webUrl}/assistant`;
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
/**
|
|
28
57
|
* Built-in environment definitions and the source of truth for the
|
|
29
58
|
* set of known environment names.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vellumai/ipc-server-utils",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "bunx tsc --noEmit",
|
|
12
|
+
"test": "bun test src/"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "1.3.10",
|
|
16
|
+
"typescript": "5.9.3"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { removeIpcEndpointFile, resolveIpcEndpoint } from "./endpoint.js";
|
|
6
|
+
const windowsEndpoint = (name: string, workspaceDir: string) => resolveIpcEndpoint(name, { workspaceDir, platform: "win32" });
|
|
7
|
+
describe("resolveIpcEndpoint", () => {
|
|
8
|
+
test("keeps long POSIX fallbacks deterministic and bounded", () => {
|
|
9
|
+
const options = {
|
|
10
|
+
workspaceDir: `/tmp/${"a".repeat(120)}`,
|
|
11
|
+
platform: "darwin" as const,
|
|
12
|
+
};
|
|
13
|
+
const endpoint = resolveIpcEndpoint("assistant", options);
|
|
14
|
+
expect(resolveIpcEndpoint("assistant", options)).toEqual(endpoint);
|
|
15
|
+
expect(Buffer.byteLength(endpoint.path)).toBeLessThanOrEqual(103);
|
|
16
|
+
});
|
|
17
|
+
test("returns normalized, isolated, bounded Windows pipes", () => {
|
|
18
|
+
const first = windowsEndpoint("assistant", "C:\\one");
|
|
19
|
+
expect(resolveIpcEndpoint("assistant", { workspaceDir: "c:\\ONE\\", platform: "win32", env: { ASSISTANT_IPC_SOCKET_DIR: "C:\\ignored" } })).toEqual(first);
|
|
20
|
+
expect(first.path.length).toBeLessThanOrEqual(256);
|
|
21
|
+
expect(windowsEndpoint("assistant", "C:\\two").path).not.toBe(first.path);
|
|
22
|
+
expect(windowsEndpoint("gateway", "C:\\one").path).not.toBe(first.path);
|
|
23
|
+
});
|
|
24
|
+
test("cleans socket files but skips named pipes", () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-ipc-cleanup-"));
|
|
26
|
+
const socketPath = join(dir, "assistant.sock");
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(socketPath, "stale");
|
|
29
|
+
removeIpcEndpointFile(socketPath);
|
|
30
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
31
|
+
removeIpcEndpointFile("\\\\.\\pipe\\vellum-assistant-test");
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { posix, win32 } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
|
|
7
|
+
const DEFAULT_UNIX_SOCKET_MAX_PATH_BYTES = 107;
|
|
8
|
+
const IPC_TMP_DIR_NAME = "vellum-ipc";
|
|
9
|
+
const join = posix.join;
|
|
10
|
+
|
|
11
|
+
export type IpcSocketPathSource =
|
|
12
|
+
| "env-override"
|
|
13
|
+
| "workspace"
|
|
14
|
+
| "tmp-hash"
|
|
15
|
+
| "tmp-short-hash"
|
|
16
|
+
| "windows-named-pipe";
|
|
17
|
+
|
|
18
|
+
export interface IpcSocketPathResolution {
|
|
19
|
+
path: string;
|
|
20
|
+
source: IpcSocketPathSource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IpcEndpointOptions {
|
|
24
|
+
workspaceDir: string;
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getUnixSocketMaxPathBytes(platform: NodeJS.Platform): number {
|
|
30
|
+
return platform === "darwin"
|
|
31
|
+
? DARWIN_UNIX_SOCKET_MAX_PATH_BYTES
|
|
32
|
+
: DEFAULT_UNIX_SOCKET_MAX_PATH_BYTES;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPathWithinSocketLimit(path: string, maxPathBytes: number): boolean {
|
|
36
|
+
return Buffer.byteLength(path, "utf8") <= maxPathBytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the env var name and socket filename from a socket name.
|
|
41
|
+
*
|
|
42
|
+
* Examples (hyphens in the name become underscores in the env var):
|
|
43
|
+
*/
|
|
44
|
+
function deriveSocketNames(socketName: string): {
|
|
45
|
+
envVar: string;
|
|
46
|
+
fileName: string;
|
|
47
|
+
} {
|
|
48
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(socketName)) {
|
|
49
|
+
throw new Error(`Invalid IPC endpoint name: ${socketName}`);
|
|
50
|
+
}
|
|
51
|
+
const envVar = `${socketName.toUpperCase().replace(/-/g, "_")}_IPC_SOCKET_DIR`;
|
|
52
|
+
const fileName = `${socketName}.sock`;
|
|
53
|
+
return { envVar, fileName };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveWindowsNamedPipe(
|
|
57
|
+
socketName: string,
|
|
58
|
+
workspaceDir: string,
|
|
59
|
+
): IpcSocketPathResolution {
|
|
60
|
+
const workspaceIdentity = win32
|
|
61
|
+
.normalize(workspaceDir)
|
|
62
|
+
.replace(/\\+$/, "")
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
const hash = createHash("sha256")
|
|
65
|
+
.update(`${workspaceIdentity}\0${socketName}`)
|
|
66
|
+
.digest("hex")
|
|
67
|
+
.slice(0, 24);
|
|
68
|
+
return {
|
|
69
|
+
path: `\\\\.\\pipe\\vellum-${socketName}-${hash}`,
|
|
70
|
+
source: "windows-named-pipe",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isNamedPipePath(endpointPath: string): boolean {
|
|
75
|
+
return /^\\\\[.?]\\pipe\\/.test(endpointPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function removeIpcEndpointFile(endpointPath: string): void {
|
|
79
|
+
if (isNamedPipePath(endpointPath)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(endpointPath);
|
|
84
|
+
} catch {
|
|
85
|
+
// Already absent.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the path to an IPC socket file.
|
|
91
|
+
*
|
|
92
|
+
* Resolution order:
|
|
93
|
+
* POSIX uses overrides, workspace paths, then bounded temporary paths.
|
|
94
|
+
* Windows uses deterministic named pipes.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveIpcEndpoint(
|
|
97
|
+
socketName: string,
|
|
98
|
+
options: IpcEndpointOptions,
|
|
99
|
+
): IpcSocketPathResolution {
|
|
100
|
+
const { envVar, fileName } = deriveSocketNames(socketName);
|
|
101
|
+
const platform = options.platform ?? process.platform;
|
|
102
|
+
if (platform === "win32") {
|
|
103
|
+
return resolveWindowsNamedPipe(socketName, options.workspaceDir);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Explicit override via env var.
|
|
107
|
+
const envSocketDir = (options.env ?? process.env)[envVar]?.trim();
|
|
108
|
+
if (envSocketDir) {
|
|
109
|
+
return {
|
|
110
|
+
path: join(envSocketDir, fileName),
|
|
111
|
+
source: "env-override",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const maxPathBytes = getUnixSocketMaxPathBytes(platform);
|
|
116
|
+
const workspacePath = join(options.workspaceDir, fileName);
|
|
117
|
+
|
|
118
|
+
if (isPathWithinSocketLimit(workspacePath, maxPathBytes)) {
|
|
119
|
+
return {
|
|
120
|
+
path: workspacePath,
|
|
121
|
+
source: "workspace",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Workspace path exceeds AF_UNIX limit - fall back to tmpdir.
|
|
126
|
+
const hash = createHash("sha256")
|
|
127
|
+
.update(workspacePath)
|
|
128
|
+
.digest("hex")
|
|
129
|
+
.slice(0, 12);
|
|
130
|
+
const hashedPath = join(tmpdir(), IPC_TMP_DIR_NAME, `${hash}-${fileName}`);
|
|
131
|
+
if (isPathWithinSocketLimit(hashedPath, maxPathBytes)) {
|
|
132
|
+
return {
|
|
133
|
+
path: hashedPath,
|
|
134
|
+
source: "tmp-hash",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
path: join(tmpdir(), `v-${hash}.sock`),
|
|
140
|
+
source: "tmp-short-hash",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SocketWatchdog,
|
|
3
|
+
ensureSocketDir,
|
|
4
|
+
type SocketWatchdogOptions,
|
|
5
|
+
type SocketWatchdogLogger,
|
|
6
|
+
} from "./socket-watchdog.js";
|
|
7
|
+
export { isNamedPipePath, removeIpcEndpointFile, resolveIpcEndpoint } from "./endpoint.js";
|
|
8
|
+
export { ipcListenOptions } from "./listen-options.js";
|
|
9
|
+
export {
|
|
10
|
+
IpcFrameReader,
|
|
11
|
+
writeLegacyMessage,
|
|
12
|
+
writeMessage,
|
|
13
|
+
writeStreamChunk,
|
|
14
|
+
writeStreamEnd,
|
|
15
|
+
type IpcEnvelope,
|
|
16
|
+
type OnMessageCallback,
|
|
17
|
+
type StreamCallbacks,
|
|
18
|
+
} from "./ipc-framing.js";
|