Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pair-explains-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/local-server": patch
---

`executor pair` no longer creates the data directory, an installation record or new keys. It now says when the directory has no saved keys, when no server answers on the port, and when the server rejects the API key.
101 changes: 101 additions & 0 deletions apps/local/server/src/implementation/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,107 @@ export const localConfiguration = (platform: string) =>
),
);

/**
* Read one directory's saved keys for a client of its running server, such as `executor pair`.
* Unlike `localConfiguration` it never creates the directory, an installation record or keys, and
* takes no lock: the server holds none once started, and nothing here is written.
*/
export const savedConfiguration = (platform: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const base = yield* ConfigProvider.ConfigProvider;
const directory = path.resolve(
yield* Config.String("EXECUTOR_DATA_DIR").pipe(Config.withDefault(".local/executor")),
);
const explicitApi = yield* Config.Redacted("EXECUTOR_API_KEY").pipe(Config.option);
const explicitEncryption = yield* Config.Redacted("EXECUTOR_ENCRYPTION_KEY").pipe(
Config.option,
);
if (Option.isSome(explicitApi) !== Option.isSome(explicitEncryption))
return yield* new LocalConfigurationError({
reason: "misconfigured",
message:
"Supply both EXECUTOR_API_KEY and EXECUTOR_ENCRYPTION_KEY, or leave both unset to use the saved keys.",
});
if (Option.isSome(explicitApi)) return yield* config;
const marker = path.join(directory, "installation.json");
const installation = (yield* fs.exists(marker))
? yield* fs
.readFileString(marker)
.pipe(
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Installation))),
Effect.mapError(invalid),
)
: undefined;
if (installation === undefined || installation.state === "pending")
return yield* new LocalConfigurationError({
reason: "credential-missing",
message: `${directory} has no saved keys. Start Executor first, or set EXECUTOR_DATA_DIR to the folder the running server uses. No new keys were created.`,
});
if (installation.state === "external")
return yield* new LocalConfigurationError({
reason: "credential-missing",
message:
"This directory uses supplied keys. Set its original EXECUTOR_API_KEY and EXECUTOR_ENCRYPTION_KEY. No replacement keys were created.",
});
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Keys));
const keyFile = path.join(directory, "keys.json");
const keys =
installation.state === "file"
? yield* fs.readFileString(keyFile).pipe(
Effect.flatMap(decode),
Effect.mapError((error) => keyFileUnusable(keyFile, error)),
)
: yield* credentialEntry(installation.id).pipe(
Effect.flatMap((entry) =>
Effect.tryPromise({
try: (signal) => entry.getPassword(signal),
catch: classify(platform),
}),
),
Effect.mapError((failure) =>
failure.kind === "denied"
? new LocalConfigurationError({
reason: "credential-denied",
message: `Access to the OS credential store was denied, or the store is locked (${failure.reason}). Allow access or unlock the store, then try again. Nothing was changed.`,
})
: unavailable(failure.reason, false),
),
Effect.flatMap((stored) =>
stored === undefined || stored === null
? Effect.fail(
new LocalConfigurationError({
reason: "credential-missing",
message:
"Executor's OS credential is missing for an existing installation. Restore that credential from your backup. It has not been replaced.",
}),
)
: decode(stored).pipe(Effect.mapError(invalid)),
),
);
return yield* config.pipe(
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.fromUnknown({
EXECUTOR_DATA_DIR: directory,
EXECUTOR_API_KEY: Redacted.value(keys.apiKey),
EXECUTOR_ENCRYPTION_KEY: Redacted.value(keys.encryptionKey),
}).pipe(ConfigProvider.orElse(base)),
),
);
}).pipe(
Effect.catchTag("PlatformError", () =>
Effect.fail(
new LocalConfigurationError({
reason: "io",
message:
"Executor could not check for its installation record. Check the data directory permissions. Nothing was changed.",
}),
),
),
);

const unchanged = (reason: LocalConfigurationReason, message: string) =>
new LocalConfigurationError({ reason, message: `${message} The API key was not changed.` });

Expand Down
32 changes: 27 additions & 5 deletions apps/local/server/src/implementation/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
import { HttpApiClient } from "effect/unstable/httpapi";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { LocalAuthApi } from "../contracts/auth.ts";
import { localConfiguration } from "./bootstrap.ts";
import { LocalConfigurationError, localConfiguration, savedConfiguration } from "./bootstrap.ts";
import { StartupFailed, type LaunchMode } from "../contracts/startup.ts";
import { readDesktopBootstrap, startLocalServer } from "../node.ts";
import { updateNotice } from "./update-notice.ts";
Expand All @@ -28,18 +28,40 @@ const openBrowser = (url: Redacted.Redacted<string>, platform: string) =>
*/
export const launch = (mode: LaunchMode, platform: string, installation?: string) =>
Effect.gen(function* () {
const settings = yield* localConfiguration(platform);
if (mode === "pair") {
const failed = () => Effect.fail(new StartupFailed({ stage: "pair" }));
const settings = yield* savedConfiguration(platform);
const client = yield* HttpApiClient.make(LocalAuthApi, {
baseUrl: `http://127.0.0.1:${settings.port}`,
transformClient: (client) =>
client.pipe(HttpClient.mapRequest(HttpClientRequest.bearerToken(settings.apiKey))),
}).pipe(Effect.provide(FetchHttpClient.layer));
const link = yield* client.auth
.pair()
.pipe(Effect.mapError(() => new StartupFailed({ stage: "pair" })));
const link = yield* client.auth.pair().pipe(
Effect.catchTags({
PairingUnauthorized: () =>
Effect.fail(
new LocalConfigurationError({
reason: "misconfigured",
message: `The server on 127.0.0.1:${settings.port} did not accept the API key for ${settings.directory}. It probably uses another data directory: set EXECUTOR_DATA_DIR to the folder it uses. Nothing was changed.`,
}),
),
HttpClientError: (error) =>
Effect.fail(
error.reason._tag === "TransportError"
? new LocalConfigurationError({
reason: "io",
message: `No Executor server answered on 127.0.0.1:${settings.port}. Start Executor first, or set EXECUTOR_PORT to the port it listens on. Nothing was changed.`,
})
: new StartupFailed({ stage: "pair" }),
),
AuthForbidden: failed,
AuthStorageError: failed,
SchemaError: failed,
}),
);
return yield* Console.log(Redacted.value(link.url));
}
const settings = yield* localConfiguration(platform);
const bootstrap = mode === "desktop" ? yield* readDesktopBootstrap : undefined;
const server = yield* startLocalServer(settings, bootstrap);
if (mode === "desktop") {
Expand Down
9 changes: 9 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,15 @@ export const scenarios = {
"self-host": na("Local process startup"),
},
},
localPair: {
file: "local-pair.spec.ts",
title: "local pair prints a link and explains a wrong directory or port without creating keys",
targets: {
local: scheduled,
cloud: na("Local CLI pairing"),
"self-host": na("Local CLI pairing"),
},
},
optimisticObservability: {
fixtures: "actors",
file: "optimistic-observability.spec.ts",
Expand Down
100 changes: 100 additions & 0 deletions e2e/tests/local-pair.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/** Run the real `executor pair` against a server the scenario starts with its own data directory. */
import { expect, layer } from "@effect/vitest";
import { Config, Effect, FileSystem, Path, Schedule, Stream } from "effect";
import { HttpClient } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { scenarios } from "../test-plan.ts";
import { TestLive, withCase } from "../support/case.ts";
import { Evidence } from "../support/evidence.ts";
import { freePort } from "../support/ports.ts";

layer(TestLive, { excludeTestServices: true })("Local pair", (it) => {
it.effect(scenarios.localPair.title, (context) =>
withCase(
context,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem,
path = yield* Path.Path,
processes = yield* ChildProcessSpawner.ChildProcessSpawner,
http = yield* HttpClient.HttpClient,
evidence = yield* Evidence;
const entry = path.resolve(
yield* Config.NonEmptyString("EXECUTOR_E2E_LOCAL_ENTRY").pipe(
Config.withDefault("apps/local/server/src/bin.ts"),
),
);
const command = (subcommand: string, directory: string, port: number) =>
ChildProcess.make("node", [entry, subcommand], {
extendEnv: false,
env: {
PATH: process.env.PATH ?? "",
// Release scenarios never send product analytics, even from a build with a baked key.
DO_NOT_TRACK: "1",
EXECUTOR_ENVIRONMENT: "e2e",
// Keys stay in keys.json so no run touches the machine's OS credential store.
EXECUTOR_KEY_STORAGE: "file",
EXECUTOR_WORKER_BUNDLE: path.resolve(".local/test-runtime/host.json"),
EXECUTOR_DATA_DIR: directory,
EXECUTOR_PORT: String(port),
},
stdout: "pipe",
stderr: "pipe",
forceKillAfter: "3 seconds",
});
const pair = (directory: string, port: number) =>
evidence.step(
`executor pair (${path.basename(directory)}, port ${port})`,
Effect.scoped(
Effect.gen(function* () {
const child = yield* processes.spawn(command("pair", directory, port));
const [code, stdout, stderr] = yield* Effect.all(
[
child.exitCode,
child.stdout.pipe(Stream.decodeText(), Stream.mkString),
child.stderr.pipe(Stream.decodeText(), Stream.mkString),
],
{ concurrency: 3 },
).pipe(Effect.timeout("30 seconds"));
return { code: Number(code), stdout, stderr };
}),
),
);

const root = yield* fs.makeTempDirectoryScoped({ prefix: "executor-pair-" });
const running = path.join(root, "running");
const port = yield* freePort;
const server = yield* processes.spawn(command("serve", running, port));
yield* server.stdout.pipe(Stream.runDrain, Effect.forkScoped);
yield* server.stderr.pipe(Stream.runDrain, Effect.forkScoped);
const ready = http.get(`http://127.0.0.1:${port}/auth/session`).pipe(
Effect.flatMap((response) => response.json),
Effect.timeout("2 seconds"),
Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 150 }),
);
const exited = server.exitCode.pipe(
Effect.flatMap(() => Effect.fail(new Error("executor serve exited before readiness"))),
);
expect(yield* Effect.raceFirst(ready, exited)).toEqual({ authenticated: false });

// The running server's own directory pairs.
const paired = yield* pair(running, port);
expect(paired.code, paired.stderr).toBe(0);
expect(paired.stdout).toContain(`http://127.0.0.1:${port}/`);

// A directory without saved keys is refused, and not even the directory is created.
const other = path.join(root, "other");
const refused = yield* pair(other, port);
yield* evidence.json("no-saved-keys.json", refused);
expect(refused.code).toBe(1);
expect(refused.stderr).toContain("has no saved keys");
expect(yield* fs.exists(other)).toBe(false);

// A port with no server says so instead of blaming the keys.
const silent = yield* pair(running, yield* freePort);
yield* evidence.json("no-server.json", silent);
expect(silent.code).toBe(1);
expect(silent.stderr).toContain("No Executor server answered on 127.0.0.1:");
}),
),
);
});