#!/usr/bin/env -S deno run -A
/**
* The console's one launch path: resolve its configuration from the fabric
* being started, print what each value resolved to and where it came from, and
* serve.
*
* deno task --cwd packages/cf-harness console:launch --instance loom
* deno task --cwd packages/cf-harness console:launch \
* --fabric-api-url http://localhost:8000 --store
*
* The console needs an identity, a space, a toolshed URL, and the store that
* toolshed serves. An operator transcribing those by hand gets a console that
* starts cleanly and is wrong: a store keyed to a superseded labs pin reads as
* "no data at cell". So each
* value is derived from the record that decides it, tagged with where it came
* from, and printed once before the server binds. Anything that cannot be
* derived is a named flag whose absence is an error naming it, never a default
* nobody chose.
*
* Which driver the console's sandbox runs on is read from the environment,
* through the derivation every cf-harness entrypoint shares, and the server
* reads the same variables the same way. The launch prints the `runsc`
* binary, rootfs and CFC policy the selection settled on. Where the
* environment names no driver, a launch by hand takes its platform's default,
* the native runtime on macOS and Linux, and is refused on every other
* platform, which has none; a launch with `--instance` is refused on every
* platform: loom chooses the driver of each instance, and names it for the
* console it starts. `docker` named is refused everywhere.
*
* A loom instance is one source among several rather than the shape of this
* module: `--instance` reads the identity, space and toolshed URL off that
* instance's records, and a fabric with no instance behind it names them
* itself. Arguments after `--` reach the console untouched, so a flag this
* launcher has no opinion about is still reachable through it rather than
* through a second launch path.
*
* `scripts/start-local-dev.sh --cf-harness` is what calls this for the fabric
* it is starting, and is how a person starts a console. The console's own flags
* and routes are in [`README.md`](README.md); the operator procedure is in
* [`../docs/WEAVER.md`](../docs/WEAVER.md).
*/
import { parseArgs } from "@std/cli/parse-args";
import { fromFileUrl, join } from "@std/path";
import { isObjectNotArray } from "@commonfabric/utils/types";
import {
argvHolds,
flagUsageLines,
HELP_SPELLINGS,
recordUndeclaredFlags,
refuseFlagsWithoutValue,
refuseUndeclaredFlags,
} from "../src/cli-flags.ts";
import { HarnessControlError } from "../src/control-errors.ts";
import { DEFAULT_HARNESS_CFC_ENFORCEMENT_MODE } from "../src/config.ts";
import type { HarnessConnectorGrantSpec } from "../src/contracts/well-known-grants.ts";
import {
nativeStoreCfcPolicy,
resolveSandboxRuntimeSelection,
RUNSC_BINARY_ENV,
RUNSC_CFC_POLICY_ENV,
SANDBOX_ROOTFS_ENV,
SANDBOX_RUNTIME_ENV,
type SandboxPlatform,
type SandboxProcess,
sandboxProcessOf,
type SandboxRuntimeChoice,
sandboxRuntimeChoiceReason,
type SandboxRuntimeSelection,
} from "../src/sandbox/runtime-selection.ts";
import {
connectorGrantLabel,
connectorGrantName,
} from "../src/well-known-grants.ts";
import { resolveConnectorGrants } from "./connector-grants.ts";
import type {
ConsoleHealthFactWithState,
ConsoleLaunchHealth,
ConsoleObservedLaunchHealth,
ConsoleResolvedValue,
} from "./health.ts";
import {
CONSOLE_FLAGS,
CONSOLE_STRING_FLAGS,
consoleHelpText,
type ConsoleHost,
parseConsoleArgs,
refuseBatchSandboxFlags,
runscWithoutPolicyRefusesTurns,
startConsoleServer,
} from "./server.ts";
/** The port Weaver's harness-console setting and loom's proxy both address. */
export const WEAVER_PAIRING_PORT = 8135;
/**
* The index and registry this deployment's consoles read. They belong to the
* deployment rather than to any one fabric, so nothing derives them; they are
* stated here, printed as what they are, and moved by the environment variables
* the console already reads.
*/
export const DEPLOYMENT_PATTERN_INDEX_URL =
"https://us-central1-pattern-index.cloudfunctions.net";
export const DEPLOYMENT_SKILLS_REGISTRY_URL = "https://skills.sh";
/**
* Proxy variables reach the console as ambient environment and break it two
* ways: loopback toolshed calls leave the machine, and the pattern index sees
* a proxy's identity rather than the console's. The launcher removes them from
* the environment it serves under and reports that it did.
*/
const PROXY_VARIABLES = [
"HTTP_PROXY",
"HTTPS_PROXY",
"http_proxy",
"https_proxy",
"ALL_PROXY",
"all_proxy",
] as const;
/**
* Every variable the launcher decides. A value it resolves is set; a value it
* resolves to nothing is removed, because the printed report is a claim about
* what the console runs under and an inherited variable the report does not
* mention would make that claim untrue. `CF_HARNESS_SPACE_DB` is here without
* being set: it names a store file directly, so an inherited one would silently
* displace the store the report attributes to loom. Pass `-- --space-db `
* to name one, where it is a decision on the command line rather than ambient.
*/
export const LAUNCHER_OWNED_VARIABLES = [
"CF_HARNESS_CONSOLE_PORT",
"CF_HARNESS_CONSOLE_DIR",
"CF_HARNESS_FABRIC_API_URL",
"CF_HARNESS_FABRIC_IDENTITY",
"CF_HARNESS_FABRIC_SPACE",
"CF_HARNESS_FABRIC_CFC_POSTURE",
"CF_HARNESS_FABRIC_CFC_FLOW_LABELS",
"CF_HARNESS_FABRIC_CFC_ENFORCEMENT_MODE",
"CF_HARNESS_CONNECTOR_GRANTS",
"CF_HARNESS_PATTERN_INDEX_URL",
"CF_HARNESS_SKILLS_REGISTRY_URL",
"CF_HARNESS_ALLOW_SKILL_SCRIPTS",
"CF_HARNESS_SPACE_DB",
"MEMORY_DIR",
] as const;
/**
* One resolved value, with the record that decided it. `source` is what an
* operator reads to know which file to edit when a value is wrong.
*/
export type ResolvedValue = ConsoleResolvedValue;
/**
* A loom instance's own records, when the fabric being started is an
* instance's. Every field is the verbatim content of one record, so resolution
* itself touches no filesystem and no subprocess.
*/
export interface LoomInstanceRecords {
/** The instance's name, for error text and the console's directory. */
id: string;
/** The instance's `pieces.json`. */
piecesJson: string;
/** The absolute path `piecesJson` was read from, for error text. */
piecesJsonPath: string;
/**
* What `loom toolshed-store-dir ` printed: a `file://` URL for the
* store keyed to the labs pin loom vendors, or an empty string when loom
* could not instance-scope it.
*/
toolshedStoreDir: string;
/**
* The instance's `sqlite-injection/handles.json` — the receipt its daemon
* writes for the connector handles it has injected — or `undefined` when
* the file does not exist yet, which is what an instance that has injected
* none looks like.
*/
handlesJson?: string;
/** The absolute path `handlesJson` would be read from, for error text. */
handlesJsonPath: string;
}
/** What the launcher read before resolving anything. */
export interface ConsoleLaunchRecords {
/** Present only when `--instance` named a loom instance. */
instance?: LoomInstanceRecords;
/** The sandbox runtime the launch environment selects. */
sandbox: ConsoleLaunchSandbox;
}
/** The sandbox runtime a launch's environment selects. */
export interface ConsoleLaunchSandbox {
/** As `resolveSandboxRuntimeSelection` derives it from the environment. */
selection: SandboxRuntimeSelection;
/** Whether `CF_HARNESS_RUNSC_BINARY` named the `runsc` binary. */
binaryNamed: boolean;
/** Whether `CF_HARNESS_SANDBOX_ROOTFS` named the rootfs. */
rootfsNamed: boolean;
/**
* Whether `CF_HARNESS_RUNSC_CFC_POLICY` named the policy, rather than the
* selection finding a default one.
*/
policyNamed: boolean;
}
/**
* What the caller names itself. Each of these wins over whatever record would
* otherwise decide the same value, and is reported as named rather than
* derived.
*/
export interface ConsoleLaunchOptions {
port?: number;
/**
* What the launching shell already held. Ranked BELOW what a loom instance
* records: an exported variable is a fact about the shell, and an instance
* that has written down its own identity, space, toolshed or store has said
* something more specific than the ambient one. Ranking them the other way
* makes a console read a store the instance does not use while reporting the
* value as though someone chose it.
*/
inheritedIdentity?: string;
inheritedSpace?: string;
inheritedToolshedUrl?: string;
inheritedStore?: string;
inheritedPort?: number;
consoleDir?: string;
identity?: string;
space?: string;
toolshedUrl?: string;
store?: string;
patternIndexUrl?: string;
skillsRegistryUrl?: string;
noPatternIndex?: boolean;
noSkillsRegistry?: boolean;
allowSkillScripts?: boolean;
inheritedAllowSkillScripts?: boolean;
posture?: string;
flowLabels?: string;
enforcementMode?: string;
}
/** The environment to serve under, and the printout that accounts for it. */
export interface ConsoleLaunchPlan {
environment: Record;
resolved: readonly ResolvedValue[];
/** The same decisions retained for the operator status endpoint. */
health: ConsoleLaunchHealth;
}
/** Source labels the printout uses for a value nothing recorded. */
const NAMED = "named on the command line";
const LAUNCHER_DEFAULT = "launcher default";
const nonEmpty = (value: string | undefined): string | undefined =>
value === undefined || value.trim() === "" ? undefined : value.trim();
const parseJsonRecord = (
text: string,
path: string,
): Record => {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
throw new Error(
`\`${path}\` is not valid JSON: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (!isObjectNotArray(parsed)) {
throw new Error(`\`${path}\` does not hold a JSON object`);
}
return parsed as Record;
};
const numericField = (
record: Record,
key: string,
): number | undefined => {
const value = record[key];
const parsed = typeof value === "number"
? value
: typeof value === "string"
? Number(value.trim())
: Number.NaN;
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
};
const stringField = (
record: Record,
key: string,
): string | undefined => {
const value = record[key];
return typeof value === "string" ? nonEmpty(value) : undefined;
};
const objectField = (
record: Record,
key: string,
): Record => {
const value = record[key];
return isObjectNotArray(value) ? value as Record : {};
};
/**
* The plain filesystem path a store value names.
*
* `loom toolshed-store-dir` prints a `file://` URL, because the toolshed reads
* `MEMORY_DIR` through `new URL()`. Inside this process nothing does: the space
* store reader treats `MEMORY_DIR` as a directory to walk, and a `file://` URL
* is not one — it walks nothing, falls through to its other candidate roots,
* and reads a different store's cells as though they were this space's. So the
* console is given the path, and the toolshed keeps the URL loom launched it
* with.
*/
const storeDirectoryPath = (memoryDir: string): string => {
try {
return fromFileUrl(memoryDir);
} catch {
return memoryDir;
}
};
/**
* Resolves the environment the console serves under, and the account of where
* each value came from.
*
* Throws with the flag to set whenever a value is neither recorded by a loom
* instance nor named by `options`, so an operator reads what to supply rather
* than discovering later that a run had no labels or no data.
*/
export const resolveConsoleLaunchPlan = (
records: ConsoleLaunchRecords,
options: ConsoleLaunchOptions,
): ConsoleLaunchPlan => {
const { instance } = records;
const defaults = instance === undefined ? {} : objectField(
parseJsonRecord(instance.piecesJson, instance.piecesJsonPath),
"defaults",
);
const instanceSource = instance === undefined
? undefined
: `\`${instance.piecesJsonPath}\``;
// Each of the four the console cannot start without, in one shape: what the
// caller named, else what the instance recorded, else an error naming the
// flag and the variable that supply it. A fabric with no instance behind it
// reaches the same error rather than a different code path.
const fromPieces = (
record: Record,
key: string,
): { value: string; source: string } | undefined => {
const value = stringField(record, key);
return value === undefined || instanceSource === undefined
? undefined
: { value, source: instanceSource };
};
const required = (
named: string | undefined,
recorded: { value: string; source: string } | undefined,
inherited: string | undefined,
what: string,
flag: string,
variable: string,
): { value: string; source: string } => {
if (named !== undefined) {
return { value: named, source: NAMED };
}
if (recorded !== undefined) {
return recorded;
}
if (inherited !== undefined) {
return { value: inherited, source: `\`${variable}\`, inherited` };
}
throw new Error(
`no ${what}: set \`${flag}\` or \`${variable}\`` +
(instance === undefined
? ""
: `, or record it in \`${instance.piecesJsonPath}\``),
);
};
const identity = required(
options.identity,
fromPieces(defaults, "identity"),
options.inheritedIdentity,
"identity keyfile, which the console signs every write with",
"--fabric-identity",
"CF_HARNESS_FABRIC_IDENTITY` or `CF_IDENTITY",
);
const space = required(
options.space,
fromPieces(defaults, "local_space"),
options.inheritedSpace,
"space, which the console writes its pieces into",
"--fabric-space",
"CF_HARNESS_FABRIC_SPACE` or `CF_SPACE",
);
const toolshedUrl = required(
options.toolshedUrl,
fromPieces(objectField(defaults, "server_urls"), "toolshed"),
options.inheritedToolshedUrl,
"toolshed URL, which is the fabric the console runs against",
"--fabric-api-url",
"CF_HARNESS_FABRIC_API_URL",
);
const store = required(
options.store,
instance === undefined || nonEmpty(instance.toolshedStoreDir) === undefined
? undefined
: {
value: instance.toolshedStoreDir,
source: `\`loom toolshed-store-dir ${instance.id}\``,
},
options.inheritedStore,
"store, which is where the console reads the labels a run wrote",
"--store",
"MEMORY_DIR",
);
// Every session this console runs holds these, so they are resolved here
// with the rest of the fabric's own facts rather than attached per task by
// whatever opens the console.
const connectorGrants: HarnessConnectorGrantSpec[] = [];
const connectorResolved: ResolvedValue[] = [];
const connectorHealth: ConsoleHealthFactWithState[] = [];
if (instance !== undefined) {
const resolvedConnectors = resolveConnectorGrants({
...(instance.handlesJson !== undefined
? { handlesJson: instance.handlesJson }
: {}),
handlesJsonPath: instance.handlesJsonPath,
piecesJson: instance.piecesJson,
piecesJsonPath: instance.piecesJsonPath,
});
for (const grant of resolvedConnectors.grants) connectorGrants.push(grant);
const source = "loom connector receipt + pieces.json";
const detail = `${instance.handlesJsonPath}; ${instance.piecesJsonPath}`;
connectorHealth.push({
id: "connectors.inventory",
group: "connectors",
label: "Connector Inventory",
state: instance.handlesJson === undefined ? "unknown" : "ok",
value: instance.handlesJson === undefined
? "No injection receipt has been recorded"
: `${resolvedConnectors.grants.length} granted; ${resolvedConnectors.unnamed.length} not granted`,
source,
detail,
...(instance.handlesJson === undefined
? {
remedy:
"Reconcile the instance's connectors in Loom, then restart the console.",
}
: {}),
});
for (const grant of resolvedConnectors.grants) {
connectorResolved.push({
name: `grant ${connectorGrantLabel(grant)}`,
value: grant.ref,
source: `\`${instance.handlesJsonPath}\`, declared by ` +
`\`${grant.source.piece}\` in \`${instance.piecesJsonPath}\``,
});
connectorHealth.push({
id: `connector.granted.${grant.name}`,
group: "connectors",
label: connectorGrantName(grant.source),
state: "ok",
value: `granted: ${connectorGrantLabel(grant)}`,
source: `${source} (${grant.source.piece})`,
detail,
});
}
for (const [index, handle] of resolvedConnectors.unnamed.entries()) {
connectorResolved.push({
name: `grant ${connectorGrantName(handle)}`,
value: `(none: ${handle.reason})`,
source: `\`${instance.handlesJsonPath}\``,
});
connectorHealth.push({
id: `connector.refused.${index}`,
group: "connectors",
label: connectorGrantName(handle),
state: handle.state,
value: "not granted",
source: `${source} (${handle.piece})`,
detail,
reason: handle.reason,
remedy: handle.remedy,
});
}
}
if (
options.patternIndexUrl !== undefined && options.noPatternIndex === true
) {
throw new Error(
"`--pattern-index-url` and `--no-pattern-index` contradict each other; " +
"name an index or waive it, not both",
);
}
if (
options.skillsRegistryUrl !== undefined && options.noSkillsRegistry === true
) {
throw new Error(
"`--skills-registry-url` and `--no-skills-registry` contradict each " +
"other; name a registry or waive it, not both",
);
}
// Not derived and not required: these belong to the deployment rather than
// to the fabric, so the constant is the answer and the flag moves it.
const patternIndexUrl = options.noPatternIndex === true
? undefined
: options.patternIndexUrl ?? DEPLOYMENT_PATTERN_INDEX_URL;
const skillsRegistryUrl = options.noSkillsRegistry === true
? undefined
: options.skillsRegistryUrl ?? DEPLOYMENT_SKILLS_REGISTRY_URL;
// The port loom's `/harness-console/*` proxy resolves its target with, in
// its order: the instance's own record, then the console's variable, then
// the port Weaver pairs with. Reading the same inputs the same way is what
// makes recording a port in one place move both sides.
const recordedPort = numericField(defaults, "harness_console_port");
const port = options.port ?? recordedPort ?? options.inheritedPort ??
WEAVER_PAIRING_PORT;
const consoleDir = options.consoleDir ??
(instance === undefined
? `.cf-harness-console-${port}`
: `.cf-harness-console-${instance.id}-${port}`);
const posture = options.posture ?? "max-enforcement";
const flowLabels = options.flowLabels ?? "persist";
const enforcementMode = options.enforcementMode ??
DEFAULT_HARNESS_CFC_ENFORCEMENT_MODE;
const deploymentDefault = "labs deployment default";
// The operator's one decision about skill scripts. Nothing about the fabric
// implies it, so it is off unless someone says otherwise, and the printout
// says which of the two ways they said it.
const allowSkillScripts = options.allowSkillScripts === true ||
options.inheritedAllowSkillScripts === true;
const allowSkillScriptsSource = options.allowSkillScripts === true
? NAMED
: options.inheritedAllowSkillScripts === true
? "`CF_HARNESS_ALLOW_SKILL_SCRIPTS`, inherited"
: LAUNCHER_DEFAULT;
const resolved: ResolvedValue[] = [
...(instance === undefined ? [] : [{
name: "instance",
value: instance.id,
source: NAMED,
}]),
{
name: "port",
value: String(port),
source: options.port !== undefined
? NAMED
: recordedPort !== undefined && instanceSource !== undefined
? instanceSource
: options.inheritedPort !== undefined
? "`CF_HARNESS_CONSOLE_PORT`, inherited"
: `${LAUNCHER_DEFAULT} (the port Weaver pairs with)`,
},
{
name: "console dir",
value: consoleDir,
source: options.consoleDir === undefined
? `${LAUNCHER_DEFAULT} (one directory per fabric and port)`
: NAMED,
},
{ name: "space", value: space.value, source: space.source },
{ name: "identity", value: identity.value, source: identity.source },
{ name: "toolshed", value: toolshedUrl.value, source: toolshedUrl.source },
{
name: "store",
value: storeDirectoryPath(store.value),
source: store.source,
},
...runscResolvedValues(records.sandbox),
{
name: "posture",
value: `${posture}, flow labels ${flowLabels}, ${enforcementMode}`,
source: options.posture === undefined &&
options.flowLabels === undefined &&
options.enforcementMode === undefined
? LAUNCHER_DEFAULT
: NAMED,
},
{
name: "index",
value: patternIndexUrl ?? "(none: --no-pattern-index)",
source:
patternIndexUrl === undefined || options.patternIndexUrl !== undefined
? NAMED
: deploymentDefault,
},
{
name: "skills",
value: skillsRegistryUrl ?? "(none: --no-skills-registry)",
source: skillsRegistryUrl === undefined ||
options.skillsRegistryUrl !== undefined
? NAMED
: deploymentDefault,
},
{
name: "skill scripts",
value: allowSkillScripts ? "run in the sandbox" : "not run",
source: allowSkillScriptsSource,
},
...connectorResolved,
{
name: "proxy",
value: "removed from the environment",
source: "launcher",
},
];
const environment: Record = {
CF_HARNESS_CONSOLE_PORT: String(port),
CF_HARNESS_CONSOLE_DIR: consoleDir,
CF_HARNESS_FABRIC_API_URL: toolshedUrl.value,
CF_HARNESS_FABRIC_IDENTITY: identity.value,
CF_HARNESS_FABRIC_SPACE: space.value,
CF_HARNESS_FABRIC_CFC_POSTURE: posture,
CF_HARNESS_FABRIC_CFC_FLOW_LABELS: flowLabels,
CF_HARNESS_FABRIC_CFC_ENFORCEMENT_MODE: enforcementMode,
MEMORY_DIR: storeDirectoryPath(store.value),
...(connectorGrants.length > 0
? { CF_HARNESS_CONNECTOR_GRANTS: JSON.stringify(connectorGrants) }
: {}),
...(patternIndexUrl !== undefined
? { CF_HARNESS_PATTERN_INDEX_URL: patternIndexUrl }
: {}),
...(skillsRegistryUrl !== undefined
? { CF_HARNESS_SKILLS_REGISTRY_URL: skillsRegistryUrl }
: {}),
...(allowSkillScripts ? { CF_HARNESS_ALLOW_SKILL_SCRIPTS: "1" } : {}),
};
return {
environment,
resolved,
health: { resolved, connectors: connectorHealth },
};
};
/** Helper for the launch report, which labels a value a variable named. */
const inherited = (variable: string): string => `\`${variable}\`, inherited`;
/**
* Helper for the launch report, which returns the source of the sandbox
* runtime: the variable that named it, or the platform default the harness
* applied where none did.
*/
const sandboxSource = (choice: SandboxRuntimeChoice): string =>
choice.source === "default"
? `harness ${sandboxRuntimeChoiceReason(choice)}`
: inherited(SANDBOX_RUNTIME_ENV);
/**
* What a console on the direct runsc driver runs under, each value beside
* what decided it: the variable that named it, the native store a defaulted
* runtime takes it from, or the harness's own default. These pass through to
* the console as inherited rather than being set by the launch, and are
* printed so the report accounts for them.
*/
const runscResolvedValues = (
sandbox: ConsoleLaunchSandbox,
): ResolvedValue[] => {
const { selection } = sandbox;
const choice = selection.sandboxRuntimeChoice;
const native = choice.source === "default" && choice.runtime === "runsc"
? choice
: undefined;
const store = native?.nativeStore;
const fromStore = store === undefined
? "harness default"
: `the native store at \`${store}\``;
return [{
name: "sandbox",
value: "runsc",
source: sandboxSource(choice),
}, {
name: "runsc",
value: selection.sandboxRunscBinary ?? "`runsc`, looked for on `PATH`",
source: sandbox.binaryNamed ? inherited(RUNSC_BINARY_ENV) : fromStore,
}, {
name: "rootfs",
value: selection.sandboxRootfs ?? "(the driver's default)",
source: sandbox.rootfsNamed ? inherited(SANDBOX_ROOTFS_ENV) : fromStore,
}, {
name: "cfc policy",
// The console takes no loop enforcement mode, so its turns run at the
// harness default.
value: selection.sandboxCfcPolicy ??
(runscWithoutPolicyRefusesTurns(DEFAULT_HARNESS_CFC_ENFORCEMENT_MODE)
? `(none: every turn is refused at \`${DEFAULT_HARNESS_CFC_ENFORCEMENT_MODE}\`)`
: "(none: `runsc` runs without `--cfc`)"),
// The policy under `HOME` is the harness's default on either runtime; the
// store's own is one only a defaulted native runtime takes.
source: sandbox.policyNamed
? inherited(RUNSC_CFC_POLICY_ENV)
: native !== undefined &&
selection.sandboxCfcPolicy ===
nativeStoreCfcPolicy(native.platform, native.nativeStore)
? fromStore
: "harness default",
}];
};
/** The lines the launcher prints before the server binds. */
export const consoleLaunchReport = (
plan: ConsoleLaunchPlan,
): readonly string[] => {
const width = plan.resolved.reduce(
(widest, entry) => Math.max(widest, entry.name.length),
0,
);
return [
" cf-harness console, resolved from the fabric it runs against:",
...plan.resolved.map((entry) =>
` ${entry.name.padEnd(width)} ${entry.value} [${entry.source}]`
),
];
};
const loomDataDirectory = (env: Record): string => {
const xdg = nonEmpty(env.XDG_DATA_HOME);
if (xdg !== undefined) {
return join(xdg, "loom");
}
const home = nonEmpty(env.HOME);
if (home === undefined) {
throw new Error(
"neither `XDG_DATA_HOME` nor `HOME` is set, so loom's instance " +
"directory cannot be located",
);
}
return join(home, ".local", "share", "loom");
};
export const readOptionalFile = async (
path: string,
): Promise => {
try {
return await Deno.readTextFile(path);
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return undefined;
}
throw error;
}
};
/**
* Runs `loom toolshed-store-dir `, which never mutates and prints
* the store the instance's toolshed serves. A `loom` that exits nonzero is
* reported with its own diagnostic, since it is the one that knows why.
*/
export const readToolshedStoreDir = async (
loomBinary: string,
instance: string,
): Promise => {
let output: Deno.CommandOutput;
try {
output = await new Deno.Command(loomBinary, {
args: ["toolshed-store-dir", instance],
stdout: "piped",
stderr: "piped",
}).output();
} catch (error) {
throw new Error(
`could not run \`${loomBinary} toolshed-store-dir ${instance}\`: ` +
`${error instanceof Error ? error.message : String(error)}; set ` +
`\`--loom-bin\` to loom's executable`,
);
}
if (!output.success) {
throw new Error(
`\`${loomBinary} toolshed-store-dir ${instance}\` failed: ` +
new TextDecoder().decode(output.stderr).trim(),
);
}
return new TextDecoder().decode(output.stdout).trim();
};
const positiveInteger = (value: string, flag: string): number => {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive integer: ${value}`);
}
return parsed;
};
/**
* The three readings a launch makes of the machine it runs on. Named as one
* interface because they are what separates deciding the console's
* configuration from finding out what the machine says: everything else in
* `prepareConsoleLaunch` is argument and environment, which a caller supplies.
*/
export interface ConsoleLaunchIo {
readTextFile: (path: string) => Promise;
readToolshedStoreDir: (
loomBinary: string,
instance: string,
) => Promise;
}
const REAL_IO: ConsoleLaunchIo = {
readTextFile: readOptionalFile,
readToolshedStoreDir,
};
/** The launcher's flags that carry a value. */
const LAUNCH_STRING_FLAGS = [
"instance",
"loom-bin",
"port",
"console-dir",
"fabric-identity",
"fabric-space",
"fabric-api-url",
"store",
"pattern-index-url",
"skills-registry-url",
"fabric-cfc-posture",
"fabric-cfc-flow-labels",
"fabric-cfc-enforcement-mode",
] as const;
/** The launcher's switches. */
const LAUNCH_BOOLEAN_FLAGS = [
"help",
"no-pattern-index",
"no-skills-registry",
"allow-skill-scripts",
] as const;
/** What `--help` before `--` prints. */
const LAUNCH_USAGE = [
"Usage: deno task --cwd packages/cf-harness console:launch [flags] [-- console flags]",
"",
"Resolves what a console serves from a loom instance's records (`--instance`)",
"or from the flags below, prints each value beside the record that decided",
"it, and serves the console under them. Arguments after `--` go to the",
"console itself, and `console:launch -- --help` lists them.",
"packages/cf-harness/console/README.md describes both.",
"",
...flagUsageLines(LAUNCH_STRING_FLAGS, LAUNCH_BOOLEAN_FLAGS),
].join("\n");
/**
* The usage `args` ask for with `--help` or `-h`: the launcher's own where the
* flag comes before `--`, the console's where it comes after, and otherwise
* `undefined`.
*/
export const consoleLaunchHelpText = (
args: readonly string[],
): string | undefined => {
if (argvHolds(args, HELP_SPELLINGS)) return LAUNCH_USAGE;
const split = args.indexOf("--");
return split === -1 ? undefined : consoleHelpText(args.slice(split + 1));
};
/**
* Who names the sandbox runtime of a console launched with `--instance`. Loom
* chooses a runtime for each of its instances and starts their consoles, so a
* console launched for an instance in an environment that names none is one
* Loom did not choose for. It is refused on every platform, instead of being
* put on a default that need not be the runtime the instance's runs are on.
*/
const INSTANCE_RUNTIME_NAMED_BY = "Loom";
/**
* Reads what the fabric records and resolves the console's environment from
* it, stopping short of serving: the plan, and the arguments after `--` that
* belong to the console rather than to this launcher. `host.platform` is the
* platform whose default sandbox runtime applies where `env` names none, as
* `Deno.build.os` writes it, which it is when absent, and `host.arch` and
* `host.uid` describe the process that default is for. A launch with
* `--instance` takes no such default, and returns in `sandboxRuntimeNamedBy`
* who has to name the runtime, for the console it serves to hold to as well.
*
* @throws HarnessControlError where `env` names `docker`, the Docker driver
* this cf-harness no longer has; where it names no sandbox runtime and the
* launch is for a Loom instance, or runs on a platform with no default, which
* is every platform but macOS and Linux; and where it names none on macOS or
* Linux and the native runtime cannot be provided.
*/
export const prepareConsoleLaunch = async (
args: readonly string[],
env: Record,
io: ConsoleLaunchIo = REAL_IO,
host: SandboxProcess & { platform?: SandboxPlatform } = {},
): Promise<{
plan: ConsoleLaunchPlan;
consoleArgs: string[];
sandboxRuntimeNamedBy?: string;
}> => {
const undeclared: string[] = [];
const parsed = parseArgs([...args], {
string: [...LAUNCH_STRING_FLAGS],
boolean: [...LAUNCH_BOOLEAN_FLAGS],
alias: { h: "help" },
"--": true,
unknown: recordUndeclaredFlags(undeclared),
});
// Before anything is read: the launcher selects the sandbox from the
// environment, as the console does, and a selection flag it ignored would
// launch a console on a sandbox other than the one it was asked for. A
// flag given no value, and one neither the launcher nor, past `--`, the
// console takes, is refused here too, rather than after the launch has
// printed what it resolved.
refuseBatchSandboxFlags(parsed);
refuseFlagsWithoutValue(args, LAUNCH_STRING_FLAGS);
const [first] = undeclared;
if (first?.startsWith("--") && CONSOLE_FLAGS.includes(first.slice(2))) {
throw new HarnessControlError(
"invalid-request",
`\`${first}\` is a flag of the console rather than of ` +
"`console:launch`; pass it after `--`.",
);
}
refuseUndeclaredFlags(
undeclared,
[...LAUNCH_STRING_FLAGS, ...LAUNCH_BOOLEAN_FLAGS],
"`console:launch`",
);
parseConsoleArgs((parsed["--"] ?? []).map(String));
// A flag present but empty is a value someone typed that did not survive
// parsing — `--port -1` leaves `port` empty, because `-1` reads as a flag of
// its own — so it is refused rather than falling through to the default the
// person was overriding.
const flag = (name: string): string | undefined => {
const value = parsed[name];
if (typeof value !== "string") {
return undefined;
}
const trimmed = nonEmpty(value);
if (trimmed === undefined) {
throw new Error(
`\`--${name}\` was given no value; a value starting with \`-\` needs ` +
`the \`--${name}=\` spelling`,
);
}
return trimmed;
};
// `--instance` alone opts into reading a loom instance's records. An
// inherited `LOOM_INSTANCE_ID` does not: the console comes along because
// someone asked for it, and the ambient variable is a fact about the process
// tree rather than a decision.
const instanceId = flag("instance");
const loomBinary = flag("loom-bin") ?? nonEmpty(env.LOOM_BIN) ?? "loom";
let instance: LoomInstanceRecords | undefined;
if (instanceId !== undefined) {
const piecesJsonPath = join(
loomDataDirectory(env),
"instances",
instanceId,
"pieces.json",
);
const piecesJson = await io.readTextFile(piecesJsonPath);
if (piecesJson === undefined) {
throw new Error(
`loom instance \`${instanceId}\` has no \`${piecesJsonPath}\`; name ` +
`a running instance with \`--instance\``,
);
}
const handlesJsonPath = join(
loomDataDirectory(env),
"instances",
instanceId,
"sqlite-injection",
"handles.json",
);
const handlesJson = await io.readTextFile(handlesJsonPath);
instance = {
id: instanceId,
piecesJson,
piecesJsonPath,
toolshedStoreDir: await io.readToolshedStoreDir(loomBinary, instanceId),
...(handlesJson !== undefined ? { handlesJson } : {}),
handlesJsonPath,
};
}
// Two vocabularies name these, and both are already exported on the machines
// this runs on: the console's own, which its flags and README use, and the
// `cf` CLI's, which a person who has ever run `cf` has set. Reading both is
// what stops a value that is plainly present from reading as absent; the
// console's own name wins, being the one that names this surface.
const inheritedIdentity = nonEmpty(env.CF_HARNESS_FABRIC_IDENTITY) ??
nonEmpty(env.CF_IDENTITY);
const inheritedSpace = nonEmpty(env.CF_HARNESS_FABRIC_SPACE) ??
nonEmpty(env.CF_SPACE);
// Read the way the server reads it, from the same environment, so the
// launch and the console describe one sandbox.
const sandboxRuntimeNamedBy = instance === undefined
? undefined
: INSTANCE_RUNTIME_NAMED_BY;
const selection = await resolveSandboxRuntimeSelection(env, {}, {
...(sandboxRuntimeNamedBy !== undefined
? { namedBy: sandboxRuntimeNamedBy }
: { platform: host.platform ?? Deno.build.os }),
...sandboxProcessOf(host),
flags: false,
cwd: Deno.cwd(),
});
const plan = resolveConsoleLaunchPlan({
...(instance !== undefined ? { instance } : {}),
sandbox: {
selection,
binaryNamed: nonEmpty(env[RUNSC_BINARY_ENV]) !== undefined,
rootfsNamed: nonEmpty(env[SANDBOX_ROOTFS_ENV]) !== undefined,
policyNamed: nonEmpty(env[RUNSC_CFC_POLICY_ENV]) !== undefined,
},
}, {
...(flag("fabric-identity") !== undefined
? { identity: flag("fabric-identity")! }
: {}),
...(inheritedIdentity !== undefined ? { inheritedIdentity } : {}),
...(flag("fabric-space") !== undefined
? { space: flag("fabric-space")! }
: {}),
...(inheritedSpace !== undefined ? { inheritedSpace } : {}),
...(flag("fabric-api-url") !== undefined
? { toolshedUrl: flag("fabric-api-url")! }
: {}),
...(nonEmpty(env.CF_HARNESS_FABRIC_API_URL) !== undefined
? { inheritedToolshedUrl: nonEmpty(env.CF_HARNESS_FABRIC_API_URL)! }
: {}),
...(flag("store") !== undefined ? { store: flag("store")! } : {}),
...(nonEmpty(env.MEMORY_DIR) !== undefined
? { inheritedStore: nonEmpty(env.MEMORY_DIR)! }
: {}),
...(flag("port") !== undefined
? { port: positiveInteger(flag("port")!, "--port") }
: {}),
...(nonEmpty(env.CF_HARNESS_CONSOLE_PORT) !== undefined
? {
inheritedPort: positiveInteger(
nonEmpty(env.CF_HARNESS_CONSOLE_PORT)!,
"CF_HARNESS_CONSOLE_PORT",
),
}
: {}),
...(flag("console-dir") !== undefined
? { consoleDir: flag("console-dir")! }
: {}),
...(flag("pattern-index-url") !== undefined
? { patternIndexUrl: flag("pattern-index-url")! }
: {}),
...(flag("skills-registry-url") !== undefined
? { skillsRegistryUrl: flag("skills-registry-url")! }
: {}),
noPatternIndex: parsed["no-pattern-index"] === true,
noSkillsRegistry: parsed["no-skills-registry"] === true,
allowSkillScripts: parsed["allow-skill-scripts"] === true,
inheritedAllowSkillScripts:
nonEmpty(env.CF_HARNESS_ALLOW_SKILL_SCRIPTS) === "1",
...(flag("fabric-cfc-posture") !== undefined
? { posture: flag("fabric-cfc-posture")! }
: {}),
...(flag("fabric-cfc-flow-labels") !== undefined
? { flowLabels: flag("fabric-cfc-flow-labels")! }
: {}),
...(flag("fabric-cfc-enforcement-mode") !== undefined
? { enforcementMode: flag("fabric-cfc-enforcement-mode")! }
: {}),
});
// The launch prints what it resolved, and this is one of the values it
// resolves, so a console argument setting it again would leave that report
// describing a console that does something else. Every other server flag
// still passes through.
const consoleArgs = (parsed["--"] ?? []).map(String);
// Both spellings: a boolean flag still parses `--flag=true` and `--flag=1`,
// so an exact-token check leaves the enabling form through and the printed
// report then describes a console that does something else.
const passedThrough = consoleArgs.find((argument) =>
argument === "--allow-skill-scripts" ||
argument.startsWith("--allow-skill-scripts=")
);
if (passedThrough !== undefined) {
throw new Error(
`\`${passedThrough}\` cannot be passed through to the console: ` +
`\`--allow-skill-scripts\` is one of the values this launch ` +
`resolves and prints, so name it before \`--\` instead`,
);
}
return {
plan,
consoleArgs,
...(sandboxRuntimeNamedBy !== undefined ? { sandboxRuntimeNamedBy } : {}),
};
};
/**
* Reads what the fabric records, prints the account of what it resolved to,
* and serves under it. Where `args` ask for help it prints the usage instead,
* and reads and serves nothing. `host.platform` is the platform whose default
* sandbox runtime applies to the launch and to the console it serves, as
* `Deno.build.os` writes it, which it is when absent, with the process that
* default is for in `host.arch` and `host.uid`. `serve` is handed what the
* console is to be told of where it runs: that platform and process, and,
* for a launch with `--instance`, who has to name the runtime.
*/
export const launchConsole = async (
args: readonly string[] = Deno.args,
env: Record = Deno.env.toObject(),
serve: (
consoleArgs: string[],
health: ConsoleObservedLaunchHealth,
consoleHost: ConsoleHost,
) => Promise = (consoleArgs, health, consoleHost) =>
startConsoleServer(consoleArgs, undefined, undefined, health, consoleHost),
io: ConsoleLaunchIo = REAL_IO,
host: SandboxProcess & { platform?: SandboxPlatform } = {},
): Promise => {
// A flag with no value first, on either side of `--`: the `-h` it leaves
// behind is not a question.
refuseFlagsWithoutValue(args, LAUNCH_STRING_FLAGS);
const split = args.indexOf("--");
if (split !== -1) {
refuseFlagsWithoutValue(args.slice(split + 1), CONSOLE_STRING_FLAGS);
}
const help = consoleLaunchHelpText(args);
if (help !== undefined) {
console.log(help);
return;
}
const { plan, consoleArgs, sandboxRuntimeNamedBy } =
await prepareConsoleLaunch(args, env, io, host);
const health = { ...plan.health, checkedAt: new Date().toISOString() };
console.log("");
for (const line of consoleLaunchReport(plan)) {
console.log(line);
}
for (const variable of [...PROXY_VARIABLES, ...LAUNCHER_OWNED_VARIABLES]) {
Deno.env.delete(variable);
}
for (const [name, value] of Object.entries(plan.environment)) {
Deno.env.set(name, value);
}
// These are the decisions that produced the running console's grants. A
// status request reports this observation even if the files later change.
await serve(consoleArgs, health, {
...host,
...(sandboxRuntimeNamedBy !== undefined ? { sandboxRuntimeNamedBy } : {}),
});
};
/**
* What a launch that could not start says. A misconfigured launch is an
* operator's problem to fix and the message is the whole of what they need,
* so the stack behind it — which names this file rather than their mistake —
* is dropped.
*/
export const launchFailureMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
// Running the file serves; importing it (the tests do) serves nothing.
if (import.meta.main) {
try {
await launchConsole();
} catch (error) {
console.error(launchFailureMessage(error));
Deno.exit(1);
}
}