Skip to content
Merged
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
84 changes: 42 additions & 42 deletions specs/004-self-hosted-onboarding/tasks.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/credential-bridge/credential-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ const BridgeStateSchema = z
credentialReference: z
.string()
.regex(
/^(?:restrictive-file:(?:codex|claude)|secret-service:(?:codex|claude):[0-9a-f-]{36})$/,
/^(?:restrictive-file:(?:codex|claude)(?::[0-9a-f-]{36})?|secret-service:(?:codex|claude):[0-9a-f-]{36})$/,
),
keyId: z.uuid().optional(),
})
.strict(),
)
Expand Down Expand Up @@ -106,7 +107,7 @@ export class CredentialResolver {
);
if (
entry === undefined ||
(entry.credentialReference !== `restrictive-file:${client}` &&
(!entry.credentialReference.startsWith(`restrictive-file:${client}`) &&
!entry.credentialReference.startsWith(`secret-service:${client}:`))
) {
throw new BridgeFailure("BRIDGE_CREDENTIAL_UNAVAILABLE");
Expand Down
82 changes: 73 additions & 9 deletions src/onboarding/adapters/credentials/restrictive-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
validateOwnedPath,
} from "../filesystem/safe-paths.js";

export type RestrictiveFileReference = `restrictive-file:${ClientName}`;
export type RestrictiveFileReference =
`restrictive-file:${ClientName}` | `restrictive-file:${ClientName}:${string}`;

export class RestrictiveFileCredentialStore {
public constructor(
Expand Down Expand Up @@ -52,6 +53,18 @@ export class RestrictiveFileCredentialStore {
return root;
}

private async syncDirectory(path: string): Promise<void> {
const handle = await open(
path,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
await handle.sync();
} finally {
await handle.close();
}
}

async store(
client: ClientName,
token: string,
Expand Down Expand Up @@ -79,16 +92,67 @@ export class RestrictiveFileCredentialStore {
} finally {
await handle.close();
}
await this.syncDirectory(root);
return `restrictive-file:${client}`;
}

async storeReplacement(
client: ClientName,
token: string,
referenceId: string,
): Promise<RestrictiveFileReference> {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
referenceId,
)
)
throw new Error("Replacement credential identity is invalid");
if (parseApiKeyToken(token) === undefined)
throw new Error("Client credential has an invalid shape");
const root = await this.credentialRoot();
const path = resolve(root, `${client}.${referenceId}.key`);
const handle = await open(
path,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o600,
);
try {
await handle.writeFile(token, "ascii");
await handle.sync();
} finally {
await handle.close();
}
await this.syncDirectory(root);
return `restrictive-file:${client}:${referenceId}`;
}

private referencePath(reference: RestrictiveFileReference): {
readonly client: ClientName;
readonly name: string;
} {
const match = /^restrictive-file:(codex|claude)(?::([0-9a-f-]{36}))?$/.exec(
reference,
);
if (match === null) throw new Error("Credential reference is invalid");
const client = match[1] as ClientName;
const generation = match[2];
return {
client,
name:
generation === undefined
? `${client}.key`
: `${client}.${generation}.key`,
};
}

async lookup(reference: RestrictiveFileReference): Promise<string> {
const client = reference.slice("restrictive-file:".length);
if (client !== "codex" && client !== "claude")
throw new Error("Credential reference is invalid");
const { name } = this.referencePath(reference);
const root = await this.credentialRoot();
const handle = await open(
resolve(root, `${client}.key`),
resolve(root, name),
constants.O_RDONLY | constants.O_NOFOLLOW,
);
try {
Expand Down Expand Up @@ -116,23 +180,23 @@ export class RestrictiveFileCredentialStore {
}

async remove(reference: RestrictiveFileReference): Promise<void> {
const client = reference.slice("restrictive-file:".length);
if (client !== "codex" && client !== "claude")
throw new Error("Credential reference is invalid");
const { name } = this.referencePath(reference);
const root = await this.credentialRoot();
const path = await validateOwnedPath(resolve(root, `${client}.key`), root);
const path = await validateOwnedPath(resolve(root, name), root);
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stats = await handle.stat();
if (
!stats.isFile() ||
stats.nlink !== 1 ||
stats.uid !== process.getuid?.() ||
(stats.mode & 0o777) !== 0o600
)
throw new Error("Credential ownership is ambiguous");
} finally {
await handle.close();
}
await unlink(path);
await this.syncDirectory(root);
}
}
85 changes: 68 additions & 17 deletions src/onboarding/adapters/docker/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type CommandOptions,
type CommandResult,
} from "../process/command-runner.js";
import { dockerProcessEnvironment } from "./environment.js";

type CommandExecutor = (options: CommandOptions) => Promise<CommandResult>;

Expand All @@ -22,6 +23,7 @@ export interface DeploymentOptions {
readonly applicationPepperFile: string;
readonly runtimeSocketDirectory: string;
readonly socketPath: string;
readonly hostEnvironment?: NodeJS.ProcessEnv | undefined;
readonly run?: CommandExecutor | undefined;
readonly readinessProbe?:
((socketPath: string, signal: AbortSignal) => Promise<boolean>) | undefined;
Expand Down Expand Up @@ -118,8 +120,7 @@ export class DeploymentAdapter {
executable: resolve(this.options.dockerExecutable),
args,
environment: {
PATH: "/usr/bin:/bin",
LANG: "C.UTF-8",
...dockerProcessEnvironment(this.options.hostEnvironment ?? {}),
SKILLWIRE_COMPOSE_PROJECT: this.options.projectName,
SKILLWIRE_POSTGRES_VOLUME: this.options.volumeName,
SKILLWIRE_IMAGE: this.options.skillwireImage,
Expand Down Expand Up @@ -151,21 +152,27 @@ export class DeploymentAdapter {
);
if (composeVersion === null || Number(composeVersion[1]) < 2)
throw new Error("Unsupported Docker Compose version");
const context = (
await this.command(["context", "show"], signal)
).stdout.trim();
const endpoint = (
await this.command(
[
"context",
"inspect",
context,
"--format",
"{{.Endpoints.docker.Host}}",
],
signal,
)
).stdout.trim();
const routedEnvironment = dockerProcessEnvironment(
this.options.hostEnvironment ?? {},
);
const pinnedEndpoint =
this.options.hostEnvironment?.["DOCKER_CONTEXT"] === undefined
? routedEnvironment["DOCKER_HOST"]
: undefined;
const endpoint =
pinnedEndpoint ??
(
await this.command(
[
"context",
"inspect",
(await this.command(["context", "show"], signal)).stdout.trim(),
"--format",
"{{.Endpoints.docker.Host}}",
],
signal,
)
).stdout.trim();
if (!endpoint.startsWith("unix://") && !endpoint.startsWith("npipe://"))
throw new Error(
"A local Docker context is required; remote contexts are refused",
Expand Down Expand Up @@ -281,4 +288,48 @@ export class DeploymentAdapter {
`SkillWire readiness failed${lastError instanceof Error ? `: ${lastError.message}` : ""}`,
);
}

async observeOwnedService(
service: "skillwire" | "postgres",
signal: AbortSignal,
): Promise<boolean> {
const listed = await this.command(
[
"compose",
"--project-name",
this.options.projectName,
"--file",
this.options.composePath,
"ps",
"--all",
"--quiet",
service,
],
signal,
);
const identities = listed.stdout.trim().split("\n").filter(Boolean);
if (identities.length === 0) return false;
if (identities.length !== 1)
throw new Error("Owned Compose service identity is ambiguous");
const inspected = await this.command(
[
"container",
"inspect",
identities[0] ?? "",
"--format",
'{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}|{{.Config.Image}}',
],
signal,
);
const expectedImage =
service === "skillwire"
? this.options.skillwireImage
: this.options.postgresImage;
if (
inspected.stdout.trim() !==
`${this.options.projectName}|${service}|${expectedImage}`
)
throw new Error("Owned Compose service labels or image identity drifted");
return true;
}
}
129 changes: 129 additions & 0 deletions src/onboarding/adapters/docker/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { isAbsolute } from "node:path";

import {
runCommand,
type CommandOptions,
type CommandResult,
} from "../process/command-runner.js";

const ROUTING_KEYS = [
"HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"DOCKER_HOST",
"DOCKER_CONTEXT",
"DOCKER_CONFIG",
"DOCKER_CERT_PATH",
"DOCKER_TLS_VERIFY",
] as const;

function safeRoutingValue(key: (typeof ROUTING_KEYS)[number], value: string) {
if (value.length === 0 || value.length > 4096 || /[\0\r\n]/.test(value))
throw new Error(`Docker ${key} routing value is invalid`);
if (
(key === "HOME" ||
key === "XDG_CONFIG_HOME" ||
key === "XDG_RUNTIME_DIR" ||
key === "DOCKER_CONFIG" ||
key === "DOCKER_CERT_PATH") &&
!isAbsolute(value)
)
throw new Error(`Docker ${key} path must be absolute`);
if (
key === "DOCKER_HOST" &&
!value.startsWith("unix://") &&
!value.startsWith("npipe://")
)
throw new Error("Only a local Docker endpoint is supported");
if (
key === "DOCKER_CONTEXT" &&
!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value)
)
throw new Error("Docker context name is invalid");
if (key === "DOCKER_TLS_VERIFY" && value !== "0" && value !== "1")
throw new Error("Docker TLS routing value is invalid");
return value;
}

export function dockerProcessEnvironment(
ambient: NodeJS.ProcessEnv,
explicit: Readonly<Record<string, string>> = {},
): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {
PATH: "/usr/bin:/bin",
LANG: "C.UTF-8",
};
for (const key of ROUTING_KEYS) {
const value = ambient[key];
if (value !== undefined) result[key] = safeRoutingValue(key, value);
}
for (const [key, value] of Object.entries(explicit)) {
if (
!/^SKILLWIRE_[A-Z0-9_]{1,96}$/.test(key) ||
value.length === 0 ||
value.length > 4096 ||
/[\0\r\n]/.test(value)
)
throw new Error("Explicit Docker Compose environment is invalid");
result[key] = value;
}
return result;
}

export async function assertLocalDockerContext(options: {
readonly dockerExecutable: string;
readonly environment: NodeJS.ProcessEnv;
readonly signal: AbortSignal;
readonly run?:
((options: CommandOptions) => Promise<CommandResult>) | undefined;
}): Promise<string> {
if (!isAbsolute(options.dockerExecutable))
throw new Error("Docker executable must be absolute");
if (options.signal.aborted) throw new Error("Docker context check cancelled");
const routedEnvironment = dockerProcessEnvironment(options.environment);
if (options.environment["DOCKER_CONTEXT"] === undefined) {
const explicitHost = routedEnvironment["DOCKER_HOST"];
if (explicitHost !== undefined) return explicitHost;
}
const run = options.run ?? runCommand;
const command = (args: readonly string[]) =>
run({
executable: options.dockerExecutable,
args,
environment: routedEnvironment,
deadlineMilliseconds: 10_000,
maximumOutputBytes: 16 * 1024,
signal: options.signal,
});
const context = (await command(["context", "show"])).stdout.trim();
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(context))
throw new Error("Effective Docker context identity is invalid");
const endpoint = (
await command([
"context",
"inspect",
context,
"--format",
"{{.Endpoints.docker.Host}}",
])
).stdout.trim();
if (
endpoint.length === 0 ||
endpoint.length > 4096 ||
/[\0\r\n]/.test(endpoint) ||
(!endpoint.startsWith("unix://") && !endpoint.startsWith("npipe://"))
)
throw new Error(
"A local Docker context is required; remote contexts are refused",
);
return endpoint;
}

export function pinLocalDockerEndpoint(
environment: NodeJS.ProcessEnv,
endpoint: string,
): NodeJS.ProcessEnv {
const pinned: NodeJS.ProcessEnv = { ...environment, DOCKER_HOST: endpoint };
delete pinned["DOCKER_CONTEXT"];
return pinned;
}
Loading