mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix: use bounded runtime paths for Unix sockets
This commit is contained in:
parent
bf68c783a9
commit
9fcaac9b1a
14 changed files with 126 additions and 77 deletions
|
|
@ -63,15 +63,16 @@ const client = new PiClient({
|
||||||
await client.connect();
|
await client.connect();
|
||||||
```
|
```
|
||||||
|
|
||||||
Unix discovery scans `~/.pi/server/*.sock`, derives each expected service ID from its filename, and verifies it through the existing handshake:
|
Unix discovery scans an explicit physical-route directory, derives each expected service ID from its filename, and verifies it through the existing handshake:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { discoverUnixServices } from "@earendil-works/pi-client/unix";
|
import { discoverUnixServices } from "@earendil-works/pi-client/unix";
|
||||||
|
|
||||||
const routes = await discoverUnixServices();
|
const routes = await discoverUnixServices({ directory: "/run/user/1000/pi" });
|
||||||
// [{ serviceId: "...", path: "/home/me/.pi/server/<serviceId>.sock" }]
|
// [{ serviceId: "...", path: "/run/user/1000/pi/<serviceId>.sock" }]
|
||||||
```
|
```
|
||||||
|
|
||||||
Malformed entries, non-sockets, stale or unresponsive endpoints, and service-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery. Pass `directory` or `timeoutMs` to override the defaults.
|
Malformed entries, non-sockets, stale or unresponsive endpoints, and service-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery. The caller must choose a short, private directory because Unix socket path limits are substantially lower than normal filesystem path limits.
|
||||||
|
Pass `timeoutMs` to override the default probe timeout.
|
||||||
|
|
||||||
`PiClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.
|
`PiClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { lstat, readdir } from "node:fs/promises";
|
import { lstat, readdir } from "node:fs/promises";
|
||||||
import { createConnection, type Socket } from "node:net";
|
import { createConnection, type Socket } from "node:net";
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_FRAME_LENGTH,
|
DEFAULT_MAX_FRAME_LENGTH,
|
||||||
|
|
@ -29,16 +28,16 @@ export interface UnixServiceRoute {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoverUnixServicesOptions {
|
export interface DiscoverUnixServicesOptions {
|
||||||
/** Defaults to ~/.pi/server. */
|
/** Directory containing service-addressed Unix sockets. */
|
||||||
directory?: string;
|
directory: string;
|
||||||
/** Maximum time for each connection and handshake. Defaults to 1,000 ms. */
|
/** Maximum time for each connection and handshake. Defaults to 1,000 ms. */
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discover reachable local Pi services by probing service-addressed Unix sockets. */
|
/** Discover reachable local Pi services by probing service-addressed Unix sockets. */
|
||||||
export async function discoverUnixServices(options: DiscoverUnixServicesOptions = {}): Promise<UnixServiceRoute[]> {
|
export async function discoverUnixServices(options: DiscoverUnixServicesOptions): Promise<UnixServiceRoute[]> {
|
||||||
if (process.platform === "win32") throw new Error("Unix transport is not supported on Windows");
|
if (process.platform === "win32") throw new Error("Unix transport is not supported on Windows");
|
||||||
const directory = options.directory ?? join(homedir(), ".pi", "server");
|
const directory = options.directory;
|
||||||
const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
|
const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
|
||||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
|
||||||
throw new TypeError(`Unix discovery timeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
|
throw new TypeError(`Unix discovery timeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { createServer, type Server, type Socket } from "node:net";
|
import { createServer, type Server, type Socket } from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { ClientMessageDecoder, encodeServerMessage, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
|
import { ClientMessageDecoder, encodeServerMessage, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
@ -13,7 +12,7 @@ const servers = new Set<Server>();
|
||||||
const sockets = new Set<Socket>();
|
const sockets = new Set<Socket>();
|
||||||
|
|
||||||
async function makeSocketPath(): Promise<string> {
|
async function makeSocketPath(): Promise<string> {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "pi-client-transport-"));
|
const directory = await mkdtemp(join("/tmp", "pi-client-transport-"));
|
||||||
tempDirectories.add(directory);
|
tempDirectories.add(directory);
|
||||||
return join(directory, "pi.sock");
|
return join(directory, "pi.sock");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { type ChildProcess, fork } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { createServer, type Server, type Socket } from "node:net";
|
import { createServer, type Server, type Socket } from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { PiServer } from "../../server/src/server.ts";
|
import { PiServer } from "../../server/src/server.ts";
|
||||||
|
|
@ -16,7 +15,7 @@ const rawSockets = new Set<Socket>();
|
||||||
const children = new Set<ChildProcess>();
|
const children = new Set<ChildProcess>();
|
||||||
|
|
||||||
async function makeDirectory(): Promise<string> {
|
async function makeDirectory(): Promise<string> {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "pc-"));
|
const directory = await mkdtemp(join("/tmp", "pc-"));
|
||||||
tempDirectories.add(directory);
|
tempDirectories.add(directory);
|
||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { lstat } from "node:fs/promises";
|
import { chmod, lstat, mkdir } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { setTimeout as delay } from "node:timers/promises";
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
|
@ -20,6 +20,7 @@ import { startExperimentalSessionWorker } from "./session-worker.ts";
|
||||||
|
|
||||||
const SOCKET_RELEASE_TIMEOUT_MS = 10_000;
|
const SOCKET_RELEASE_TIMEOUT_MS = 10_000;
|
||||||
const SOCKET_RELEASE_POLL_MS = 10;
|
const SOCKET_RELEASE_POLL_MS = 10;
|
||||||
|
const EXPERIMENTAL_SOCKET_ROOT = "/tmp";
|
||||||
|
|
||||||
export interface ExperimentalMemoryServer {
|
export interface ExperimentalMemoryServer {
|
||||||
readonly serviceId: string;
|
readonly serviceId: string;
|
||||||
|
|
@ -38,19 +39,21 @@ export type ExperimentalClientResult =
|
||||||
| { readonly kind: "attached"; readonly serviceId: string; readonly sessionId: string };
|
| { readonly kind: "attached"; readonly serviceId: string; readonly sessionId: string };
|
||||||
|
|
||||||
export interface StartExperimentalMemoryServerOptions {
|
export interface StartExperimentalMemoryServerOptions {
|
||||||
/** Directory for service-addressed Unix sockets. Defaults to ~/.pi/server. */
|
/** Directory for service-addressed Unix sockets. Defaults to a short, private per-user runtime directory. */
|
||||||
readonly directory?: string;
|
readonly directory?: string;
|
||||||
readonly path?: string;
|
readonly path?: string;
|
||||||
readonly serviceId?: string;
|
readonly serviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartExperimentalServerGenerationOptions {
|
export interface StartExperimentalServerGenerationOptions {
|
||||||
/** One directory represents one experimental local service profile. */
|
/** Persistent profile directory. Defaults to ~/.pi/server. */
|
||||||
readonly directory?: string;
|
readonly directory?: string;
|
||||||
|
/** Physical socket directory. Defaults to a short, private per-user runtime directory. */
|
||||||
|
readonly socketDirectory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunExperimentalClientOptions {
|
export interface RunExperimentalClientOptions {
|
||||||
/** Directory searched when --connect is omitted. Defaults to ~/.pi/server. */
|
/** Directory searched when --connect is omitted. Defaults to the experimental per-user runtime directory. */
|
||||||
readonly directory?: string;
|
readonly directory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +98,12 @@ export async function startExperimentalMemoryServer(
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const socketPath = options.path ?? getUnixSocketPath(serviceId, options.directory);
|
let socketPath = options.path;
|
||||||
|
if (socketPath === undefined) {
|
||||||
|
const socketDirectory = options.directory ?? getExperimentalSocketDirectory();
|
||||||
|
await ensurePrivateSocketDirectory(socketDirectory);
|
||||||
|
socketPath = getUnixSocketPath(serviceId, socketDirectory);
|
||||||
|
}
|
||||||
const server = createUnixServer(host, { serviceId, path: socketPath, mode: 0o600 });
|
const server = createUnixServer(host, { serviceId, path: socketPath, mode: 0o600 });
|
||||||
try {
|
try {
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|
@ -142,10 +150,12 @@ export async function startExperimentalServerGeneration(
|
||||||
options: StartExperimentalServerGenerationOptions = {},
|
options: StartExperimentalServerGenerationOptions = {},
|
||||||
): Promise<ExperimentalMemoryServer> {
|
): Promise<ExperimentalMemoryServer> {
|
||||||
const directory = options.directory ?? join(homedir(), ".pi", "server");
|
const directory = options.directory ?? join(homedir(), ".pi", "server");
|
||||||
|
const socketDirectory = options.socketDirectory ?? getExperimentalSocketDirectory();
|
||||||
const { serviceId, release } = await acquireExperimentalServiceProfile(directory);
|
const { serviceId, release } = await acquireExperimentalServiceProfile(directory);
|
||||||
let runtime: ExperimentalMemoryServer;
|
let runtime: ExperimentalMemoryServer;
|
||||||
try {
|
try {
|
||||||
const socketPath = getUnixSocketPath(serviceId, directory);
|
await ensurePrivateSocketDirectory(socketDirectory);
|
||||||
|
const socketPath = getUnixSocketPath(serviceId, socketDirectory);
|
||||||
await drainExistingGeneration(serviceId, socketPath);
|
await drainExistingGeneration(serviceId, socketPath);
|
||||||
runtime = await startExperimentalMemoryServer({
|
runtime = await startExperimentalMemoryServer({
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -195,7 +205,9 @@ export async function runExperimentalClient(
|
||||||
options: RunExperimentalClientOptions = {},
|
options: RunExperimentalClientOptions = {},
|
||||||
): Promise<ExperimentalClientResult> {
|
): Promise<ExperimentalClientResult> {
|
||||||
if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server");
|
if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server");
|
||||||
const routes = command.connect ? [routeFromExplicitPath(command.connect.path)] : await discoverUnixServices(options);
|
const routes = command.connect
|
||||||
|
? [routeFromExplicitPath(command.connect.path)]
|
||||||
|
: await discoverUnixServices({ directory: options.directory ?? getExperimentalSocketDirectory() });
|
||||||
const discovered: { route: UnixServiceRoute; sessionIds: string[] }[] = [];
|
const discovered: { route: UnixServiceRoute; sessionIds: string[] }[] = [];
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
|
|
@ -265,6 +277,23 @@ async function waitForSocketRelease(path: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExperimentalSocketDirectory(): string {
|
||||||
|
if (process.platform === "win32" || typeof process.getuid !== "function") {
|
||||||
|
throw new Error("Experimental Unix server transport requires a POSIX user ID");
|
||||||
|
}
|
||||||
|
return join(EXPERIMENTAL_SOCKET_ROOT, `pi-server-${process.getuid()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePrivateSocketDirectory(directory: string): Promise<void> {
|
||||||
|
if (typeof process.getuid !== "function") throw new Error("Unix socket directory requires a POSIX user ID");
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const stats = await lstat(directory);
|
||||||
|
if (!stats.isDirectory()) throw new Error(`Unix socket directory is not a directory: ${directory}`);
|
||||||
|
if (stats.uid !== process.getuid())
|
||||||
|
throw new Error(`Unix socket directory is not owned by the current user: ${directory}`);
|
||||||
|
await chmod(directory, 0o700);
|
||||||
|
}
|
||||||
|
|
||||||
function hasErrorCode(error: unknown, code: string): boolean {
|
function hasErrorCode(error: unknown, code: string): boolean {
|
||||||
let current = error;
|
let current = error;
|
||||||
const seen = new Set<unknown>();
|
const seen = new Set<unknown>();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { lstat, mkdtemp, rm } from "node:fs/promises";
|
import { chmod, lstat, mkdtemp, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,6 +27,20 @@ afterEach(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("experimental memory server composition", () => {
|
describe("experimental memory server composition", () => {
|
||||||
|
test("does not change permissions on an explicit socket-path parent", async () => {
|
||||||
|
const directory = await mkdtemp(join("/tmp", "pep-"));
|
||||||
|
directories.add(directory);
|
||||||
|
await chmod(directory, 0o750);
|
||||||
|
const serviceId = "00000000000000000000000000000001";
|
||||||
|
const runtime = await startExperimentalMemoryServer({
|
||||||
|
path: join(directory, `${serviceId}.sock`),
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
servers.add(runtime);
|
||||||
|
|
||||||
|
expect((await lstat(directory)).mode & 0o777).toBe(0o750);
|
||||||
|
});
|
||||||
|
|
||||||
test("discovers and lists seeded sessions without hosting either session", async () => {
|
test("discovers and lists seeded sessions without hosting either session", async () => {
|
||||||
const { directory, runtime } = await makeServer();
|
const { directory, runtime } = await makeServer();
|
||||||
|
|
||||||
|
|
@ -99,8 +113,8 @@ describe("experimental memory server composition", () => {
|
||||||
const directory = await mkdtemp(join("/tmp", "pel-"));
|
const directory = await mkdtemp(join("/tmp", "pel-"));
|
||||||
directories.add(directory);
|
directories.add(directory);
|
||||||
const generations = await Promise.all([
|
const generations = await Promise.all([
|
||||||
startExperimentalServerGeneration({ directory }),
|
startExperimentalServerGeneration({ directory, socketDirectory: directory }),
|
||||||
startExperimentalServerGeneration({ directory }),
|
startExperimentalServerGeneration({ directory, socketDirectory: directory }),
|
||||||
]);
|
]);
|
||||||
for (const generation of generations) servers.add(generation);
|
for (const generation of generations) servers.add(generation);
|
||||||
|
|
||||||
|
|
@ -125,8 +139,14 @@ describe("experimental memory server composition", () => {
|
||||||
const otherDirectory = await mkdtemp(join("/tmp", "per-"));
|
const otherDirectory = await mkdtemp(join("/tmp", "per-"));
|
||||||
directories.add(firstDirectory);
|
directories.add(firstDirectory);
|
||||||
directories.add(otherDirectory);
|
directories.add(otherDirectory);
|
||||||
const first = await startExperimentalServerGeneration({ directory: firstDirectory });
|
const first = await startExperimentalServerGeneration({
|
||||||
const other = await startExperimentalServerGeneration({ directory: otherDirectory });
|
directory: firstDirectory,
|
||||||
|
socketDirectory: firstDirectory,
|
||||||
|
});
|
||||||
|
const other = await startExperimentalServerGeneration({
|
||||||
|
directory: otherDirectory,
|
||||||
|
socketDirectory: otherDirectory,
|
||||||
|
});
|
||||||
servers.add(first);
|
servers.add(first);
|
||||||
servers.add(other);
|
servers.add(other);
|
||||||
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory });
|
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory });
|
||||||
|
|
@ -136,7 +156,10 @@ describe("experimental memory server composition", () => {
|
||||||
expect(firstWorkerPid).toEqual(expect.any(Number));
|
expect(firstWorkerPid).toEqual(expect.any(Number));
|
||||||
expect(otherWorkerPid).toEqual(expect.any(Number));
|
expect(otherWorkerPid).toEqual(expect.any(Number));
|
||||||
|
|
||||||
const replacement = await startExperimentalServerGeneration({ directory: firstDirectory });
|
const replacement = await startExperimentalServerGeneration({
|
||||||
|
directory: firstDirectory,
|
||||||
|
socketDirectory: firstDirectory,
|
||||||
|
});
|
||||||
servers.add(replacement);
|
servers.add(replacement);
|
||||||
await first.closed;
|
await first.closed;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
@ -91,22 +92,35 @@ afterEach(async () => {
|
||||||
|
|
||||||
describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => {
|
describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => {
|
||||||
test("a second CLI generation starts clean and accepts explicit reattachment", async () => {
|
test("a second CLI generation starts clean and accepts explicit reattachment", async () => {
|
||||||
const home = await mkdtemp(join("/tmp", "pcs-"));
|
const root = await mkdtemp(join(tmpdir(), "pcs-"));
|
||||||
directories.add(home);
|
directories.add(root);
|
||||||
const directory = join(home, ".pi", "server");
|
const home = join(root, "long-home-segment".repeat(8));
|
||||||
|
await mkdir(home);
|
||||||
const first = await startServer(home);
|
const first = await startServer(home);
|
||||||
const firstIdentity = await waitForOutput(first, /Service: ([0-9a-f]{32})/);
|
const firstIdentity = await waitForOutput(first, /Service: ([0-9a-f]{32})/);
|
||||||
await waitForOutput(first, /Socket: .+\.sock/);
|
const firstSocket = await waitForOutput(first, /Socket: (.+\.sock)/);
|
||||||
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory });
|
expect(firstSocket[1]).not.toContain(home);
|
||||||
|
expect(Buffer.byteLength(firstSocket[1]!)).toBeLessThanOrEqual(103);
|
||||||
|
await runExperimentalClient({
|
||||||
|
command: "client",
|
||||||
|
sessionId: "demo-1",
|
||||||
|
connect: { transport: "unix", path: firstSocket[1]! },
|
||||||
|
});
|
||||||
|
|
||||||
const replacement = await startServer(home);
|
const replacement = await startServer(home);
|
||||||
const replacementIdentity = await waitForOutput(replacement, /Service: ([0-9a-f]{32})/);
|
const replacementIdentity = await waitForOutput(replacement, /Service: ([0-9a-f]{32})/);
|
||||||
await waitForOutput(replacement, /Socket: .+\.sock/);
|
const replacementSocket = await waitForOutput(replacement, /Socket: (.+\.sock)/);
|
||||||
await waitForExit(first.child);
|
await waitForExit(first.child);
|
||||||
|
|
||||||
expect(replacementIdentity[1]).toBe(firstIdentity[1]);
|
expect(replacementIdentity[1]).toBe(firstIdentity[1]);
|
||||||
|
expect(replacementSocket[1]).toBe(firstSocket[1]);
|
||||||
expect(replacement.child.pid).not.toBe(first.child.pid);
|
expect(replacement.child.pid).not.toBe(first.child.pid);
|
||||||
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toMatchObject({
|
await expect(
|
||||||
|
runExperimentalClient({
|
||||||
|
command: "client",
|
||||||
|
connect: { transport: "unix", path: replacementSocket[1]! },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
kind: "list",
|
kind: "list",
|
||||||
sessions: [
|
sessions: [
|
||||||
{ serviceId: firstIdentity[1], sessionId: "demo-1" },
|
{ serviceId: firstIdentity[1], sessionId: "demo-1" },
|
||||||
|
|
@ -114,7 +128,11 @@ describe.skipIf(process.platform === "win32")("experimental CLI server replaceme
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }),
|
runExperimentalClient({
|
||||||
|
command: "client",
|
||||||
|
sessionId: "demo-1",
|
||||||
|
connect: { transport: "unix", path: replacementSocket[1]! },
|
||||||
|
}),
|
||||||
).resolves.toMatchObject({ kind: "attached", sessionId: "demo-1" });
|
).resolves.toMatchObject({ kind: "attached", sessionId: "demo-1" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { defineConfig, mergeConfig } from "vitest/config";
|
import { defineConfig, mergeConfig } from "vitest/config";
|
||||||
import baseConfig, { workspaceSourcePaths } from "../../vitest.base.ts";
|
import baseConfig, { workspaceSourcePaths } from "../../vitest.base.ts";
|
||||||
|
|
||||||
|
|
@ -22,30 +21,6 @@ export default mergeConfig(
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: [
|
||||||
{
|
|
||||||
find: /^@earendil-works\/pi-client\/control$/,
|
|
||||||
replacement: fileURLToPath(new URL("../client/src/control.ts", import.meta.url)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
find: /^@earendil-works\/pi-client\/unix$/,
|
|
||||||
replacement: fileURLToPath(new URL("../client/src/unix.ts", import.meta.url)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
find: /^@earendil-works\/pi-server\/unix$/,
|
|
||||||
replacement: fileURLToPath(new URL("../server/src/transports/unix/index.ts", import.meta.url)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
find: /^@earendil-works\/pi-server$/,
|
|
||||||
replacement: fileURLToPath(new URL("../server/src/index.ts", import.meta.url)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
find: /^@earendil-works\/pi-client$/,
|
|
||||||
replacement: fileURLToPath(new URL("../client/src/index.ts", import.meta.url)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
find: /^@earendil-works\/pi-protocol$/,
|
|
||||||
replacement: fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)),
|
|
||||||
},
|
|
||||||
{ find: /^@earendil-works\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
|
{ find: /^@earendil-works\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
|
||||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
|
{ find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
|
||||||
{ find: /^@mariozechner\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
|
{ find: /^@mariozechner\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Concurrent attachments to one session reuse one hosted Harness. Attachment is a
|
||||||
```ts
|
```ts
|
||||||
import { MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
import { MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
||||||
import { generateServiceId, type PiServerHost } from "@earendil-works/pi-server";
|
import { generateServiceId, type PiServerHost } from "@earendil-works/pi-server";
|
||||||
import { createUnixServer } from "@earendil-works/pi-server/unix";
|
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
|
||||||
|
|
||||||
const sessions = new MemorySessionRepo();
|
const sessions = new MemorySessionRepo();
|
||||||
const host: PiServerHost = {
|
const host: PiServerHost = {
|
||||||
|
|
@ -24,13 +24,15 @@ const host: PiServerHost = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serviceId = generateServiceId();
|
||||||
const server = createUnixServer(host, {
|
const server = createUnixServer(host, {
|
||||||
serviceId: generateServiceId(),
|
serviceId,
|
||||||
|
path: getUnixSocketPath(serviceId, "/run/user/1000/pi"),
|
||||||
});
|
});
|
||||||
await server.start();
|
await server.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
Applications supply the repository and Harness factory. `serviceId` is a logical identity supplied by the launcher, not a socket address. `generateServiceId()` creates an in-memory 128-bit identity. The Unix preset defaults to `~/.pi/server/<serviceId>.sock`; pass `path` to override it. A long-lived launcher can reuse the same ID and path when replacing a server process.
|
Applications supply the repository and Harness factory. `serviceId` is a logical identity supplied by the launcher, not a socket address. `generateServiceId()` creates an in-memory 128-bit identity. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process.
|
||||||
|
|
||||||
`PiServer` composes authenticated transports through `PiServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`.
|
`PiServer` composes authenticated transports through `PiServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { validateUnixSocketPath } from "./listener.ts";
|
import { validateUnixSocketPath } from "./listener.ts";
|
||||||
|
|
||||||
/** Derive the local Unix socket path for one logical service identity. */
|
/** Derive the local Unix socket path for one logical service identity. */
|
||||||
export function getUnixSocketPath(serviceId: string, serverDirectory = join(homedir(), ".pi", "server")): string {
|
export function getUnixSocketPath(serviceId: string, serverDirectory: string): string {
|
||||||
if (!/^[0-9a-f]{32}$/.test(serviceId)) {
|
if (!/^[0-9a-f]{32}$/.test(serviceId)) {
|
||||||
throw new TypeError("Unix serviceId must be 32 lowercase hexadecimal characters");
|
throw new TypeError("Unix serviceId must be 32 lowercase hexadecimal characters");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { PiServer } from "../../server.ts";
|
import { PiServer } from "../../server.ts";
|
||||||
import type { PiServerHost } from "../../types.ts";
|
import type { PiServerHost } from "../../types.ts";
|
||||||
import { getUnixSocketPath } from "./address.ts";
|
|
||||||
import { createUnixListener } from "./listener.ts";
|
import { createUnixListener } from "./listener.ts";
|
||||||
import type { UnixServerOptions } from "./types.ts";
|
import type { UnixServerOptions } from "./types.ts";
|
||||||
|
|
||||||
/** Compose PiServer with one Unix-domain socket listener. */
|
/** Compose PiServer with one Unix-domain socket listener. */
|
||||||
export function createUnixServer(host: PiServerHost, options: UnixServerOptions): PiServer {
|
export function createUnixServer(host: PiServerHost, options: UnixServerOptions): PiServer {
|
||||||
const listener = createUnixListener({
|
const listener = createUnixListener({
|
||||||
path: options.path ?? getUnixSocketPath(options.serviceId),
|
path: options.path,
|
||||||
mode: options.mode,
|
mode: options.mode,
|
||||||
maxFrameLength: options.maxFrameLength,
|
maxFrameLength: options.maxFrameLength,
|
||||||
maxPendingBytes: options.maxPendingBytes,
|
maxPendingBytes: options.maxPendingBytes,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,4 @@ export interface UnixListenerOptions {
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnixServerOptions extends Omit<PiServerOptions, "listeners">, Omit<UnixListenerOptions, "path"> {
|
export interface UnixServerOptions extends Omit<PiServerOptions, "listeners">, UnixListenerOptions {}
|
||||||
/** Defaults to ~/.pi/server/<serviceId>.sock. */
|
|
||||||
path?: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { type ChildProcess, fork } from "node:child_process";
|
import { type ChildProcess, fork } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { lstat, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
import { lstat, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||||
import { homedir, tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { generateServiceId, type PiServer } from "../src/index.ts";
|
import { generateServiceId, type PiServer } from "../src/index.ts";
|
||||||
|
|
@ -14,7 +13,7 @@ const children = new Set<ChildProcess>();
|
||||||
const tempDirectories = new Set<string>();
|
const tempDirectories = new Set<string>();
|
||||||
|
|
||||||
async function makeSocketPath(nested = false): Promise<string> {
|
async function makeSocketPath(nested = false): Promise<string> {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "ps-"));
|
const directory = await mkdtemp(join("/tmp", "ps-"));
|
||||||
tempDirectories.add(directory);
|
tempDirectories.add(directory);
|
||||||
return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock");
|
return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock");
|
||||||
}
|
}
|
||||||
|
|
@ -48,16 +47,14 @@ test("generates unique service IDs", () => {
|
||||||
expect(second).not.toBe(first);
|
expect(second).not.toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates an in-memory service ID and derives its Unix socket path", async () => {
|
test("creates an in-memory service ID and derives its explicit Unix socket path", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "pi-server-"));
|
const directory = await mkdtemp(join("/tmp", "pi-server-"));
|
||||||
tempDirectories.add(directory);
|
tempDirectories.add(directory);
|
||||||
const serviceId = generateServiceId();
|
const serviceId = generateServiceId();
|
||||||
const path = getUnixSocketPath(serviceId, directory);
|
const path = getUnixSocketPath(serviceId, directory);
|
||||||
|
|
||||||
expect(serviceId).toMatch(/^[0-9a-f]{32}$/);
|
expect(serviceId).toMatch(/^[0-9a-f]{32}$/);
|
||||||
expect(path).toBe(join(directory, `${serviceId}.sock`));
|
expect(path).toBe(join(directory, `${serviceId}.sock`));
|
||||||
expect(getUnixSocketPath(serviceId)).toBe(join(homedir(), ".pi", "server", `${serviceId}.sock`));
|
|
||||||
|
|
||||||
const first = createUnixServer(new TestServerHost(), { serviceId, path });
|
const first = createUnixServer(new TestServerHost(), { serviceId, path });
|
||||||
servers.add(first);
|
servers.add(first);
|
||||||
await first.start();
|
await first.start();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ export const workspaceSourcePaths = {
|
||||||
aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)),
|
aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)),
|
||||||
aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)),
|
aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)),
|
||||||
agentIndex: fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)),
|
agentIndex: fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)),
|
||||||
|
protocolIndex: fileURLToPath(new URL("./packages/protocol/src/index.ts", import.meta.url)),
|
||||||
|
clientIndex: fileURLToPath(new URL("./packages/client/src/index.ts", import.meta.url)),
|
||||||
|
clientControl: fileURLToPath(new URL("./packages/client/src/control.ts", import.meta.url)),
|
||||||
|
clientUnix: fileURLToPath(new URL("./packages/client/src/unix.ts", import.meta.url)),
|
||||||
|
serverIndex: fileURLToPath(new URL("./packages/server/src/index.ts", import.meta.url)),
|
||||||
|
serverUnix: fileURLToPath(new URL("./packages/server/src/transports/unix/index.ts", import.meta.url)),
|
||||||
codingAgentIndex: fileURLToPath(new URL("./packages/coding-agent/src/index.ts", import.meta.url)),
|
codingAgentIndex: fileURLToPath(new URL("./packages/coding-agent/src/index.ts", import.meta.url)),
|
||||||
tuiIndex: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)),
|
tuiIndex: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -26,6 +32,12 @@ export default defineConfig({
|
||||||
replacement: `${workspaceSourcePaths.aiProviders}/$1.ts`,
|
replacement: `${workspaceSourcePaths.aiProviders}/$1.ts`,
|
||||||
},
|
},
|
||||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
|
{ find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
|
||||||
|
{ find: /^@earendil-works\/pi-protocol$/, replacement: workspaceSourcePaths.protocolIndex },
|
||||||
|
{ find: /^@earendil-works\/pi-client$/, replacement: workspaceSourcePaths.clientIndex },
|
||||||
|
{ find: /^@earendil-works\/pi-client\/control$/, replacement: workspaceSourcePaths.clientControl },
|
||||||
|
{ find: /^@earendil-works\/pi-client\/unix$/, replacement: workspaceSourcePaths.clientUnix },
|
||||||
|
{ find: /^@earendil-works\/pi-server$/, replacement: workspaceSourcePaths.serverIndex },
|
||||||
|
{ find: /^@earendil-works\/pi-server\/unix$/, replacement: workspaceSourcePaths.serverUnix },
|
||||||
{ find: /^@earendil-works\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex },
|
{ find: /^@earendil-works\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue