Files
Hy-Memory/memhub/packages/runtime-control/src/runtime-cli.ts
T
feitianxiang 3511153f9f Merge pull request #274 from Tencent-Hunyuan/fix/deepseek-harness-profiles
fix(memhub): DeepSeek Harness one switch grants every profile; import versioned sessions and v3 tool text
2026-11-02 14:17:44 +08:00

1259 lines
42 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { access, lstat, readlink, realpath } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { RecallBroker } from "@one-memory/host-bridge";
import {
type BackgroundHealth,
inspectBackgroundHealth,
} from "./background-health.js";
import { createConnectionControlService } from "./connection-control-service.js";
import { CoreSupervisor } from "./core-supervisor.js";
import { DiagnosticLogger } from "./diagnostic-log.js";
import { HostPermissions } from "./host-permissions.js";
import { verifyInstalledDistribution } from "./local-install.js";
import { RecallBrokerSupervisor } from "./recall-broker-supervisor.js";
import { recallHosts } from "./recall-hosts.js";
import {
type RuntimeBuildIdentity,
RuntimeService,
} from "./runtime-service.js";
import { RuntimeSupervisor } from "./runtime-supervisor.js";
import {
runSdkMigration,
type SdkMigrationOptions,
type SdkMigrationResult,
} from "./sdk-migration.js";
import {
readSdkMigrationBlock,
writeSdkMigrationLog,
} from "./sdk-migration-log.js";
import { createServiceLogCapture } from "./service-log.js";
import {
appOwnedRuntimeShouldStop,
disableRuntimeService,
readRuntimeServiceOwner,
} from "./service-ownership.js";
import {
defaultManagedInstallRoot,
hourlyIsolationFromEnvironment,
hourlyManagedDataDirFromEnvironment,
planUserService,
type RuntimePlatform,
runtimeInstallLayout,
} from "./service-plan.js";
import { createSourceChangeHandler } from "./source-change-handler.js";
declare const __ONE_MEMORY_VERSION__: string;
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
declare const __ONE_MEMORY_COMMIT__: string;
export interface RuntimeCliConfig {
dataDir: string;
runtimeEntry: string;
coreCommand: string;
coreArgs: string[];
webDirectory: string;
mcpEntryPath: string;
recallCliPath: string;
syncHookAssetsDirectory: string;
nodeCommand: string;
hostHome?: string;
brokerPort?: number;
/** Package/build identity when the runtime is running from a built package.
* Source checkouts leave it undefined rather than inventing an id. */
buildIdentity?: {
version?: string;
buildId?: string;
packageId?: string;
round?: string;
buildNumber?: string;
commit?: string;
};
hostRuntime?: HostRuntimeAttestation;
hostRuntimeError?: HostRuntimeErrorCode;
}
type HostRuntimeErrorCode =
| "host_runtime_current_unavailable"
| "host_runtime_app_location_unsafe"
| "host_runtime_current_unsafe"
| "host_runtime_current_changed"
| "host_runtime_identity_mismatch"
| "host_runtime_component_mismatch";
interface HostRuntimeAttestation {
schemaVersion: 1;
managedInstallRoot: string;
selectedVersionRoot: string;
selectedRelativeTarget: string;
appMarker: string;
manifestPackageId: string;
buildNumber: number;
platform: RuntimePlatform;
arch: string;
manifestSha256: string;
componentDigests: Record<string, string>;
runtimeCommand: string;
mcpEntryPath: string;
recallCliPath: string;
captureCommand: string;
cliEntryPath: string;
webDirectory: string;
syncHookAssetsDirectory: string;
}
/** Default Core port. Unchanged for the everyday App path. */
export const DEFAULT_CORE_PORT = 8765;
/** Same shape the Desktop entry uses to opt into an isolated hour. */
const HOURLY_INSTANCE_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** True only for a genuine isolated-hour package launch.
*
* Mirrors `apps/desktop/src/isolated-runtime.ts::isIsolatedHourlyDesktop`: a
* v4 UUID instance id AND a package id shaped `One-Memory-H*-c7-isolated`. A
* package that merely looks similar (no marker, or a wrong suffix) is NOT
* isolated, so it cannot use the port override.
*/
export function isIsolatedHourlyRuntime(
environment: NodeJS.ProcessEnv,
): boolean {
const instanceId = environment.ONE_MEMORY_HOURLY_INSTANCE_ID;
const packageId = environment.ONE_MEMORY_HOURLY_PACKAGE_ID;
return (
typeof instanceId === "string" &&
HOURLY_INSTANCE_RE.test(instanceId) &&
typeof packageId === "string" &&
packageId.startsWith("One-Memory-H") &&
packageId.endsWith("-c7-isolated")
);
}
/** Decide the Core port for a launch.
*
* The everyday App path stays pinned at `DEFAULT_CORE_PORT`. A non-default
* (custom or isolated) install root may override it with
* `ONE_MEMORY_CORE_PORT`, but only when the value is a usable port; anything
* else falls back to the default and reports why.
*
* The gate is "this launch is not the everyday install", not "this launch is an
* isolated hour": the failure this pins is two ordinary custom install roots
* sharing 8765, and those carry no hourly markers. The daily install never
* reaches this function with an override set, because `runtimeEnvironment()`
* does not put `ONE_MEMORY_CORE_PORT` in the daily plist at all.
*/
export function resolveCorePort(environment: NodeJS.ProcessEnv): {
port: number;
overrideApplied: boolean;
ignoredReason: string | null;
} {
// `ONE_MEMORY_RUNTIME_PORT` is the standalone probe/spawn contract: a CLI
// harness that starts the Runtime itself must be able to name a non-canonical
// port without depending on the LaunchAgent's `ONE_MEMORY_CORE_PORT`. The
// plist name wins when both are set, because it is what the installed
// service actually launches with.
const raw =
environment.ONE_MEMORY_CORE_PORT ?? environment.ONE_MEMORY_RUNTIME_PORT;
if (raw === undefined || raw === "") {
return {
port: DEFAULT_CORE_PORT,
overrideApplied: false,
ignoredReason: null,
};
}
// The override is only ever written into a non-default install's plist, so a
// marker is not required here. Still refuse to honor it when the launch
// explicitly says it is the daily root, so a stray variable cannot move the
// everyday port.
if (isDailyRuntimeRoot(environment)) {
return {
port: DEFAULT_CORE_PORT,
overrideApplied: false,
ignoredReason: "core_port_override_ignored_daily_root",
};
}
const requested = Number(raw);
if (!Number.isInteger(requested) || requested < 1024 || requested > 65535) {
return {
port: DEFAULT_CORE_PORT,
overrideApplied: false,
ignoredReason: "core_port_override_invalid",
};
}
return { port: requested, overrideApplied: true, ignoredReason: null };
}
/** True when the launch names the everyday install root as its own root.
*
* The Runtime knows which payload it is running from (`ONE_MEMORY_INSTALL_ROOT`,
* set by the launcher for a built package). When that equals the platform's
* default managed root, this is the daily install and must keep 8765. Absent
* that variable we cannot prove "daily", and the override is still only present
* because a non-default plist put it there, so the launch is allowed through.
*/
function isDailyRuntimeRoot(environment: NodeJS.ProcessEnv): boolean {
const declared = environment.ONE_MEMORY_INSTALL_ROOT;
if (typeof declared !== "string" || declared === "") return false;
const home = environment.ONE_MEMORY_HOST_HOME ?? environment.HOME;
if (typeof home !== "string" || home === "") return false;
const platform: RuntimePlatform =
process.platform === "darwin"
? "darwin"
: process.platform === "win32"
? "win32"
: "linux";
const paths = platform === "win32" ? path.win32 : path.posix;
const daily = paths.resolve(
defaultManagedInstallRoot(platform, home, environment.LOCALAPPDATA),
);
const declaredRoot = paths.resolve(declared);
// Compare with and without a trailing `current`, since callers name either.
const normalized =
paths.basename(declaredRoot) === "current"
? paths.dirname(declaredRoot)
: declaredRoot;
return normalized === daily;
}
const HOST_RUNTIME_KEYS = [
"schemaVersion",
"managedInstallRoot",
"selectedVersionRoot",
"selectedRelativeTarget",
"appMarker",
"manifestPackageId",
"buildNumber",
"platform",
"arch",
"manifestSha256",
"componentDigests",
"runtimeCommand",
"mcpEntryPath",
"recallCliPath",
"captureCommand",
"cliEntryPath",
"webDirectory",
"syncHookAssetsDirectory",
] as const;
const HOST_RUNTIME_ERRORS = new Set<HostRuntimeErrorCode>([
"host_runtime_current_unavailable",
"host_runtime_app_location_unsafe",
"host_runtime_current_unsafe",
"host_runtime_current_changed",
"host_runtime_identity_mismatch",
"host_runtime_component_mismatch",
]);
function parseHostRuntime(environment: NodeJS.ProcessEnv): {
hostRuntime?: HostRuntimeAttestation;
hostRuntimeError?: HostRuntimeErrorCode;
} {
const blocked = environment.ONE_MEMORY_HOST_RUNTIME_ERROR;
const raw = environment.ONE_MEMORY_HOST_RUNTIME_ATTESTATION;
if (blocked !== undefined) {
if (
raw !== undefined ||
!HOST_RUNTIME_ERRORS.has(blocked as HostRuntimeErrorCode)
)
throw new Error("host_runtime_attestation_invalid");
return { hostRuntimeError: blocked as HostRuntimeErrorCode };
}
if (raw === undefined) return {};
if (Buffer.byteLength(raw) > 32768)
throw new Error("host_runtime_attestation_invalid");
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("host_runtime_attestation_invalid");
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("host_runtime_attestation_invalid");
const record = value as Record<string, unknown>;
if (
JSON.stringify(Object.keys(record)) !== JSON.stringify(HOST_RUNTIME_KEYS) ||
JSON.stringify(value) !== raw
)
throw new Error("host_runtime_attestation_invalid");
const strings = HOST_RUNTIME_KEYS.filter(
(key) =>
!["schemaVersion", "buildNumber", "componentDigests"].includes(key),
);
if (
record.schemaVersion !== 1 ||
!Number.isSafeInteger(record.buildNumber) ||
strings.some(
(key) =>
typeof record[key] !== "string" ||
(record[key] as string).length > 4096,
)
)
throw new Error("host_runtime_attestation_invalid");
if (
!/^versions\/[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/.test(
record.selectedRelativeTarget as string,
) ||
!["darwin", "linux"].includes(record.platform as string) ||
!/^[a-f0-9]{64}$/.test(record.manifestSha256 as string) ||
!/^One-Memory-HAPP1R[0-9]+(?:\.[0-9]+)*-[a-f0-9]{8}-c7-isolated$/.test(
record.appMarker as string,
) ||
!/^One-Memory-APP1R[0-9]+(?:\.[0-9]+)*-[a-f0-9]{8}-candidate$/.test(
record.manifestPackageId as string,
) ||
[
"managedInstallRoot",
"selectedVersionRoot",
"runtimeCommand",
"mcpEntryPath",
"recallCliPath",
"captureCommand",
"cliEntryPath",
"webDirectory",
"syncHookAssetsDirectory",
].some((key) => !path.isAbsolute(record[key] as string))
)
throw new Error("host_runtime_attestation_invalid");
if (
path.normalize(record.managedInstallRoot as string) !==
record.managedInstallRoot ||
/[\0\r\n]/.test(record.managedInstallRoot as string)
)
throw new Error("host_runtime_attestation_invalid");
if (
!record.componentDigests ||
typeof record.componentDigests !== "object" ||
Array.isArray(record.componentDigests) ||
Object.keys(record.componentDigests as object).length > 256 ||
Object.entries(record.componentDigests as Record<string, unknown>).some(
([key, digest]) =>
!/^[A-Za-z0-9_./-]{1,512}$/.test(key) ||
typeof digest !== "string" ||
!/^[a-f0-9]{64}$/.test(digest),
)
)
throw new Error("host_runtime_attestation_invalid");
const digests = record.componentDigests as Record<string, string>;
for (const required of [
"core/one-memory",
"runtime/runtime.cjs",
"host-bridge/mcp.cjs",
"host-bridge/recall-cli.cjs",
"bin/one-memory.cjs",
"web/index.html",
"host-bridge/sync-hook/runtime.mjs",
]) {
if (!(required in digests))
throw new Error("host_runtime_attestation_invalid");
}
if (
Object.keys(digests).some(
(key) =>
![
"core/one-memory",
"runtime/runtime.cjs",
"host-bridge/mcp.cjs",
"host-bridge/recall-cli.cjs",
"bin/one-memory.cjs",
].includes(key) &&
!key.startsWith("web/") &&
!key.startsWith("host-bridge/sync-hook/"),
)
)
throw new Error("host_runtime_attestation_invalid");
return { hostRuntime: value as HostRuntimeAttestation };
}
export function resolveRuntimeCliConfig(
environment: NodeJS.ProcessEnv = process.env,
executable = process.argv[1] ??
path.join(process.cwd(), "runtime", "runtime.cjs"),
): RuntimeCliConfig {
const runtimeRoot = path.resolve(path.dirname(executable), "..");
const coreExecutable =
process.platform === "win32" ? "one-memory.exe" : "one-memory";
// An hourly-package process carries its markers, not the plist's
// ONE_MEMORY_DATA_DIR; without this it would silently manage the daily root.
const dataDir =
hourlyManagedDataDirFromEnvironment(environment, os.homedir()) ??
path.join(os.homedir(), ".one-memory");
if (!path.isAbsolute(dataDir) || /[\0\r\n]/.test(dataDir)) {
throw new Error("runtime_service_data_directory_invalid");
}
const hostHome = environment.ONE_MEMORY_HOST_HOME ?? os.homedir();
if (!path.isAbsolute(hostHome) || /[\0\r\n]/.test(hostHome)) {
throw new Error("runtime_service_host_home_invalid");
}
const hostRuntime = parseHostRuntime(environment);
const requestedBrokerPort = Number(environment.ONE_MEMORY_BROKER_PORT);
const brokerPort =
Number.isSafeInteger(requestedBrokerPort) &&
requestedBrokerPort >= 1024 &&
requestedBrokerPort <= 65535 &&
requestedBrokerPort !== 8765 &&
requestedBrokerPort !== 18765
? requestedBrokerPort
: undefined;
return {
dataDir: path.normalize(dataDir),
runtimeEntry: path.resolve(
environment.ONE_MEMORY_RUNTIME_ENTRY?.trim() ||
path.join(runtimeRoot, "runtime", "runtime.cjs"),
),
coreCommand: path.resolve(
environment.ONE_MEMORY_CORE_COMMAND?.trim() ||
path.join(runtimeRoot, "core", coreExecutable),
),
coreArgs: ["serve"],
webDirectory: path.resolve(
environment.ONE_MEMORY_WEB_DIR?.trim() || path.join(runtimeRoot, "web"),
),
mcpEntryPath: path.resolve(
environment.ONE_MEMORY_MCP_ENTRY?.trim() ||
path.join(runtimeRoot, "host-bridge", "mcp.cjs"),
),
recallCliPath: path.resolve(
environment.ONE_MEMORY_RECALL_CLI?.trim() ||
path.join(runtimeRoot, "host-bridge", "recall-cli.cjs"),
),
syncHookAssetsDirectory: path.resolve(
environment.ONE_MEMORY_SYNC_HOOK_ASSETS?.trim() ||
path.join(runtimeRoot, "host-bridge", "sync-hook"),
),
nodeCommand: path.resolve(
environment.ONE_MEMORY_NODE_COMMAND?.trim() || process.execPath,
),
hostHome: path.normalize(hostHome),
...(brokerPort === undefined ? {} : { brokerPort }),
buildIdentity: resolveBuildIdentity(environment),
...hostRuntime,
};
}
/** Package identity for the diagnostic chain. The launcher sets these for a
* built package; a source checkout leaves them unset, and we do not invent an
* id, so the log never claims an identity the run does not have. */
function resolveBuildIdentity(
environment: NodeJS.ProcessEnv,
): RuntimeCliConfig["buildIdentity"] {
const embeddedVersion =
typeof __ONE_MEMORY_VERSION__ === "string"
? __ONE_MEMORY_VERSION__.trim()
: "";
const embeddedBuildNumber =
typeof __ONE_MEMORY_BUILD_NUMBER__ === "string"
? __ONE_MEMORY_BUILD_NUMBER__.trim()
: "";
const embeddedCommit =
typeof __ONE_MEMORY_COMMIT__ === "string"
? __ONE_MEMORY_COMMIT__.trim()
: "";
const version =
environment.ONE_MEMORY_VERSION?.trim() ||
(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
embeddedVersion,
)
? embeddedVersion
: "");
const buildId = environment.ONE_MEMORY_BUILD_ID?.trim();
const packageId = environment.ONE_MEMORY_PACKAGE_ID?.trim();
const round = environment.ONE_MEMORY_ROUND?.trim();
const buildNumber =
environment.ONE_MEMORY_BUILD_NUMBER?.trim() ||
(/^\d+$/.test(embeddedBuildNumber) ? embeddedBuildNumber : "");
const commit =
environment.ONE_MEMORY_COMMIT?.trim() ||
(embeddedCommit && embeddedCommit !== "unknown" ? embeddedCommit : "");
if (!version && !buildId && !packageId && !round && !buildNumber && !commit)
return undefined;
return {
...(version ? { version } : {}),
...(buildId ? { buildId } : {}),
...(packageId ? { packageId } : {}),
...(round ? { round } : {}),
...(buildNumber ? { buildNumber } : {}),
...(commit ? { commit } : {}),
};
}
export async function revalidateHostRuntime(
config: RuntimeCliConfig,
): Promise<void> {
if (config.hostRuntimeError) throw new Error(config.hostRuntimeError);
const a = config.hostRuntime;
if (!a) return;
const root = a.managedInstallRoot;
const versions = path.join(root, "versions");
const current = path.join(root, "current");
for (const directory of [root, versions, a.selectedVersionRoot]) {
const info = await lstat(directory).catch(() => null);
if (
!info?.isDirectory() ||
info.isSymbolicLink() ||
(info.mode & 0o022) !== 0 ||
(process.getuid && info.uid !== process.getuid())
)
throw new Error("host_runtime_current_unsafe");
}
if (
(await realpath(root).catch(() => null)) !== root ||
(await realpath(versions).catch(() => null)) !== versions
)
throw new Error("host_runtime_current_unsafe");
if (path.dirname(a.selectedVersionRoot) !== (await realpath(versions)))
throw new Error("host_runtime_current_unsafe");
const link = await readlink(current).catch(() => null);
if (
link !== a.selectedRelativeTarget ||
path.isAbsolute(a.selectedRelativeTarget)
)
throw new Error("host_runtime_current_changed");
if ((await realpath(current).catch(() => null)) !== a.selectedVersionRoot)
throw new Error("host_runtime_current_changed");
const manifest = (await verifyInstalledDistribution(
a.selectedVersionRoot,
a.platform,
a.arch,
)) as {
build_number?: number;
package_id?: unknown;
files: Record<string, string>;
};
if (
manifest.build_number !== a.buildNumber ||
manifest.package_id !== a.manifestPackageId
)
throw new Error("host_runtime_identity_mismatch");
const family = a.appMarker.match(
/^One-Memory-H(APP1R[0-9]+(?:\.[0-9]+)*-[0-9a-f]{8})-c7-isolated$/,
)?.[1];
if (!family || a.manifestPackageId !== `One-Memory-${family}-candidate`)
throw new Error("host_runtime_identity_mismatch");
const expectedPaths = {
runtimeCommand: path.join(a.selectedVersionRoot, "bin", "node"),
mcpEntryPath: path.join(a.selectedVersionRoot, "host-bridge", "mcp.cjs"),
recallCliPath: path.join(
a.selectedVersionRoot,
"host-bridge",
"recall-cli.cjs",
),
captureCommand: path.join(a.selectedVersionRoot, "core", "one-memory"),
cliEntryPath: path.join(a.selectedVersionRoot, "bin", "one-memory.cjs"),
webDirectory: path.join(a.selectedVersionRoot, "web"),
syncHookAssetsDirectory: path.join(
a.selectedVersionRoot,
"host-bridge",
"sync-hook",
),
};
if (
Object.entries(expectedPaths).some(
([key, expected]) => a[key as keyof typeof expectedPaths] !== expected,
)
)
throw new Error("host_runtime_attestation_invalid");
if (
(await fileHash(path.join(a.selectedVersionRoot, "manifest.json"))) !==
a.manifestSha256
)
throw new Error("host_runtime_current_changed");
for (const [relative, digest] of Object.entries(a.componentDigests)) {
if (
manifest.files[relative] !== digest ||
(await fileHash(path.join(a.selectedVersionRoot, relative))) !== digest
)
throw new Error("host_runtime_component_mismatch");
}
const node = path.join(a.selectedVersionRoot, "bin", "node");
const nodeInfo = await lstat(node).catch(() => null);
if (
!nodeInfo?.isFile() ||
nodeInfo.isSymbolicLink() ||
(nodeInfo.mode & 0o111) === 0
)
throw new Error("host_runtime_component_mismatch");
await access(node, 1);
}
async function fileHash(file: string): Promise<string> {
const digest = createHash("sha256");
for await (const chunk of createReadStream(file)) digest.update(chunk);
return digest.digest("hex");
}
export function guardedPermissions(
owner: HostPermissions,
verify: () => Promise<void>,
attestationDigest: string,
): HostPermissions {
const guarded = new Set([
"repairInstalledCommand",
"repairInstalledCommands",
"previewInstall",
"install",
"setPermission",
"revokePermission",
"previewSkillSync",
"applySkillSync",
"previewSyncHook",
"applySyncHook",
"syncStaleInstalls",
"refreshInstalledHosts",
]);
return new Proxy(owner, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (
typeof property !== "string" ||
!guarded.has(property) ||
typeof value !== "function"
)
return typeof value === "function" ? value.bind(target) : value;
return async (...args: unknown[]) => {
await verify();
if (property === "previewInstall") {
const preview = await value.apply(target, args);
return {
...preview,
fingerprint: boundFingerprint(
preview.fingerprint,
attestationDigest,
),
};
}
if (property === "install") {
const [layout, approved] = args;
const preview = await target.previewInstall(layout as never);
if (
typeof approved !== "string" ||
boundFingerprint(preview.fingerprint, attestationDigest) !==
approved
)
throw new Error("host_install_preview_stale");
await verify();
return target.install(layout as never, preview.fingerprint);
}
return value.apply(target, args);
};
},
});
}
function boundFingerprint(
fingerprint: string,
attestationDigest: string,
): string {
return createHash("sha256")
.update("host-runtime-v1\0")
.update(attestationDigest)
.update("\0")
.update(fingerprint)
.digest("hex");
}
/** The build this Runtime publishes in its receipt. Unknown stays `null`, never
* a guess: an App treats an unknown build as "do not touch". */
export function runtimeBuildIdentity(
identity: RuntimeCliConfig["buildIdentity"],
): RuntimeBuildIdentity {
const build = Number(identity?.buildNumber);
const commit = identity?.commit?.trim().toLowerCase() ?? "";
return {
buildNumber: Number.isSafeInteger(build) && build > 0 ? build : null,
commit: /^[0-9a-f]{7,40}$/.test(commit) ? commit : null,
};
}
export async function createRuntimeService(
config: RuntimeCliConfig,
): Promise<RuntimeService> {
const diagnostics = new DiagnosticLogger({
dataDir: config.dataDir,
service: "runtime",
sessionId: process.env.ONE_MEMORY_RUNTIME_SESSION_ID,
});
const hostDiagnostics = new DiagnosticLogger({
dataDir: config.dataDir,
service: "host-bridge",
});
return new RuntimeService({
dataDir: config.dataDir,
diagnostics,
buildIdentity: runtimeBuildIdentity(config.buildIdentity),
async createSupervisor(onSidecarFault) {
const { port, overrideApplied, ignoredReason } = resolveCorePort(
process.env,
);
// Only `mode` and `reason` are in the diagnostic allowlist; the concrete
// port lives in the Core receipt (`runtime-service.json.core_port`), which
// is the authority the renderer/matrix read anyway.
await diagnostics.log({
level: "info",
component: "runtime.service",
event: "core.port_resolved",
outcome: "started",
details: {
mode: overrideApplied ? "isolated_override" : "default",
reason: ignoredReason,
},
});
const capture = await createServiceLogCapture(config.dataDir);
const core = new CoreSupervisor({
launch: { command: config.coreCommand, args: config.coreArgs },
dataDir: config.dataDir,
port,
webDirectory: config.webDirectory,
diagnostics,
buildIdentity: config.buildIdentity,
parentSessionId: diagnostics.sessionId,
onOutput:
capture === null
? undefined
: (chunk, stream) => {
void (stream === "stdout"
? capture.writeStdout(chunk)
: capture.writeStderr(chunk));
},
});
const recallRuntime = {
runtimeCommand: config.nodeCommand,
mcpEntryPath: config.mcpEntryPath,
discoveryPath: path.join(
config.dataDir,
"integrations",
"cursor-broker.json",
),
recallCliPath: config.recallCliPath,
captureCommand:
config.hostRuntime?.captureCommand ?? config.coreCommand,
hermesProviderSource: path.join(
path.dirname(config.mcpEntryPath),
"hermes-provider",
"hy_memory",
),
dataDir: config.dataDir,
};
const permissionOwner = await HostPermissions.open(
config.dataDir,
recallRuntime,
{ syncHookAssetsDirectory: config.syncHookAssetsDirectory },
);
const verifyHostRuntime = () => revalidateHostRuntime(config);
const permissions = guardedPermissions(
permissionOwner,
verifyHostRuntime,
config.hostRuntime?.manifestSha256 ?? "daily",
);
const scanHosts = async () =>
(
await recallHosts(config.hostHome ?? os.homedir(), {
...process.env,
ONE_MEMORY_HOST_HOME: config.hostHome,
})
).filter((host) => host.hostId !== "openclaw");
const hosts = await scanHosts();
await verifyHostRuntime().then(
async () => {
// Every Runtime start after an upgrade, the App's or `memhub
// upgrade`'s, comes through here first; once per build after that.
await permissions.refreshInstalledHosts(
hosts.map((host) => host.layout),
{
build:
config.buildIdentity?.buildNumber ??
config.buildIdentity?.version,
diagnostics: hostDiagnostics,
},
);
},
() => undefined,
);
// `RuntimeService` owns the durable receipt and hands down
// `onSidecarFault`, so a captured Broker fault is published (as
// `sidecar: "failed"`) for a separate `status` process to read.
return new RuntimeSupervisor({
core,
diagnostics,
onSidecarFault: () => onSidecarFault(),
createSidecar(connection, reportFault) {
const control = createConnectionControlService({
dataDir: config.dataDir,
connection,
permissions,
hosts,
// The Runtime outlives the App: a Harness profile created after it
// started must not make every later host read fail.
discoverHosts: scanHosts,
layouts: new Map(
hosts.flatMap((host) =>
host.hostId === "claude-code"
? ([
[host.hostId, host.layout],
["claude", host.layout],
] as const)
: ([[host.hostId, host.layout]] as const),
),
),
});
const broker = new RecallBrokerSupervisor({
discoveryPath: recallRuntime.discoveryPath,
instanceId: connection.instanceId,
diagnostics: hostDiagnostics,
onFault: (code) => reportFault(code),
createBroker: () =>
new RecallBroker({
coreBaseUrl: connection.coreBaseUrl,
coreApiToken: connection.coreApiToken,
discoveryPath: recallRuntime.discoveryPath,
instanceId: connection.instanceId,
hostGrantStore: permissions.hostGrantStore,
...(config.brokerPort === undefined
? {}
: { preferredPort: config.brokerPort }),
diagnostics: (event) => hostDiagnostics.log(event),
connectionControl: (request) => control.handle(request),
// A broker-internal fault must degrade the Runtime, not crash it.
onFault: (code) => reportFault(code),
sourceNotificationHandler: createSourceChangeHandler({
coreBaseUrl: connection.coreBaseUrl,
coreApiToken: connection.coreApiToken,
instanceId: connection.instanceId,
}),
}),
});
return {
start: () => broker.start(),
async stop() {
await control.stop();
await broker.stop();
},
};
},
});
},
});
}
/**
* The Runtime service runs the SDK migration itself before it spawns Core.
*
* Desktop and CLI install/upgrade migrate before they start the service, but a
* login start, `memhub start`/`restart` and a launchd restart reach this
* process directly; without this gate they served a home that never finished
* its upgrade. The service is the Runtime owner here and Core is not running
* yet, so the maintenance fence (which stops the Runtime and disables its
* service) must not be used: it would stop this very process.
*/
export async function gateServeOnSdkMigration(
config: Pick<RuntimeCliConfig, "dataDir" | "coreCommand" | "buildIdentity">,
migrate: (
options: SdkMigrationOptions,
) => Promise<SdkMigrationResult> = runSdkMigration,
): Promise<SdkMigrationResult> {
return migrate({
dataDir: config.dataDir,
core: { command: config.coreCommand },
runtimeBuild:
config.buildIdentity?.buildNumber ??
config.buildIdentity?.version ??
"unknown",
caller: "runtime-service",
fence: async (_dataDir, action) => action(),
});
}
/**
* Run the migration gate until the home is on SDK authority, or the service is
* asked to stop. Core is never spawned while this has not returned "serve".
*
* A failed migration freezes the home (see `runSdkMigration`): the service
* then exits instead of idling, and its enable marker is off so launchd and
* systemd do not start it again to retry on their own. A service started on a
* frozen home (a Desktop login launch, `memhub install` re-registering it)
* exits the same way without migrating; the next foreground attempt (Desktop
* launch, `memhub install`/`upgrade`/`start`/`restart`) retries and lifts the
* freeze. The refusal goes to the migration log and the Runtime diagnostics,
* the block note tells `status`/`doctor` and Desktop why.
*
* When the freeze could not be recorded, the service falls back to staying
* alive but idle (no Core) until another migrator clears the block note.
*/
export async function holdUntilSdkMigrated(
config: Pick<RuntimeCliConfig, "dataDir" | "coreCommand" | "buildIdentity">,
options: {
stopped: Promise<unknown>;
migrate?: (options: SdkMigrationOptions) => Promise<SdkMigrationResult>;
pollIntervalMs?: number;
},
): Promise<"serve" | "stop"> {
let stop = false;
void options.stopped.then(() => {
stop = true;
});
const runtimeBuild =
config.buildIdentity?.buildNumber ?? config.buildIdentity?.version;
const refuse = async (code: string, frozen: boolean) => {
await writeSdkMigrationLog(config.dataDir, {
writer: "runtime-service",
event: frozen ? "service.frozen_exit" : "service.core_refused",
outcome: "refused",
code,
runtimeBuild,
});
await new DiagnosticLogger({
dataDir: config.dataDir,
service: "runtime",
sessionId: process.env.ONE_MEMORY_RUNTIME_SESSION_ID,
})
.log({
level: "error",
component: "runtime.service",
event: "service.start_failed",
outcome: "blocked",
error_code: code,
})
.catch(() => undefined);
process.stderr.write(`sdk_migration_required: ${code}\n`);
};
const frozenHome = async (): Promise<string | null> => {
const block = await readSdkMigrationBlock(config.dataDir);
if (block?.frozen === undefined) return null;
// Whoever started this service turned the marker on; a frozen home keeps
// it off so the exit below is not followed by a relaunch.
await disableRuntimeService(config.dataDir).catch(() => undefined);
return block.code;
};
const frozenAtStart = await frozenHome();
if (frozenAtStart !== null) {
await refuse(frozenAtStart, true);
return "stop";
}
for (;;) {
if (stop) return "stop";
const migration = await gateServeOnSdkMigration(config, options.migrate);
if (migration.state !== "deferred") return "serve";
const code = migration.code ?? "sdk_migration_incomplete";
const frozen = (await frozenHome()) !== null;
await refuse(code, frozen);
if (frozen) return "stop";
// Wait for a stop, or for another migrator to clear the block note.
for (;;) {
const outcome = await Promise.race([
options.stopped.then(() => "stop" as const),
delay(options.pollIntervalMs ?? 2_000).then(() => "poll" as const),
]);
if (outcome === "stop") return "stop";
if ((await readSdkMigrationBlock(config.dataDir)) === null) break;
}
}
}
export async function runRuntimeCli(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv = process.env,
): Promise<number> {
if (arguments_[0] === "service-plan") {
return printServicePlan(arguments_.slice(1), environment);
}
if (arguments_[0] === "status") {
return printStatus(arguments_.slice(1), environment);
}
if (
arguments_.length > 1 ||
(arguments_[0] !== undefined && arguments_[0] !== "serve")
) {
process.stderr.write(
"Usage: one-memory-runtime [serve|status [--data-dir <absolute-path>]|service-plan --platform <darwin|linux|win32> --home <path> --install-root <path> [--data-dir <absolute-path>] [--uid <number>] [--xdg-config-home <path>] [--port <1024..65535>]]\n",
);
return 2;
}
const config = resolveRuntimeCliConfig(environment);
if (
await cleanupRemovedAppOwnership({
dataDir: config.dataDir,
markerPath: environment.ONE_MEMORY_SERVICE_ENABLE_MARKER,
})
) {
return 0;
}
// Mirror our own stdout/stderr too: with the plist redirect gone, anything the
// Runtime itself prints would otherwise be lost. Best effort -- a logging
// problem must never stop the Runtime.
const ownLogs = await createServiceLogCapture(config.dataDir);
if (ownLogs !== null) {
const mirror = (stream: "stdout" | "stderr") => (chunk: Buffer) => {
void (stream === "stdout"
? ownLogs.writeStdout(chunk.toString())
: ownLogs.writeStderr(chunk.toString()));
};
process.stdout.on("data", mirror("stdout"));
process.stderr.on("data", mirror("stderr"));
}
// One listener for the whole life of the process: a stop while waiting on
// an unfinished migration and a stop while serving are the same request.
const stopRequested = waitForStopSignal();
const idleRemoval = new AbortController();
const held = await holdUntilSdkMigrated(config, {
stopped: Promise.race([
stopRequested,
waitForAppRemoval(
{
dataDir: config.dataDir,
markerPath: environment.ONE_MEMORY_SERVICE_ENABLE_MARKER,
},
idleRemoval.signal,
),
]),
}).finally(() => idleRemoval.abort());
if (held === "stop") {
await ownLogs?.flush();
return 0;
}
const service = await createRuntimeService(config);
await service.start();
const appRemoval = new AbortController();
try {
await Promise.race([
stopRequested,
waitForAppRemoval(
{
dataDir: config.dataDir,
markerPath: environment.ONE_MEMORY_SERVICE_ENABLE_MARKER,
},
appRemoval.signal,
),
]);
} finally {
appRemoval.abort();
await service.stop();
await ownLogs?.flush();
}
return 0;
}
export async function waitForAppRemoval(
options: {
dataDir: string;
markerPath?: string;
shouldStop?: typeof appOwnedRuntimeShouldStop;
pollIntervalMs?: number;
},
signal: AbortSignal,
): Promise<void> {
while (!signal.aborted) {
const continued = await abortableDelay(
options.pollIntervalMs ?? 2_000,
signal,
);
if (!continued) return;
const owner = await readRuntimeServiceOwner(options.dataDir).catch(
() => null,
);
if (owner?.owner !== "app") continue;
if (
await cleanupRemovedAppOwnership({
...options,
...(options.shouldStop === undefined
? {}
: { shouldStop: options.shouldStop }),
})
) {
return;
}
}
}
async function abortableDelay(
milliseconds: number,
signal: AbortSignal,
): Promise<boolean> {
if (signal.aborted) return false;
return new Promise((resolve) => {
const timer = setTimeout(done, milliseconds);
const abort = () => done(false);
function done(continued = true) {
clearTimeout(timer);
signal.removeEventListener("abort", abort);
resolve(continued);
}
signal.addEventListener("abort", abort, { once: true });
});
}
export async function cleanupRemovedAppOwnership(options: {
dataDir: string;
markerPath?: string;
shouldStop?: typeof appOwnedRuntimeShouldStop;
}): Promise<boolean> {
if (
!(await (options.shouldStop ?? appOwnedRuntimeShouldStop)({
dataDir: options.dataDir,
markerPath: options.markerPath,
}))
) {
return false;
}
const owner = await readRuntimeServiceOwner(options.dataDir).catch(
() => null,
);
if (owner?.owner !== "app") return false;
await disableRuntimeService(options.dataDir);
return true;
}
/** Observe the same state the settings page and the tray read.
*
* Read-only on purpose: it starts nothing, repairs nothing and prints no
* token, so it is safe to run while investigating a login-start problem. A
* non-zero exit distinguishes "not healthy" from "could not tell", because a
* script that cannot tell must not be read as a pass.
*/
async function printStatus(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
): Promise<number> {
const values = optionValues(arguments_);
const dataDir =
values?.get("data-dir") ??
hourlyManagedDataDirFromEnvironment(environment, os.homedir()) ??
path.join(os.homedir(), ".one-memory");
const unexpected = [...(values?.keys() ?? [])].some(
(name) => name !== "data-dir",
);
if (!values || unexpected || !path.isAbsolute(dataDir)) {
process.stderr.write("status requires an absolute data directory\n");
return 2;
}
let health: BackgroundHealth;
try {
health = await inspectBackgroundHealth({ dataDir });
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : "background_status_unavailable"}\n`,
);
return 2;
}
process.stdout.write(
`${JSON.stringify({ data_dir: dataDir, ...health }, null, 2)}\n`,
);
return health.state === "healthy" ? 0 : 4;
}
function printServicePlan(
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
): number {
const values = optionValues(arguments_);
if (!values) {
process.stderr.write("service-plan options are invalid\n");
return 2;
}
const platform = values.get("platform");
const home = values.get("home") ?? environment.HOME;
const installRoot = values.get("install-root");
if (
(platform !== "darwin" && platform !== "linux" && platform !== "win32") ||
!home ||
!installRoot
) {
process.stderr.write(
"service-plan requires platform, home and install-root\n",
);
return 2;
}
const uidText = values.get("uid");
const uid = uidText === undefined ? undefined : Number(uidText);
if (uidText !== undefined && (!Number.isSafeInteger(uid) || (uid ?? 0) < 1)) {
process.stderr.write("service-plan uid must be a positive integer\n");
return 2;
}
// An explicit port lets a probe harness compute the exact plan/receipt for a
// non-canonical port without re-deriving `stableCorePort`. Absent, the layout
// derives its stable port as before.
const portText = values.get("port");
const corePort = portText === undefined ? undefined : Number(portText);
if (
portText !== undefined &&
(!Number.isSafeInteger(corePort) ||
(corePort ?? 0) < 1024 ||
(corePort ?? 0) > 65535)
) {
process.stderr.write(
"service-plan port must be an integer in 1024..65535\n",
);
return 2;
}
// Keep absence distinct: --home then owns the default data directory.
const dataDir = values.get("data-dir") ?? environment.ONE_MEMORY_DATA_DIR;
const paths = platform === "win32" ? path.win32 : path.posix;
if (
dataDir !== undefined &&
(!paths.isAbsolute(dataDir) || /[\0\r\n]/.test(dataDir))
) {
process.stderr.write(
"service-plan data directory must be an absolute path without control characters\n",
);
return 2;
}
const plan = planUserService(
runtimeInstallLayout({
platform: platform as RuntimePlatform,
home,
installRoot,
dataDir,
...(corePort === undefined ? {} : { corePort }),
...hourlyIsolationFromEnvironment(environment),
}),
{ uid, xdgConfigHome: values.get("xdg-config-home") },
);
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return plan.status === "ready" ? 0 : 4;
}
function optionValues(
arguments_: readonly string[],
): Map<string, string> | null {
const result = new Map<string, string>();
const allowed = new Set([
"platform",
"home",
"install-root",
"data-dir",
"uid",
"xdg-config-home",
"port",
]);
for (let index = 0; index < arguments_.length; index += 2) {
const key = arguments_[index];
const value = arguments_[index + 1];
const name = key?.startsWith("--") ? key.slice(2) : "";
if (
!allowed.has(name) ||
result.has(name) ||
!value ||
value.startsWith("--")
) {
return null;
}
result.set(name, value);
}
return result;
}
async function waitForStopSignal(): Promise<void> {
await new Promise<void>((resolve) => {
const stop = () => {
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
resolve();
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
});
}