mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Connect daemons to Hub through browser approval (#2208)
* feat(cli): connect daemons to Hub through browser approval Let interactive setup complete through a short-lived browser authorization while preserving --token for automation and existing integrations. * ci: build server before CLI tests * fix(cli): keep Hub approval polling within expiry Bound both response headers and body consumption to the original authorization lifetime so stalled responses remain retryable without extending approval. Run the CLI test helper through Node and the resolved tsx entry for cross-platform behavior. * fix(cli): bound Hub registration startup Fail a stalled authorization start within a fixed timeout without retrying the non-idempotent request. Declare the response validator as a direct CLI runtime dependency. * fix(cli): restrict Hub activation URLs * fix(cli): harden Hub approval transport
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -349,6 +349,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: node scripts/npm-retry.mjs ci
|
run: node scripts/npm-retry.mjs ci
|
||||||
|
|
||||||
|
- name: Build server stack
|
||||||
|
run: npm run build:server
|
||||||
|
|
||||||
- name: Install agent CLIs for provider tests
|
- name: Install agent CLIs for provider tests
|
||||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||||
|
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -36247,7 +36247,8 @@
|
|||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"build:clean": "npm run clean && npm run build",
|
"build:clean": "npm run clean && npm run build",
|
||||||
"prepack": "npm run build:clean",
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "npm run test:local",
|
"test": "npm run test:unit && npm run test:local",
|
||||||
|
"test:unit": "vitest run src",
|
||||||
"test:local": "tsx tests/run-all.ts",
|
"test:local": "tsx tests/run-all.ts",
|
||||||
"test:e2e": "npm run test:local",
|
"test:e2e": "npm run test:local",
|
||||||
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
||||||
@@ -35,7 +36,8 @@
|
|||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
|
|||||||
202
packages/cli/src/commands/hub/cloud-device-authorization.test.ts
Normal file
202
packages/cli/src/commands/hub/cloud-device-authorization.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
import { CloudDeviceAuthorizationClient } from "./cloud-device-authorization.js";
|
||||||
|
|
||||||
|
describe("Cloud device authorization", () => {
|
||||||
|
it("accepts loopback HTTP activation URLs", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("loopback-authorization");
|
||||||
|
try {
|
||||||
|
const authorization = await new CloudDeviceAuthorizationClient().start(
|
||||||
|
cloud.origin,
|
||||||
|
"Studio Mac",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(authorization.verificationUri, `${cloud.origin}/activate`);
|
||||||
|
assert.equal(
|
||||||
|
authorization.verificationUriComplete,
|
||||||
|
`${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-web activation URL at the Cloud boundary", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("non-web-authorization");
|
||||||
|
try {
|
||||||
|
await assert.rejects(new CloudDeviceAuthorizationClient().start(cloud.origin, "Studio Mac"), {
|
||||||
|
name: "ZodError",
|
||||||
|
});
|
||||||
|
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when start headers arrive but the response body stalls", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("stalled-start-body");
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
new CloudDeviceAuthorizationClient(100).start(cloud.origin, "Studio Mac"),
|
||||||
|
{ message: "Cloud registration start timed out" },
|
||||||
|
);
|
||||||
|
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when poll headers arrive but the response body stalls", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("stalled-poll-body");
|
||||||
|
try {
|
||||||
|
const outcome = await new CloudDeviceAuthorizationClient().poll(
|
||||||
|
cloud.origin,
|
||||||
|
"device-code-with-more-than-thirty-two-characters",
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(outcome, { status: "retry_later" });
|
||||||
|
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when a poll response body resets after headers arrive", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("reset-poll-body");
|
||||||
|
try {
|
||||||
|
const outcome = await new CloudDeviceAuthorizationClient().poll(
|
||||||
|
cloud.origin,
|
||||||
|
"device-code-with-more-than-thirty-two-characters",
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(outcome, { status: "retry_later" });
|
||||||
|
assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a completed malformed poll response", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("malformed-poll-body");
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
new CloudDeviceAuthorizationClient().poll(
|
||||||
|
cloud.origin,
|
||||||
|
"device-code-with-more-than-thirty-two-characters",
|
||||||
|
1_000,
|
||||||
|
),
|
||||||
|
{ name: "SyntaxError" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a completed poll response with an invalid shape", async () => {
|
||||||
|
const cloud = await RegistrationCloud.start("invalid-poll-body");
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
new CloudDeviceAuthorizationClient().poll(
|
||||||
|
cloud.origin,
|
||||||
|
"device-code-with-more-than-thirty-two-characters",
|
||||||
|
1_000,
|
||||||
|
),
|
||||||
|
{ name: "ZodError" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cloud.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegistrationCloudResponse =
|
||||||
|
| "loopback-authorization"
|
||||||
|
| "non-web-authorization"
|
||||||
|
| "stalled-start-body"
|
||||||
|
| "stalled-poll-body"
|
||||||
|
| "reset-poll-body"
|
||||||
|
| "malformed-poll-body"
|
||||||
|
| "invalid-poll-body";
|
||||||
|
|
||||||
|
class RegistrationCloud {
|
||||||
|
readonly receivedPaths: string[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
readonly origin: string,
|
||||||
|
private readonly server: Server,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static async start(responseBody: RegistrationCloudResponse): Promise<RegistrationCloud> {
|
||||||
|
let cloud: RegistrationCloud;
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
if (request.url !== undefined) cloud.receivedPaths.push(request.url);
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
if (responseBody === "malformed-poll-body") {
|
||||||
|
response.end("not-json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (responseBody === "invalid-poll-body") {
|
||||||
|
response.end('{"status":"pending"}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (responseBody === "non-web-authorization") {
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
deviceCode: "device-code-with-more-than-thirty-two-characters",
|
||||||
|
userCode: "ABCD-EFGH-JKLMN",
|
||||||
|
verificationUri: "https://cloud.paseo.test/activate",
|
||||||
|
verificationUriComplete: "file:///tmp/paseo-activate",
|
||||||
|
expiresAt: "2026-07-18T12:10:00.000Z",
|
||||||
|
interval: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (responseBody === "loopback-authorization") {
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
deviceCode: "device-code-with-more-than-thirty-two-characters",
|
||||||
|
userCode: "ABCD-EFGH-JKLMN",
|
||||||
|
verificationUri: `${cloud.origin}/activate`,
|
||||||
|
verificationUriComplete: `${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`,
|
||||||
|
expiresAt: "2026-07-18T12:10:00.000Z",
|
||||||
|
interval: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (responseBody === "stalled-start-body") {
|
||||||
|
response.write('{"deviceCode":"device-code-with-more-than-thirty-two-characters"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (responseBody === "reset-poll-body") {
|
||||||
|
response.flushHeaders();
|
||||||
|
response.write('{"status":"pending"');
|
||||||
|
setImmediate(() => response.socket?.destroy());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.write('{"status":"pending","interval":5');
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
cloud = new RegistrationCloud(`http://127.0.0.1:${address.port}`, server);
|
||||||
|
return cloud;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.server.close((error) => {
|
||||||
|
if (error !== undefined) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
108
packages/cli/src/commands/hub/cloud-device-authorization.ts
Normal file
108
packages/cli/src/commands/hub/cloud-device-authorization.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const START_TIMEOUT_MS = 15_000;
|
||||||
|
const activationUrlSchema = z.url({ protocol: /^https?$/u });
|
||||||
|
|
||||||
|
const authorizationSchema = z.object({
|
||||||
|
deviceCode: z.string().min(32),
|
||||||
|
userCode: z.string().min(1),
|
||||||
|
verificationUri: activationUrlSchema,
|
||||||
|
verificationUriComplete: activationUrlSchema,
|
||||||
|
expiresAt: z.string().datetime(),
|
||||||
|
interval: z.number().int().min(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pollSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({ status: z.literal("pending"), interval: z.number().int().min(5) }),
|
||||||
|
z.object({ status: z.literal("slow_down"), interval: z.number().int().min(5) }),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("approved"),
|
||||||
|
interval: z.number().int().min(5),
|
||||||
|
enrollmentToken: z.string().min(32),
|
||||||
|
}),
|
||||||
|
z.object({ status: z.literal("denied"), interval: z.number().int().min(5) }),
|
||||||
|
z.object({ status: z.literal("expired"), interval: z.number().int().min(5) }),
|
||||||
|
z.object({ status: z.literal("enrolled"), interval: z.number().int().min(5) }),
|
||||||
|
z.object({ status: z.literal("retry_later") }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type DeviceAuthorization = z.infer<typeof authorizationSchema>;
|
||||||
|
export type DeviceAuthorizationPoll = z.infer<typeof pollSchema>;
|
||||||
|
|
||||||
|
export interface CloudDeviceAuthorization {
|
||||||
|
start(hubUrl: string, displayName: string): Promise<DeviceAuthorization>;
|
||||||
|
poll(
|
||||||
|
hubUrl: string,
|
||||||
|
deviceCode: string,
|
||||||
|
timeoutMilliseconds: number,
|
||||||
|
): Promise<DeviceAuthorizationPoll>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloudDeviceAuthorizationClient implements CloudDeviceAuthorization {
|
||||||
|
constructor(private readonly startTimeoutMilliseconds = START_TIMEOUT_MS) {}
|
||||||
|
|
||||||
|
async start(hubUrl: string, displayName: string): Promise<DeviceAuthorization> {
|
||||||
|
const signal = AbortSignal.timeout(this.startTimeoutMilliseconds);
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint(hubUrl, "/api/device-authorizations/"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ displayName }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Cloud registration failed (${response.status})`);
|
||||||
|
return authorizationSchema.parse(await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new Error("Cloud registration start timed out", { cause: error });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(
|
||||||
|
hubUrl: string,
|
||||||
|
deviceCode: string,
|
||||||
|
timeoutMilliseconds: number,
|
||||||
|
): Promise<DeviceAuthorizationPoll> {
|
||||||
|
const signal = AbortSignal.timeout(timeoutMilliseconds);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint(hubUrl, "/api/device-authorizations/poll"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ deviceCode }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { status: "retry_later" };
|
||||||
|
}
|
||||||
|
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
|
||||||
|
return { status: "retry_later" };
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(`Cloud registration poll failed (${response.status})`);
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted || error instanceof TypeError) return { status: "retry_later" };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return pollSchema.parse(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(hubUrl: string, pathname: string): string {
|
||||||
|
const url = new URL(hubUrl);
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash
|
||||||
|
) {
|
||||||
|
throw new Error("Hub URL must be an HTTP or HTTPS origin without credentials or a query");
|
||||||
|
}
|
||||||
|
url.pathname = `${url.pathname.replace(/\/$/u, "")}${pathname}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
273
packages/cli/src/commands/hub/device-authorization.test.ts
Normal file
273
packages/cli/src/commands/hub/device-authorization.test.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
import { DeviceAuthorizationWorkflow, SystemBrowser } from "./device-authorization.js";
|
||||||
|
import type {
|
||||||
|
CloudDeviceAuthorization,
|
||||||
|
DeviceAuthorizationPoll,
|
||||||
|
} from "./cloud-device-authorization.js";
|
||||||
|
import { createHubCommand } from "./index.js";
|
||||||
|
|
||||||
|
describe("Hub device authorization", () => {
|
||||||
|
it("opens activation URLs on Windows without a command shell", async () => {
|
||||||
|
const launches: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const browser = new SystemBrowser({
|
||||||
|
hostPlatform: "win32",
|
||||||
|
launch: async (command, args) => void launches.push({ command, args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.open("https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN");
|
||||||
|
|
||||||
|
assert.deepEqual(launches, [
|
||||||
|
{
|
||||||
|
command: "rundll32.exe",
|
||||||
|
args: [
|
||||||
|
"url.dll,FileProtocolHandler",
|
||||||
|
"https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the browser, follows Cloud cadence, and returns the approved enrollment token", async () => {
|
||||||
|
const cloud = new FakeCloud([
|
||||||
|
{ status: "pending", interval: 5 },
|
||||||
|
{ status: "slow_down", interval: 10 },
|
||||||
|
{ status: "approved", interval: 10, enrollmentToken: "approved-enrollment-token-1234567890" },
|
||||||
|
]);
|
||||||
|
const authorization = new AuthorizationJourney(cloud);
|
||||||
|
|
||||||
|
const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac");
|
||||||
|
|
||||||
|
assert.equal(token, "approved-enrollment-token-1234567890");
|
||||||
|
assert.deepEqual(authorization.observed(), {
|
||||||
|
starts: [{ hubUrl: "https://cloud.paseo.test", displayName: "Studio Mac" }],
|
||||||
|
polls: [
|
||||||
|
{
|
||||||
|
hubUrl: "https://cloud.paseo.test",
|
||||||
|
deviceCode: "device-code-with-more-than-thirty-two-characters",
|
||||||
|
timeoutMilliseconds: 595_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hubUrl: "https://cloud.paseo.test",
|
||||||
|
deviceCode: "device-code-with-more-than-thirty-two-characters",
|
||||||
|
timeoutMilliseconds: 590_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hubUrl: "https://cloud.paseo.test",
|
||||||
|
deviceCode: "device-code-with-more-than-thirty-two-characters",
|
||||||
|
timeoutMilliseconds: 580_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
waits: [5_000, 5_000, 10_000],
|
||||||
|
opened: ["https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN"],
|
||||||
|
instructions: ["https://cloud.paseo.test/activate ABCD-EFGH-JKLMN"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops without an enrollment token when the browser denies the request", async () => {
|
||||||
|
const authorization = new AuthorizationJourney(
|
||||||
|
new FakeCloud([{ status: "denied", interval: 5 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), {
|
||||||
|
message: "Daemon registration was denied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers the approved authority after its first poll response is lost", async () => {
|
||||||
|
const authorization = new AuthorizationJourney(
|
||||||
|
new FakeCloud([
|
||||||
|
{ status: "retry_later" },
|
||||||
|
{
|
||||||
|
status: "approved",
|
||||||
|
interval: 5,
|
||||||
|
enrollmentToken: "stable-enrollment-token-after-response-loss",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac");
|
||||||
|
|
||||||
|
assert.equal(token, "stable-enrollment-token-after-response-loss");
|
||||||
|
assert.deepEqual(authorization.observed().waits, [5_000, 5_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries timeout failures only while the fixed authorization expiry remains", async () => {
|
||||||
|
const authorization = new AuthorizationJourney(
|
||||||
|
new FakeCloud(
|
||||||
|
[{ status: "retry_later" }, { status: "retry_later" }],
|
||||||
|
"2026-07-18T12:00:11.000Z",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), {
|
||||||
|
message: "Daemon registration expired",
|
||||||
|
});
|
||||||
|
assert.deepEqual(authorization.observed().waits, [5_000, 5_000, 1_000]);
|
||||||
|
assert.deepEqual(
|
||||||
|
authorization.observed().polls.map(({ timeoutMilliseconds }) => timeoutMilliseconds),
|
||||||
|
[6_000, 1_000],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops without an enrollment token when the request expires", async () => {
|
||||||
|
const authorization = new AuthorizationJourney(
|
||||||
|
new FakeCloud([{ status: "expired", interval: 5 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), {
|
||||||
|
message: "Daemon registration expired",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects the real hub command with a browser-approved token", async () => {
|
||||||
|
const daemon = new FakeDaemon();
|
||||||
|
|
||||||
|
await createHubCommand({
|
||||||
|
connect: async () => daemon,
|
||||||
|
authorize: async (url, displayName) => {
|
||||||
|
assert.equal(url, "https://cloud.paseo.test");
|
||||||
|
assert.equal(displayName, "Studio Mac");
|
||||||
|
return "approved-enrollment-token-1234567890";
|
||||||
|
},
|
||||||
|
displayName: () => "Studio Mac",
|
||||||
|
}).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], {
|
||||||
|
from: "node",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(daemon.connections, [
|
||||||
|
{
|
||||||
|
url: "https://cloud.paseo.test",
|
||||||
|
token: "approved-enrollment-token-1234567890",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(daemon.closed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the default daemon name before starting browser authorization", async () => {
|
||||||
|
const daemon = new FakeDaemon();
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
await createHubCommand({
|
||||||
|
connect: async () => daemon,
|
||||||
|
authorize: async (_url, displayName) => {
|
||||||
|
names.push(displayName);
|
||||||
|
return "approved-enrollment-token-1234567890";
|
||||||
|
},
|
||||||
|
displayName: () => ` ${"very-long-hostname".repeat(10)} `,
|
||||||
|
}).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], {
|
||||||
|
from: "node",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(names, ["very-long-hostname".repeat(10).slice(0, 100)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class AuthorizationJourney {
|
||||||
|
private now = Date.parse("2026-07-18T12:00:00.000Z");
|
||||||
|
private readonly waits: number[] = [];
|
||||||
|
private readonly opened: string[] = [];
|
||||||
|
private readonly instructions: string[] = [];
|
||||||
|
private readonly workflow: DeviceAuthorizationWorkflow;
|
||||||
|
|
||||||
|
constructor(private readonly cloud: FakeCloud) {
|
||||||
|
this.workflow = new DeviceAuthorizationWorkflow({
|
||||||
|
cloud,
|
||||||
|
waiter: {
|
||||||
|
wait: async (milliseconds) => {
|
||||||
|
this.waits.push(milliseconds);
|
||||||
|
this.now += milliseconds;
|
||||||
|
},
|
||||||
|
now: () => this.now,
|
||||||
|
},
|
||||||
|
browser: { open: async (url) => void this.opened.push(url) },
|
||||||
|
reporter: {
|
||||||
|
instructions: (url, code) => void this.instructions.push(`${url} ${code}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(hubUrl: string, displayName: string): Promise<string> {
|
||||||
|
return this.workflow.authorize(hubUrl, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
observed() {
|
||||||
|
return {
|
||||||
|
starts: this.cloud.starts,
|
||||||
|
polls: this.cloud.polls,
|
||||||
|
waits: this.waits,
|
||||||
|
opened: this.opened,
|
||||||
|
instructions: this.instructions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCloud implements CloudDeviceAuthorization {
|
||||||
|
readonly starts: Array<{ hubUrl: string; displayName: string }> = [];
|
||||||
|
readonly polls: Array<{
|
||||||
|
hubUrl: string;
|
||||||
|
deviceCode: string;
|
||||||
|
timeoutMilliseconds: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly outcomes: DeviceAuthorizationPoll[],
|
||||||
|
private readonly expiresAt = "2026-07-18T12:10:00.000Z",
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(hubUrl: string, displayName: string) {
|
||||||
|
this.starts.push({ hubUrl, displayName });
|
||||||
|
return {
|
||||||
|
deviceCode: "device-code-with-more-than-thirty-two-characters",
|
||||||
|
userCode: "ABCD-EFGH-JKLMN",
|
||||||
|
verificationUri: "https://cloud.paseo.test/activate",
|
||||||
|
verificationUriComplete: "https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN",
|
||||||
|
expiresAt: this.expiresAt,
|
||||||
|
interval: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(
|
||||||
|
hubUrl: string,
|
||||||
|
deviceCode: string,
|
||||||
|
timeoutMilliseconds: number,
|
||||||
|
): Promise<DeviceAuthorizationPoll> {
|
||||||
|
this.polls.push({ hubUrl, deviceCode, timeoutMilliseconds });
|
||||||
|
const outcome = this.outcomes[this.polls.length - 1];
|
||||||
|
if (outcome === undefined) throw new Error("No Cloud poll outcome remains");
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDaemon {
|
||||||
|
readonly connections: Array<{ url: string; token: string }> = [];
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
async getHubStatus() {
|
||||||
|
return { status: hubStatus("not_connected") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectHub(url: string, token: string) {
|
||||||
|
this.connections.push({ url, token });
|
||||||
|
return { status: hubStatus("connected") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectHub() {
|
||||||
|
return { status: hubStatus("not_connected") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hubStatus(state: string) {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
daemonId: state === "connected" ? "daemon-1" : null,
|
||||||
|
hubOrigin: state === "connected" ? "https://cloud.paseo.test" : null,
|
||||||
|
scopes: state === "connected" ? ["hub.execution.*"] : [],
|
||||||
|
connectedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
packages/cli/src/commands/hub/device-authorization.ts
Normal file
118
packages/cli/src/commands/hub/device-authorization.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { platform } from "node:os";
|
||||||
|
import {
|
||||||
|
CloudDeviceAuthorizationClient,
|
||||||
|
type CloudDeviceAuthorization,
|
||||||
|
} from "./cloud-device-authorization.js";
|
||||||
|
|
||||||
|
export interface AuthorizationWaiter {
|
||||||
|
wait(milliseconds: number): Promise<void>;
|
||||||
|
now(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserOpener {
|
||||||
|
open(url: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserLaunch = (command: string, args: string[]) => Promise<void>;
|
||||||
|
|
||||||
|
interface SystemBrowserOptions {
|
||||||
|
hostPlatform?: NodeJS.Platform;
|
||||||
|
launch?: BrowserLaunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemBrowser implements BrowserOpener {
|
||||||
|
private readonly hostPlatform: NodeJS.Platform;
|
||||||
|
private readonly launch: BrowserLaunch;
|
||||||
|
|
||||||
|
constructor(options: SystemBrowserOptions = {}) {
|
||||||
|
this.hostPlatform = options.hostPlatform ?? platform();
|
||||||
|
this.launch = options.launch ?? launchDetached;
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(url: string): Promise<void> {
|
||||||
|
if (this.hostPlatform === "win32") {
|
||||||
|
await this.launch("rundll32.exe", ["url.dll,FileProtocolHandler", url]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.launch(this.hostPlatform === "darwin" ? "open" : "xdg-open", [url]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationReporter {
|
||||||
|
instructions(verificationUri: string, userCode: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceAuthorizationWorkflowOptions {
|
||||||
|
cloud: CloudDeviceAuthorization;
|
||||||
|
waiter: AuthorizationWaiter;
|
||||||
|
browser: BrowserOpener;
|
||||||
|
reporter: AuthorizationReporter;
|
||||||
|
openBrowser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceAuthorizationWorkflow {
|
||||||
|
constructor(private readonly options: DeviceAuthorizationWorkflowOptions) {}
|
||||||
|
|
||||||
|
async authorize(hubUrl: string, displayName: string): Promise<string> {
|
||||||
|
const authorization = await this.options.cloud.start(hubUrl, displayName);
|
||||||
|
this.options.reporter.instructions(authorization.verificationUri, authorization.userCode);
|
||||||
|
if (this.options.openBrowser !== false) {
|
||||||
|
await this.options.browser.open(authorization.verificationUriComplete).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval = authorization.interval;
|
||||||
|
const expiresAt = Date.parse(authorization.expiresAt);
|
||||||
|
while (true) {
|
||||||
|
const remaining = expiresAt - this.options.waiter.now();
|
||||||
|
if (remaining <= 0) throw new Error("Daemon registration expired");
|
||||||
|
await this.options.waiter.wait(Math.min(interval * 1_000, remaining));
|
||||||
|
if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired");
|
||||||
|
const pollLifetime = expiresAt - this.options.waiter.now();
|
||||||
|
if (pollLifetime <= 0) throw new Error("Daemon registration expired");
|
||||||
|
const outcome = await this.options.cloud.poll(hubUrl, authorization.deviceCode, pollLifetime);
|
||||||
|
if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired");
|
||||||
|
if (outcome.status === "retry_later") continue;
|
||||||
|
interval = outcome.interval;
|
||||||
|
if (outcome.status === "approved") return outcome.enrollmentToken;
|
||||||
|
if (outcome.status === "denied") throw new Error("Daemon registration was denied");
|
||||||
|
if (outcome.status === "expired") throw new Error("Daemon registration expired");
|
||||||
|
if (outcome.status === "enrolled") {
|
||||||
|
throw new Error("Daemon registration was already used");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceAuthorizationWorkflow(): DeviceAuthorizationWorkflow {
|
||||||
|
return new DeviceAuthorizationWorkflow({
|
||||||
|
cloud: new CloudDeviceAuthorizationClient(),
|
||||||
|
waiter: {
|
||||||
|
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
||||||
|
now: Date.now,
|
||||||
|
},
|
||||||
|
browser: new SystemBrowser(),
|
||||||
|
reporter: {
|
||||||
|
instructions(verificationUri, userCode) {
|
||||||
|
process.stderr.write(`Open ${verificationUri} and enter code ${userCode}\n`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openBrowser: process.stderr.isTTY === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchDetached(command: string, args: string[]): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
detached: true,
|
||||||
|
shell: false,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
child.once("spawn", () => {
|
||||||
|
child.unref();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.once("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,37 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import { hostname } from "node:os";
|
||||||
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
|
import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js";
|
||||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||||
import { connectToDaemon } from "../../utils/client.js";
|
import { connectToDaemon } from "../../utils/client.js";
|
||||||
|
import { createDeviceAuthorizationWorkflow } from "./device-authorization.js";
|
||||||
|
|
||||||
|
interface HubCommandClient {
|
||||||
|
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
|
||||||
|
getHubStatus(): Promise<{ status: HubStatus }>;
|
||||||
|
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HubStatus {
|
||||||
|
state: string;
|
||||||
|
daemonId: string | null;
|
||||||
|
hubOrigin: string | null;
|
||||||
|
scopes: string[];
|
||||||
|
connectedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HubCommandEnvironment {
|
||||||
|
connect(host: string | undefined): Promise<HubCommandClient>;
|
||||||
|
authorize(url: string, displayName: string): Promise<string>;
|
||||||
|
displayName(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionEnvironment: HubCommandEnvironment = {
|
||||||
|
connect: (host) => connectToDaemon({ host }),
|
||||||
|
authorize: (url, displayName) => createDeviceAuthorizationWorkflow().authorize(url, displayName),
|
||||||
|
displayName: hostname,
|
||||||
|
};
|
||||||
|
|
||||||
interface HubRow {
|
interface HubRow {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -55,10 +85,11 @@ function result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function withClient<T>(
|
async function withClient<T>(
|
||||||
|
environment: HubCommandEnvironment,
|
||||||
host: string | undefined,
|
host: string | undefined,
|
||||||
action: (client: Awaited<ReturnType<typeof connectToDaemon>>) => Promise<T>,
|
action: (client: HubCommandClient) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const client = await connectToDaemon({ host });
|
const client = await environment.connect(host);
|
||||||
try {
|
try {
|
||||||
return await action(client);
|
return await action(client);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,23 +97,36 @@ async function withClient<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHubCommand(): Command {
|
export function createHubCommand(
|
||||||
|
environment: HubCommandEnvironment = productionEnvironment,
|
||||||
|
): Command {
|
||||||
const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship");
|
const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship");
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
hub.command("connect").argument("<url>").requiredOption("--token <token>"),
|
hub.command("connect").argument("<url>").option("--token <token>"),
|
||||||
).action(
|
).action(
|
||||||
withOutput(async (...args) => {
|
withOutput(async (...args) => {
|
||||||
const url = args[0] as string;
|
const url = args[0] as string;
|
||||||
const options = args.at(-2) as { token: string; host?: string };
|
const options = args.at(-2) as { token?: string; host?: string };
|
||||||
return withClient(options.host, async (client) =>
|
return withClient(environment, options.host, async (client) => {
|
||||||
result((await client.connectHub(url, options.token)).status),
|
if (options.token !== undefined) {
|
||||||
);
|
return result((await client.connectHub(url, options.token)).status);
|
||||||
|
}
|
||||||
|
const existing = (await client.getHubStatus()).status;
|
||||||
|
if (existing.state !== "not_connected" && existing.state !== "revoked") {
|
||||||
|
throw new Error("This daemon already has a Hub relationship");
|
||||||
|
}
|
||||||
|
const token = await environment.authorize(
|
||||||
|
url,
|
||||||
|
suggestedDisplayName(environment.displayName()),
|
||||||
|
);
|
||||||
|
return result((await client.connectHub(url, token)).status);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
addJsonAndDaemonHostOptions(hub.command("status")).action(
|
addJsonAndDaemonHostOptions(hub.command("status")).action(
|
||||||
withOutput(async (...args) => {
|
withOutput(async (...args) => {
|
||||||
const options = args.at(-2) as { host?: string };
|
const options = args.at(-2) as { host?: string };
|
||||||
return withClient(options.host, async (client) =>
|
return withClient(environment, options.host, async (client) =>
|
||||||
result((await client.getHubStatus()).status),
|
result((await client.getHubStatus()).status),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -94,7 +138,7 @@ export function createHubCommand(): Command {
|
|||||||
).action(
|
).action(
|
||||||
withOutput(async (...args) => {
|
withOutput(async (...args) => {
|
||||||
const options = args.at(-2) as { host?: string; force?: boolean };
|
const options = args.at(-2) as { host?: string; force?: boolean };
|
||||||
return withClient(options.host, async (client) => {
|
return withClient(environment, options.host, async (client) => {
|
||||||
const response = await client.disconnectHub(options.force ?? false);
|
const response = await client.disconnectHub(options.force ?? false);
|
||||||
return result(response.status, response.warning);
|
return result(response.status, response.warning);
|
||||||
});
|
});
|
||||||
@@ -102,3 +146,7 @@ export function createHubCommand(): Command {
|
|||||||
);
|
);
|
||||||
return hub;
|
return hub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suggestedDisplayName(value: string): string {
|
||||||
|
return value.trim().slice(0, 100) || "Paseo daemon";
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { mkdtemp, rm, mkdir } from "fs/promises";
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
import { ChildProcess, spawn } from "child_process";
|
import { ChildProcess, spawn } from "child_process";
|
||||||
import { getAvailablePort } from "./network.ts";
|
import { getAvailablePort } from "./network.ts";
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
|
|||||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||||
};
|
};
|
||||||
const TEST_DAEMON_HOST = "127.0.0.1";
|
const TEST_DAEMON_HOST = "127.0.0.1";
|
||||||
|
const TSX_ENTRY = fileURLToPath(import.meta.resolve("tsx/cli"));
|
||||||
|
|
||||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
|
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
|
||||||
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
||||||
@@ -233,19 +235,23 @@ export async function startTestDaemon(options?: {
|
|||||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||||
|
|
||||||
// Start daemon process using tsx to run TypeScript directly
|
// Start daemon process using tsx to run TypeScript directly
|
||||||
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
|
const daemonProcess = spawn(
|
||||||
env: {
|
process.execPath,
|
||||||
...process.env,
|
[TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"],
|
||||||
...TEST_DAEMON_ENV_DEFAULTS,
|
{
|
||||||
PASEO_HOME: paseoHome,
|
env: {
|
||||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
...process.env,
|
||||||
// Force no TTY to prevent QR code output
|
...TEST_DAEMON_ENV_DEFAULTS,
|
||||||
CI: "true",
|
PASEO_HOME: paseoHome,
|
||||||
...options?.env,
|
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||||
|
// Force no TTY to prevent QR code output
|
||||||
|
CI: "true",
|
||||||
|
...options?.env,
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
);
|
||||||
detached: process.platform !== "win32",
|
|
||||||
});
|
|
||||||
|
|
||||||
const stdout = createOutputCapture();
|
const stdout = createOutputCapture();
|
||||||
const stderr = createOutputCapture();
|
const stderr = createOutputCapture();
|
||||||
@@ -345,7 +351,7 @@ export async function runPaseoCli(
|
|||||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
|
const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...TEST_DAEMON_ENV_DEFAULTS,
|
...TEST_DAEMON_ENV_DEFAULTS,
|
||||||
|
|||||||
Reference in New Issue
Block a user