fix(cli): --yes in an unattended run no longer overrides a no saved by another process (#4547)

* fix(cli): --yes in an unattended run no longer overrides a no saved by another process

* fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no

* fix(cli): the thin-results download offer also needs a real terminal

* fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes

* fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no

* fix(cli): a settings lock that cannot be released no longer fails a write that landed

* fix(cli): a leftover settings lock is never taken over, and doctor names it

* fix(cli): a background update that cannot be recorded is not launched again every run

* fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing

* fix(cli): the thin-results offer stops when a yes cannot be saved

* fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once

* fix(cli): the leftover-lock warning follows the attended-terminal rule
This commit is contained in:
Miguel Ángel
2026-09-27 01:39:17 +00:00
committed by GitHub
parent 76723c614c
commit d290c05b09
27 changed files with 1089 additions and 85 deletions
+2 -1
View File
@@ -40,7 +40,8 @@ The download is never started without a yes:
been asked yet.
- Agents, CI and `--json` runs are never prompted. They are told to ask the person they work for;
`--on-device --yes` is that person's yes, and records it.
- After a no, `--on-device` alone is skipped with a message; `--on-device --yes` changes the answer.
- After a no, `--on-device` alone is skipped with a message. Only `--on-device --yes` in an interactive
terminal (stdin and stdout both terminals, no `--json`, not CI) changes the answer; elsewhere a no stands.
Once the model is on your machine, every `catalog --query` uses meaning search. If it cannot run, the
CLI says why and falls back to word match.
+2 -6
View File
@@ -1,4 +1,5 @@
import { failCommand, failUsage } from "../../utils/commandResult.js";
import { envFlagEnabled, isAttendedTerminal } from "../../utils/attendedTerminal.js";
/**
* `hyperframes auth login` — sign in to HeyGen.
*
@@ -117,13 +118,8 @@ function isRemoteOrHeadless(): boolean {
);
}
function envFlagEnabled(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
}
function assertAttendedDeviceFlow(): void {
if (envFlagEnabled("CI") || process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
if (!isAttendedTerminal()) {
console.error(
c.error(
"`--device` requires an attended terminal and is disabled in CI. Use an API key or workload credential for automation.",
+202 -7
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RegistryItem } from "@hyperframes/core";
@@ -84,8 +85,14 @@ const state = vi.hoisted(() => ({
modelStatus: "ready" as "ready" | "not-asked" | "declined" | "unavailable",
confirmAnswer: true as boolean,
consentRecorded: [] as boolean[],
consentSavedMeanwhile: undefined as boolean | undefined,
consentWriteFails: false,
downloads: 0,
runtimeInstalls: 0,
runtimeAvailable: true,
noSavedDuringRuntime: false,
vectorsOnDisk: true,
runtimeOnDisk: true,
}));
vi.mock("../registry/resolver.js", () => ({
@@ -131,16 +138,34 @@ vi.mock("../registry/localModel.js", () => ({
return true;
},
recordLocalModelConsent: (enabled: boolean) => {
if (state.consentWriteFails) return state.consentRecorded.at(-1);
state.consentRecorded.push(enabled);
return enabled;
},
assumeLocalModelConsent: () => {
if (state.consentWriteFails) return undefined;
const onDisk = state.consentSavedMeanwhile ?? state.consentRecorded.at(-1);
if (onDisk !== undefined) return onDisk;
state.consentRecorded.push(true);
return true;
},
// "unavailable" and "ready" both mean a yes is saved; only a later save changes that.
savedLocalModelConsent: () =>
state.consentSavedMeanwhile ??
state.consentRecorded.at(-1) ??
(state.modelStatus === "ready" || state.modelStatus === "unavailable" ? true : undefined),
downloadOfferMessage: () => "offer",
nonInteractiveConsentMessage: () => "consent",
}));
vi.mock("../registry/localEmbedder.js", () => ({
hasLocalRuntime: () => state.runtimeOnDisk,
// Left unmocked this would run a real npm install under vitest.
ensureLocalRuntime: async () =>
state.runtimeAvailable ? { ok: true } : { ok: false, reason: "installing it failed" },
ensureLocalRuntime: async () => {
state.runtimeInstalls += 1;
if (state.noSavedDuringRuntime) state.consentSavedMeanwhile = false;
return state.runtimeAvailable ? { ok: true } : { ok: false, reason: "installing it failed" };
},
}));
vi.mock("../registry/localSemantic.js", () => ({
@@ -151,7 +176,7 @@ vi.mock("../registry/localSemantic.js", () => ({
},
localVectorNames: () => state.indexed,
cachedLocalVectorRevision: () => state.cachedVectorRevision,
hasLocalVectors: () => true,
hasLocalVectors: () => state.vectorsOnDisk,
fetchLocalVectors: async (_registry: string, options: { expectedRevision?: string } = {}) => {
state.vectorFetches += 1;
if (state.vectorFetchSucceeds && options.expectedRevision !== undefined) {
@@ -252,8 +277,14 @@ beforeEach(() => {
state.rankingError = null;
state.confirmAnswer = true;
state.consentRecorded = [];
state.consentSavedMeanwhile = undefined;
state.consentWriteFails = false;
state.downloads = 0;
state.runtimeInstalls = 0;
state.runtimeAvailable = true;
state.noSavedDuringRuntime = false;
state.vectorsOnDisk = true;
state.runtimeOnDisk = true;
state.registry = [block("count-up"), block("fade-through"), component("whip-pan")];
state.indexed = ["count-up", "fade-through", "whip-pan"];
state.ranking = [
@@ -608,14 +639,27 @@ describe("the on-device download offer", () => {
// The offer only exists for someone who can answer it. Off a terminal the
// caller must add --yes explicitly, so a test that forgets the terminal
// never reaches the prompt and passes for the wrong reason.
const asATerminal = async (run: () => Promise<string>): Promise<string> => {
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const asATerminal = async <T>(
run: () => Promise<T>,
{ stdin = true, ci }: { stdin?: boolean; ci?: string } = {},
): Promise<T> => {
const streams = [process.stdin, process.stdout];
const descriptors = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
const savedCi = process.env["CI"];
Object.defineProperty(process.stdin, "isTTY", { value: stdin, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
if (ci === undefined) delete process.env["CI"];
else process.env["CI"] = ci;
try {
return await run();
} finally {
if (descriptor) Object.defineProperty(process.stdout, "isTTY", descriptor);
else delete (process.stdout as unknown as { isTTY?: boolean }).isTTY;
streams.forEach((stream, i) => {
const descriptor = descriptors[i];
if (descriptor) Object.defineProperty(stream, "isTTY", descriptor);
else delete (stream as unknown as { isTTY?: boolean }).isTTY;
});
if (savedCi === undefined) delete process.env["CI"];
else process.env["CI"] = savedCi;
}
};
@@ -659,6 +703,58 @@ describe("the on-device download offer", () => {
expect(state.consentRecorded).toEqual([false, true]);
});
it("says a no that could not be saved was not saved", async () => {
state.modelStatus = "not-asked";
state.confirmAnswer = false;
state.consentWriteFails = true;
const { err } = await asATerminal(() => runForExit({ query: "count up", "on-device": true }));
expect([state.downloads, state.consentRecorded]).toEqual([0, []]);
expect(err).toContain("declined, but could not save the answer in settings");
});
it("says so when a no given to the thin-results offer could not be saved", async () => {
state.modelStatus = "not-asked";
state.confirmAnswer = false;
state.consentWriteFails = true;
const { err } = await asATerminal(() => runForExit({ query: "count up" }));
expect(err).toContain("Could not save the answer in settings; `hyperframes doctor` says why.");
});
it.each([
["piped stdin", { stdin: false }],
["CI", { ci: "true" }],
])("treats a terminal with %s as unwatched, so its --yes keeps a saved no", async (_, env) => {
state.consentRecorded = [false];
const { err } = await asATerminal(
() => runForExit({ query: "count up", "on-device": true, yes: true }),
env,
);
expect([state.runtimeInstalls, state.downloads, state.consentRecorded]).toEqual([
0,
0,
[false],
]);
expect(err).toContain("previously declined");
});
it.each([
["piped stdin", { stdin: false }],
["CI", { ci: "true" }],
])("does not offer the download after thin results in a terminal with %s", async (_, env) => {
state.modelStatus = "not-asked";
state.confirmAnswer = true;
await asATerminal(() => runForExit({ query: "count up" }), env);
expect([state.downloads, state.consentRecorded]).toEqual([0, []]);
});
it("does not treat non-interactive output as download consent", async () => {
state.modelStatus = "not-asked";
@@ -667,6 +763,105 @@ describe("the on-device download offer", () => {
expect(state.downloads).toBe(0);
expect(state.consentRecorded).toEqual([]);
});
it("lets --yes in a run nobody watches answer a question never asked", async () => {
state.modelStatus = "not-asked";
await runEnvelope({ query: "count up", "on-device": true, yes: true });
expect([state.downloads, state.consentRecorded]).toEqual([1, [true]]);
});
it("keeps a recorded no against --yes in a run nobody watches, installing nothing", async () => {
state.consentRecorded = [false];
const { warnings } = await runEnvelope({ query: "count up", "on-device": true, yes: true });
expect([state.runtimeInstalls, state.downloads, state.consentRecorded]).toEqual([
0,
0,
[false],
]);
expect(warnings?.join(" ")).toContain("previously declined");
});
it("installs nothing, and does not claim a no, when the answer cannot be saved", async () => {
state.modelStatus = "not-asked";
state.consentWriteFails = true;
const { warnings } = await runEnvelope({ query: "count up", "on-device": true, yes: true });
expect([state.runtimeInstalls, state.downloads]).toEqual([0, 0]);
expect(warnings?.join(" ")).toContain("could not save the answer");
expect(warnings?.join(" ")).not.toContain("previously declined");
});
it("keeps a no saved by someone else while the run was starting", async () => {
state.modelStatus = "not-asked";
state.consentSavedMeanwhile = false;
const { warnings } = await runEnvelope({ query: "count up", "on-device": true, yes: true });
expect([state.runtimeInstalls, state.downloads, state.consentRecorded]).toEqual([0, 0, []]);
expect(warnings?.join(" ")).toContain("previously declined");
});
it("installs nothing on the routine update when a no was saved after the yes", async () => {
state.modelStatus = "unavailable";
state.consentSavedMeanwhile = false;
const { warnings } = await runEnvelope({ query: "count up" });
expect([state.runtimeInstalls, state.downloads]).toEqual([0, 0]);
expect(warnings?.join(" ")).toContain("previously declined");
});
it("skips the model download when a no is saved while the runtime installs", async () => {
state.modelStatus = "unavailable";
state.noSavedDuringRuntime = true;
await runEnvelope({ query: "count up", "on-device": true });
expect([state.runtimeInstalls, state.downloads]).toEqual([1, 0]);
});
it("fetches nothing after a thin-results yes that cannot be saved", async () => {
state.modelStatus = "not-asked";
state.confirmAnswer = true;
state.consentWriteFails = true;
state.vectorsOnDisk = false;
await asATerminal(() => runForExit({ query: "count up" }));
expect([state.vectorFetches, state.downloads]).toEqual([0, 0]);
});
it.each([
[true, 1],
[false, 0],
])(
"installs a runtime missing beside a ready model only through the consent check (saved yes: %s)",
async (savedYes, installs) => {
state.runtimeOnDisk = false;
if (!savedYes) state.consentSavedMeanwhile = false;
await runEnvelope({ query: "count up" });
expect(state.runtimeInstalls).toBe(installs);
},
);
it("installs nothing when a person's yes cannot be saved", async () => {
state.consentRecorded = [false];
state.consentWriteFails = true;
const { err } = await asATerminal(() =>
runForExit({ query: "count up", "on-device": true, yes: true }),
);
expect([state.runtimeInstalls, state.downloads]).toEqual([0, 0]);
expect(err).toContain("could not save the answer in settings");
});
});
describe("catalogRow", () => {
+47 -16
View File
@@ -17,9 +17,12 @@ import { fetchRegistryManifest } from "../registry/remote.js";
import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js";
import { resolve } from "node:path";
import { finishCommand } from "../utils/commandResult.js";
import { isAttendedTerminal } from "../utils/attendedTerminal.js";
import { runAdd } from "./add.js";
import { hasNoSearchableTokens, searchByWords } from "../registry/localSearch.js";
import {
assumeLocalModelConsent,
savedLocalModelConsent,
downloadOfferMessage,
ensureLocalModel,
type LocalModelStatus,
@@ -27,7 +30,7 @@ import {
nonInteractiveConsentMessage,
recordLocalModelConsent,
} from "../registry/localModel.js";
import { ensureLocalRuntime } from "../registry/localEmbedder.js";
import { ensureLocalRuntime, hasLocalRuntime } from "../registry/localEmbedder.js";
import {
cachedLocalVectorRevision,
fetchLocalVectors,
@@ -62,10 +65,12 @@ async function prepareOnDeviceTier(opts: {
};
const status = opts.status;
if (!opts.assumedYes && status.status === "declined") {
warn(
"on-device search skipped: the model download was previously declined. Re-run with --yes to consent.",
);
// A person at a terminal may reverse their own no with --yes; an unwatched run only answers a question never asked.
const unwatchedYes = opts.assumedYes && !opts.canPrompt;
const declined =
"on-device search skipped: the model download was previously declined. To consent, re-run with --yes in an interactive terminal, without --json and outside CI.";
if (status.status === "declined" && !opts.assumedYes) {
warn(declined);
return warnings;
}
@@ -80,8 +85,11 @@ async function prepareOnDeviceTier(opts: {
initialValue: true,
});
if (clack.isCancel(answer) || answer !== true) {
recordLocalModelConsent(false);
warn("on-device search skipped: the download was declined.");
warn(
recordLocalModelConsent(false) === false
? "on-device search skipped: the download was declined."
: "on-device search skipped: the download was declined, but could not save the answer in settings; `hyperframes doctor` says why.",
);
// Return, or the decline is the only thing that does not happen: the
// runtime check below is skipped precisely because consent is now false,
// control reaches recordLocalModelConsent(true), and the answer is
@@ -90,16 +98,36 @@ async function prepareOnDeviceTier(opts: {
}
}
// Settled from disk before anything installs, on every path, so a no saved by another process stops it.
const asked = status.status === "declined" || status.status === "not-asked";
const agreed = !asked
? savedLocalModelConsent()
: unwatchedYes
? assumeLocalModelConsent()
: recordLocalModelConsent(true);
if (agreed !== true) {
warn(
agreed === false && (unwatchedYes || !asked)
? declined
: asked
? "on-device search skipped: could not save the answer in settings; `hyperframes doctor` says why."
: nonInteractiveConsentMessage(),
);
return warnings;
}
// Before the model download: fetching 32 MB and then finding the runtime missing wastes it.
const runtime = await ensureLocalRuntime();
if (!runtime.ok) {
warn(`on-device search skipped: ${runtime.reason}`);
return warnings;
}
if (status.status === "declined" || status.status === "not-asked") {
recordLocalModelConsent(true);
// The runtime install can take a while; a no saved during it stops the model download.
if (savedLocalModelConsent() !== true) {
warn(declined);
return warnings;
}
const model = await ensureLocalModel();
const revisionStale =
opts.artifactRevision !== undefined && cachedLocalVectorRevision() !== opts.artifactRevision;
@@ -215,8 +243,8 @@ export default defineCommand({
const routineUpdate =
searchContext?.status.status === "unavailable" ||
(searchContext?.status.status === "ready" &&
artifactRevision !== undefined &&
cachedLocalVectorRevision() !== artifactRevision);
(!hasLocalRuntime() ||
(artifactRevision !== undefined && cachedLocalVectorRevision() !== artifactRevision)));
const shouldPrepare = args["on-device"] === true || routineUpdate;
let warnings: string[] = [];
let effectiveStatus = searchContext?.status;
@@ -224,7 +252,7 @@ export default defineCommand({
warnings = await prepareOnDeviceTier({
assumedYes: args.yes === true,
artifactRevision,
canPrompt: process.stdout.isTTY === true && !json,
canPrompt: isAttendedTerminal() && !json,
registry: config.registry,
registryNames,
status: searchContext.status,
@@ -676,7 +704,7 @@ type LocalMode = "local-model" | "words";
* person to prompt instead, or when that person has already answered.
*/
function localModelHint(json: boolean, status: LocalModelStatus | undefined): string | null {
if (!json && process.stdout.isTTY) return null;
if (!json && isAttendedTerminal()) return null;
if (status?.status !== "not-asked") return null;
return nonInteractiveConsentMessage();
}
@@ -694,7 +722,7 @@ async function offerLocalModel(
registryBaseUrl: string,
artifactRevision?: string,
): Promise<void> {
if (json || !process.stdout.isTTY) return;
if (json || !isAttendedTerminal()) return;
if (localModelStatus().status !== "not-asked") return;
// Deliberately not gated on the number of results. That gate was set when the
// catalog was small; against 411 moves a word match nearly always returns
@@ -706,7 +734,10 @@ async function offerLocalModel(
initialValue: true,
});
if (clack.isCancel(answer)) return;
recordLocalModelConsent(answer === true);
if (recordLocalModelConsent(answer === true) !== (answer === true)) {
console.error(" Could not save the answer in settings; `hyperframes doctor` says why.");
return;
}
if (answer !== true) return;
// The vectors come from the registry rather than the package, so consent is
+23 -1
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
@@ -311,3 +311,25 @@ describe("checkOptionalPackage", () => {
});
});
});
describe("checkSettingsLock", () => {
it("names a lock left by a process that stopped, and leaves it for the person to remove", async () => {
const { checkSettingsLock } = await import("./doctor.js");
const dir = mkdtempSync(join(tmpdir(), "hf-doctor-lock-"));
try {
const lock = join(dir, "config.json.lock");
expect(checkSettingsLock(lock)).toEqual({ ok: true, detail: "Not locked" });
writeFileSync(lock, "stopped");
const past = new Date(Date.now() - 60_000);
utimesSync(lock, past, past);
const result = checkSettingsLock(lock);
expect(result.ok).toBe(false);
expect(result.hint).toContain(`delete ${lock}`);
expect(existsSync(lock)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+16
View File
@@ -1,11 +1,15 @@
// fallow-ignore-file complexity
import { defineCommand } from "citty";
import { execFileSync, execSync } from "node:child_process";
import * as fs from "node:fs";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { dirname } from "node:path";
import { resolveExtractCacheDir } from "@hyperframes/engine";
import type { Example } from "./_examples.js";
import { CONFIG_PATH } from "../telemetry/config.js";
import { withFileLock } from "../media-use/lib/config-lock.mjs";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { c } from "../ui/colors.js";
import { parseToolVersion, runEnvironmentChecks } from "../browser/preflight.js";
import { KOKORO_MODULES, KOKORO_PIP, MUSICGEN_MODULES, MUSICGEN_PIP } from "../audio/providers.js";
@@ -236,6 +240,17 @@ export function checkArchiveExtractor(
};
}
/** A lock left by a hyperframes process that stopped mid-write blocks settings writes until a person removes it. */
export function checkSettingsLock(lockPath = `${CONFIG_PATH}.lock`): CheckResult {
if (!existsSync(lockPath)) return { ok: true, detail: "Not locked" };
try {
withFileLock(lockPath, fs, () => undefined);
return { ok: true, detail: "Not locked" };
} catch (error) {
return { ok: false, detail: "Locked", hint: normalizeErrorMessage(error) };
}
}
function checkEnvironment(): CheckResult {
const sys = getSystemMeta();
const parts: string[] = [];
@@ -361,6 +376,7 @@ export default defineCommand({
{ name: "Disk", run: checkDisk },
{ name: "Frames cache", run: () => checkFramesCache() },
{ name: "Archive extractor", run: checkArchiveExtractor },
{ name: "Settings lock", run: () => checkSettingsLock() },
];
// /dev/shm is only relevant on Linux (especially Docker)
@@ -0,0 +1,3 @@
import type * as fs from "node:fs";
export function withFileLock<T>(lockPath: string, fsModule: typeof fs, task: () => T): T;
@@ -0,0 +1,45 @@
// The one lock on ~/.hyperframes/config.json, for the CLI, its auto-update step and media-use. The auto-update step
// embeds this function's source, so it must use only its arguments and globals.
export function withFileLock(lockPath, fs, task) {
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const started = Date.now();
for (;;) {
let fd;
try {
fd = fs.openSync(lockPath, "wx");
} catch (error) {
if (error.code !== "EEXIST") throw error;
// Never taken over: a lock older than any hold was left by a process that stopped, and a person removes it.
let leftover = false;
try {
leftover = Date.now() - fs.statSync(lockPath).mtimeMs > 5000;
} catch {}
if (leftover || Date.now() - started > 10000)
throw Object.assign(
new Error(
`Settings are locked by another hyperframes process. If none is running, delete ${lockPath}`,
),
{ code: "HF_SETTINGS_LOCKED" },
);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
continue;
}
try {
fs.writeSync(fd, token);
} catch (error) {
fs.rmSync(lockPath, { force: true });
throw error;
} finally {
fs.closeSync(fd);
}
break;
}
try {
return task();
} finally {
// No code removes another's lock, so release needs no check (only a person deleting a live one defeats it).
try {
fs.rmSync(lockPath);
} catch {}
}
}
@@ -0,0 +1,79 @@
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { test } from "node:test";
import * as fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { withFileLock } from "./config-lock.mjs";
function lockIn() {
const dir = fs.mkdtempSync(join(tmpdir(), "hf-config-lock-"));
return {
lock: join(dir, "config.json.lock"),
cleanup: () => fs.rmSync(dir, { recursive: true }),
};
}
test("returns the task's result even when the lock cannot be released", () => {
const { lock, cleanup } = lockIn();
try {
const refusing = {
...fs,
rmSync(path, options) {
if (path === lock) throw Object.assign(new Error("EPERM"), { code: "EPERM" });
return fs.rmSync(path, options);
},
};
assert.equal(
withFileLock(lock, refusing, () => "saved"),
"saved",
);
} finally {
cleanup();
}
});
test("refuses a lock left by a process that stopped, names it, and never removes it", () => {
const { lock, cleanup } = lockIn();
try {
fs.writeFileSync(lock, "dead");
const past = new Date(Date.now() - 60_000);
fs.utimesSync(lock, past, past);
const started = Date.now();
let ran = false;
assert.throws(
() => withFileLock(lock, fs, () => (ran = true)),
(error) => error.code === "HF_SETTINGS_LOCKED" && error.message.includes(lock),
);
assert.ok(Date.now() - started < 1000);
assert.equal(ran, false);
assert.equal(fs.readFileSync(lock, "utf8"), "dead");
} finally {
cleanup();
}
});
test("waits for a process that holds the lock, then takes it", async () => {
const { lock, cleanup } = lockIn();
try {
const script = `
const fs = require("fs");
const lock = process.argv[1];
fs.writeFileSync(lock, "other", { flag: "wx" });
process.stdout.write("locked\\n");
setTimeout(() => {
fs.writeFileSync(lock + ".released", String(Date.now()));
fs.rmSync(lock);
}, 300);`;
const other = spawn(process.execPath, ["-e", script, lock]);
await new Promise((ready) => other.stdout.once("data", ready));
const ranAt = withFileLock(lock, fs, () => Date.now());
assert.ok(ranAt >= Number(fs.readFileSync(`${lock}.released`, "utf8")));
await new Promise((exited) => other.once("exit", exited));
} finally {
cleanup();
}
});
+24 -8
View File
@@ -2,9 +2,11 @@
// never carry intent text, file names, or paths.
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import * as fs from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { withFileLock } from "./config-lock.mjs";
import { globalMediaDir } from "./media-home.mjs";
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -62,10 +64,24 @@ function readSharedConfig() {
return {};
}
function writeSharedConfig(config) {
const dir = join(homedir(), ".hyperframes");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
// Best effort: skipped when the lock cannot be taken or the file cannot be read, never written unlocked.
function updateSharedConfig(patch) {
const file = sharedConfigPath();
mkdirSync(dirname(file), { recursive: true });
try {
withFileLock(`${file}.lock`, fs, () => {
let config = {};
try {
config = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
if (error.code !== "ENOENT") return;
}
if (!config || typeof config !== "object" || Array.isArray(config)) return;
const tmp = `${file}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify({ ...config, ...patch }, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, file);
});
} catch {}
}
// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
@@ -93,7 +109,7 @@ function anonymousId() {
return config.anonymousId.trim();
}
const id = legacyMediaAnonId() || randomUUID();
writeSharedConfig({ ...config, anonymousId: id });
updateSharedConfig({ anonymousId: id });
return id;
} catch {
return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
@@ -133,7 +149,7 @@ function showTelemetryNotice() {
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
].join("\n"),
);
writeSharedConfig({ ...config, telemetryNoticeShown: true });
updateSharedConfig({ telemetryNoticeShown: true });
} catch {
// notice is best-effort; never surface into the command
}
@@ -1,4 +1,5 @@
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { test } from "node:test";
import {
existsSync,
@@ -372,3 +373,65 @@ test("read-only telemetry state degrades without throwing", async () => {
__resetTelemetryForTest();
}
});
async function trackQuietly(home) {
const originalFetch = globalThis.fetch;
const originalError = console.error;
globalThis.fetch = async () => ({ ok: true });
console.error = () => {};
try {
withoutTelemetryOptOut();
await track("media_use_resolve", { type: "bgm" });
return readFileSync(join(home, ".hyperframes/config.json"), "utf8");
} finally {
globalThis.fetch = originalFetch;
console.error = originalError;
}
}
test("waits for another process's locked settings write, then keeps the no it saved", async () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
const dir = join(home, ".hyperframes");
mkdirSync(dir, { recursive: true });
const holder = spawn(process.execPath, [
"-e",
`const fs = require("fs");
const dir = process.argv[1];
fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
process.stdout.write("locked\\n");
setTimeout(() => {
fs.writeFileSync(dir + "/other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
fs.renameSync(dir + "/other.tmp", dir + "/config.json");
fs.rmSync(dir + "/config.json.lock");
}, 1000);`,
dir,
]);
const exited = new Promise((done) => holder.on("exit", done));
try {
await new Promise((locked) => holder.stdout.once("data", locked));
const saved = JSON.parse(await trackQuietly(home));
await exited;
assert.equal(saved.localEmbeddingEnabled, false);
assert.equal(saved.telemetryNoticeShown, true);
assert.equal(typeof saved.anonymousId, "string");
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("leaves a settings file it cannot read untouched", async () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(join(home, ".hyperframes/config.json"), "{not json");
try {
assert.equal(await trackQuietly(home), "{not json");
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
const installs = vi.hoisted(() => ({ count: 0 }));
vi.mock("../utils/optionalPackages.js", () => ({
installedOptionalPackageVersion: () => null,
loadInstalledOptionalPackage: () => null,
loadOptionalPackage: async () => {
installs.count += 1;
return {};
},
}));
import { hasLocalRuntime, loadLocalEmbedder } from "./localEmbedder.js";
describe("loadLocalEmbedder", () => {
it("never installs the runtime; a search without it falls back instead", async () => {
await expect(loadLocalEmbedder()).rejects.toThrow("not installed");
expect(installs.count).toBe(0);
expect(hasLocalRuntime()).toBe(false);
});
});
+12 -2
View File
@@ -13,7 +13,11 @@
import { readFileSync } from "node:fs";
import { loadOptionalPackage } from "../utils/optionalPackages.js";
import {
installedOptionalPackageVersion,
loadInstalledOptionalPackage,
loadOptionalPackage,
} from "../utils/optionalPackages.js";
import {
LOCAL_MODEL_DIMENSIONS,
QUERY_INSTRUCTION,
@@ -44,8 +48,14 @@ export async function ensureLocalRuntime(): Promise<{ ok: true } | { ok: false;
}
}
export function hasLocalRuntime(): boolean {
return installedOptionalPackageVersion("onnxruntime-node") !== null;
}
/** Loads what is installed and never installs: only ensureLocalRuntime does, after consent is settled. */
export async function loadLocalEmbedder(): Promise<LocalEmbedder> {
const ort = await loadOptionalPackage("onnxruntime-node", FEATURE);
const ort = loadInstalledOptionalPackage("onnxruntime-node");
if (ort === null) throw new Error("the on-device runtime is not installed yet");
const config = configFromTokenizerJson(readFileSync(localTokenizerPath(), "utf-8"));
const session = await ort.InferenceSession.create(localModelPath());
+17 -15
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const readConfig = vi.fn();
const writeConfig = vi.fn();
const disk = { consent: undefined as boolean | undefined };
const existsSync = vi.fn();
const readFileSync = vi.fn();
const unlinkSync = vi.fn();
@@ -10,7 +10,8 @@ const digest = vi.fn();
vi.mock("../telemetry/config.js", () => ({
readConfig: () => readConfig(),
writeConfig: (c: unknown) => writeConfig(c),
updateLocalModelConsent: (decide: (onDisk: boolean | undefined) => boolean | undefined) =>
(disk.consent = decide(disk.consent)),
}));
vi.mock("node:fs", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs")>()),
@@ -45,6 +46,7 @@ const {
localTokenizerPath,
localModelPath,
recordLocalModelConsent,
assumeLocalModelConsent,
} = await import("./localModel.js");
function returnMatchingDigests(rounds = 1): void {
@@ -64,7 +66,7 @@ function makeDownloadsAppearOnDisk(): void {
beforeEach(() => {
readConfig.mockReturnValue({});
existsSync.mockReturnValue(false);
writeConfig.mockReset();
disk.consent = undefined;
readFileSync.mockReturnValue(Buffer.from("artifact"));
unlinkSync.mockReset();
downloadFile.mockReset();
@@ -78,20 +80,20 @@ describe("consent state", () => {
expect(localModelConsent()).toBeUndefined();
});
it("persists a yes", () => {
readConfig.mockReturnValue({ telemetryEnabled: true });
it("records a person's answer over whatever was saved", () => {
disk.consent = false;
recordLocalModelConsent(true);
expect(writeConfig).toHaveBeenCalledWith(
expect.objectContaining({ localEmbeddingEnabled: true }),
);
expect(disk.consent).toBe(true);
recordLocalModelConsent(false);
expect(disk.consent).toBe(false);
});
it("persists a no without discarding other settings", () => {
readConfig.mockReturnValue({ telemetryEnabled: false, anonymousId: "abc" });
recordLocalModelConsent(false);
expect(writeConfig).toHaveBeenCalledWith(
expect.objectContaining({ localEmbeddingEnabled: false, anonymousId: "abc" }),
);
it("assumes yes only for a question never asked, and keeps a saved no", () => {
disk.consent = undefined;
expect(assumeLocalModelConsent()).toBe(true);
disk.consent = false;
expect(assumeLocalModelConsent()).toBe(false);
expect(disk.consent).toBe(false);
});
});
@@ -171,7 +173,7 @@ describe("status", () => {
// The caller owns the prompt, because only it knows whether word matching
// already answered well enough to make the offer pointless.
expect(() => localModelStatus()).not.toThrow();
expect(writeConfig).not.toHaveBeenCalled();
expect(disk.consent).toBeUndefined();
});
});
+13 -3
View File
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { downloadFile } from "../utils/download.js";
import { readConfig, writeConfig } from "../telemetry/config.js";
import { readConfig, updateLocalModelConsent } from "../telemetry/config.js";
/**
* bge-small-en-v1.5: 62.17 MTEB average against 62.3 for text-embedding-3-small,
@@ -82,8 +82,18 @@ export function localModelConsent(): LocalModelDecision {
return readConfig().localEmbeddingEnabled;
}
export function recordLocalModelConsent(enabled: boolean): void {
writeConfig({ ...readConfig(), localEmbeddingEnabled: enabled });
export function recordLocalModelConsent(enabled: boolean): LocalModelDecision {
return updateLocalModelConsent(() => enabled);
}
/** The answer saved right now, read through the settings lock so a write in progress lands first. */
export function savedLocalModelConsent(): LocalModelDecision {
return updateLocalModelConsent((onDisk) => onDisk);
}
/** --yes in a run nobody watches: it answers only a question never asked. Returns the decision that stands. */
export function assumeLocalModelConsent(): LocalModelDecision {
return updateLocalModelConsent((onDisk) => onDisk ?? true);
}
export function localModelPath(): string {
@@ -0,0 +1,162 @@
// @vitest-environment node
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let home: string;
let configDir: string;
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "hf-config-lock-"));
configDir = join(home, ".hyperframes");
mkdirSync(configDir, { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
// A worker thread keeps the real home; never write a test answer into it.
if (homedir() !== home) throw new Error("these tests need a process per test file");
vi.resetModules();
});
afterEach(() => {
process.env.HOME = saved.HOME;
process.env.USERPROFILE = saved.USERPROFILE;
rmSync(home, { recursive: true, force: true });
});
const consentOnDisk = () =>
JSON.parse(readFileSync(join(configDir, "config.json"), "utf-8")).localEmbeddingEnabled;
/** Another CLI process: takes the settings lock, saves the person's no after a pause, lets go. */
function anotherProcessSavesNo(): Promise<void> {
const script = `
const fs = require("fs");
const dir = process.argv[1];
fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
process.stdout.write("locked\\n");
setTimeout(() => {
fs.writeFileSync(dir + "/config.json.other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
fs.renameSync(dir + "/config.json.other.tmp", dir + "/config.json");
fs.rmSync(dir + "/config.json.lock");
}, 300);`;
const child = spawn(process.execPath, ["-e", script, configDir]);
return new Promise((locked, failed) => {
child.stdout.on("data", () => locked());
child.on("error", failed);
});
}
describe("a settings file that exists but cannot be read", () => {
it.each([
[
"corrupt",
() => writeFileSync(join(configDir, "config.json"), '{"localEmbeddingEnabled": fal'),
],
["not a file", () => mkdirSync(join(configDir, "config.json"))],
[
"its answer is not a yes or no",
() => writeFileSync(join(configDir, "config.json"), '{"localEmbeddingEnabled": "false"}'),
],
])("counts as a no when %s, never as a question not yet asked", async (_, make) => {
make();
const { readConfig, updateLocalModelConsent } = await import("./config.js");
readConfig();
expect(updateLocalModelConsent((onDisk) => onDisk ?? true)).toBe(false);
});
});
describe("a settings lock left by a process that stopped", () => {
const leaveLock = () => {
const lock = join(configDir, "config.json.lock");
writeFileSync(lock, "stopped");
const past = new Date(Date.now() - 60_000);
utimesSync(lock, past, past);
return lock;
};
it.each([
["a terminal", true, [], undefined, 1],
["--json", true, ["--json"], undefined, 0],
["--json=true", true, ["--json=true"], undefined, 0],
["piped output", false, [], undefined, 0],
["CI", true, [], "true", 0],
])("warns once per process on %s", async (_, tty, extraArgs, ci, lines) => {
const lock = leaveLock();
const { writeConfig, readConfig } = await import("./config.js");
const streams = [process.stdin, process.stdout];
const saved = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
const argv = process.argv;
const savedCi = process.env.CI;
const error = vi.spyOn(console, "error").mockImplementation(() => {});
for (const stream of streams)
Object.defineProperty(stream, "isTTY", { value: tty, configurable: true });
process.argv = [...argv, ...extraArgs];
if (ci === undefined) delete process.env.CI;
else process.env.CI = ci;
try {
writeConfig(readConfig());
writeConfig(readConfig());
const warnings = error.mock.calls.map(([line]) => String(line));
expect(warnings).toHaveLength(lines);
if (lines) expect(warnings[0]).toContain(lock);
if (lines) expect(warnings[0]).toContain("hyperframes doctor");
} finally {
error.mockRestore();
process.argv = argv;
if (savedCi === undefined) delete process.env.CI;
else process.env.CI = savedCi;
streams.forEach((stream, i) => {
const descriptor = saved[i];
if (descriptor) Object.defineProperty(stream, "isTTY", descriptor);
else delete (stream as { isTTY?: boolean }).isTTY;
});
}
});
});
describe("config writes across processes", () => {
it("reads a no another process is saving at that moment, not the answer before it", async () => {
const { readConfig, updateLocalModelConsent } = await import("./config.js");
writeFileSync(join(configDir, "config.json"), JSON.stringify({ localEmbeddingEnabled: true }));
readConfig();
await anotherProcessSavesNo();
expect(updateLocalModelConsent((onDisk) => onDisk)).toBe(false);
});
it("waits for another process's settings write, then keeps the no it saved", async () => {
const { readConfig, updateLocalModelConsent } = await import("./config.js");
expect(readConfig().localEmbeddingEnabled).toBeUndefined();
await anotherProcessSavesNo();
expect(updateLocalModelConsent((onDisk) => onDisk ?? true)).toBe(false);
expect(consentOnDisk()).toBe(false);
});
it("reads a no saved elsewhere even when keeping it writes nothing", async () => {
const { readConfig, updateLocalModelConsent } = await import("./config.js");
const early = readConfig();
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ ...early, localEmbeddingEnabled: false }),
);
expect(updateLocalModelConsent((onDisk) => onDisk ?? true)).toBe(false);
expect(readConfig().localEmbeddingEnabled).toBe(false);
});
it("never lets a copy read before another process's no put the question back", async () => {
const { readConfig, writeConfig } = await import("./config.js");
const early = readConfig();
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ ...early, localEmbeddingEnabled: false }),
);
writeConfig({ ...early, commandCount: early.commandCount + 1 });
expect(consentOnDisk()).toBe(false);
});
});
+72 -1
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { beforeEach, describe, expect, it, vi } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -10,6 +11,7 @@ import { join } from "node:path";
// vitest worker.
const fsState = vi.hoisted(() => ({
files: new Map<string, string>(),
fds: new Map<number, string>(),
}));
vi.mock("node:fs", () => ({
@@ -17,7 +19,8 @@ vi.mock("node:fs", () => ({
mkdirSync: vi.fn(() => undefined),
readFileSync: vi.fn((path: string) => {
const content = fsState.files.get(path);
if (content === undefined) throw new Error(`ENOENT: ${path}`);
if (content === undefined)
throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
return content;
}),
writeFileSync: vi.fn((path: string, content: string) => {
@@ -34,6 +37,21 @@ vi.mock("node:fs", () => ({
rmSync: vi.fn((path: string) => {
fsState.files.delete(path);
}),
// The settings lock: an exclusive create, then the owner's token written through the descriptor.
openSync: vi.fn((path: string, flag: string) => {
if (flag === "wx" && fsState.files.has(path))
throw Object.assign(new Error(`EEXIST: ${path}`), { code: "EEXIST" });
fsState.files.set(path, "");
fsState.fds.set(3, path);
return 3;
}),
writeSync: vi.fn((fd: number, data: string) => {
const path = fsState.fds.get(fd) ?? "";
fsState.files.set(path, (fsState.files.get(path) ?? "") + data);
}),
closeSync: vi.fn(),
linkSync: vi.fn(),
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
}));
// The backfill warning is suppressed under a telemetry runtime override (a
@@ -828,3 +846,56 @@ describe("identity-persistence classification (sticky per anonymous id)", () =>
expect(getIdentityWriteOutcome()).toBe("ok");
});
});
describe("updateLocalModelConsent fails closed to the answer on disk", () => {
let readConfig: typeof import("./config.js").readConfig;
let updateLocalModelConsent: typeof import("./config.js").updateLocalModelConsent;
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
beforeEach(async () => {
fsState.files.clear();
vi.resetModules();
({ readConfig, updateLocalModelConsent, CONFIG_PATH } = await import("./config.js"));
});
const consentOnDisk = () =>
JSON.parse(fsState.files.get(CONFIG_PATH) ?? "{}").localEmbeddingEnabled;
it("returns the answer on disk, not the new one, when the write fails", async () => {
readConfig();
const fs = await import("node:fs");
vi.mocked(fs.renameSync).mockImplementationOnce(() => {
throw new Error("EACCES: permission denied");
});
expect(updateLocalModelConsent(() => true)).toBeUndefined();
expect(consentOnDisk()).toBeUndefined();
});
it("returns the no on disk and writes nothing when another process keeps the lock", () => {
fsState.files.set(
CONFIG_PATH,
JSON.stringify({ ...readConfig(), localEmbeddingEnabled: false }),
);
fsState.files.set(`${CONFIG_PATH}.lock`, "");
let now = Date.now();
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 4_000));
try {
expect(updateLocalModelConsent(() => true)).toBe(false);
} finally {
clock.mockRestore();
}
expect(consentOnDisk()).toBe(false);
});
it("does not wait on its own lock when the read inside it has to write", async () => {
const fs = await import("node:fs");
vi.mocked(fs.openSync).mockClear();
expect(updateLocalModelConsent(() => true)).toBe(true);
const lockTakes = vi
.mocked(fs.openSync)
.mock.calls.filter(([path, flag]) => flag === "wx" && path === `${CONFIG_PATH}.lock`);
expect(lockTakes).toHaveLength(1);
});
});
+82 -4
View File
@@ -1,9 +1,12 @@
import * as fs from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { telemetryRuntimeOverride } from "./policy.js";
import { isAttendedTerminal } from "../utils/attendedTerminal.js";
import { withFileLock } from "../media-use/lib/config-lock.mjs";
// ---------------------------------------------------------------------------
// Config directory: ~/.hyperframes/
@@ -654,9 +657,7 @@ function passthroughFields(parsed: Partial<HyperframesConfig>): Partial<Hyperfra
skillsOutdatedCount: parsed.skillsOutdatedCount,
skillsMissingCount: parsed.skillsMissingCount,
skillsRemovedCount: parsed.skillsRemovedCount,
// Consent, so it survives the run that recorded it. Undefined stays
// undefined on purpose: it means never asked, which is not the same as no.
localEmbeddingEnabled: parsed.localEmbeddingEnabled,
localEmbeddingEnabled: consentFrom(parsed.localEmbeddingEnabled),
};
}
@@ -760,7 +761,7 @@ export function readConfig(): HyperframesConfig {
// Recover through the same mint path as a missing file — so a tripped
// breaker survives config corruption too — but fail closed for the
// privacy control: recovery must never silently turn telemetry back on.
const config = { ...mintConfig(), telemetryEnabled: false };
const config = { ...mintConfig(), telemetryEnabled: false, localEmbeddingEnabled: false };
const write = writeConfigWithResult(config);
classifyIdentity(
config.anonymousId,
@@ -815,6 +816,83 @@ export type ConfigWriteResult =
* must distinguish a durable preference write from a best-effort update.
*/
export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteResult {
try {
// The consent answer changes only through updateLocalModelConsent; a copy read earlier must not undo one.
return withConfigLock(() =>
persistConfig({ ...config, localEmbeddingEnabled: localModelConsentOnDisk() }),
);
} catch (error) {
warnSettingsLockedOnce(error);
return { ok: false, error: normalizeErrorMessage(error) };
}
}
let warnedSettingsLocked = false;
/** One line per process while a leftover lock blocks settings; never where a program reads the output. */
function warnSettingsLockedOnce(error: unknown): void {
if (warnedSettingsLocked || (error as NodeJS.ErrnoException)?.code !== "HF_SETTINGS_LOCKED")
return;
warnedSettingsLocked = true;
if (
!isAttendedTerminal() ||
process.argv.some((arg) => arg === "--json" || arg.startsWith("--json="))
)
return;
console.error(`${(error as Error).message} (see \`hyperframes doctor\`).`);
}
export function updateLocalModelConsent(
decide: (onDisk: boolean | undefined) => boolean | undefined,
): boolean | undefined {
try {
return withConfigLock(() => {
const onDisk = localModelConsentOnDisk();
const next = decide(onDisk);
if (next === onDisk) {
if (cachedConfig) cachedConfig.localEmbeddingEnabled = onDisk;
return next;
}
const written = persistConfig({ ...readConfigFresh(), localEmbeddingEnabled: next });
return written.ok ? next : localModelConsentOnDisk();
});
} catch (error) {
warnSettingsLockedOnce(error);
return localModelConsentOnDisk();
}
}
/** Absent means never asked; anything present but not `true` counts as a no. */
function consentFrom(value: unknown): boolean | undefined {
return value === undefined ? undefined : value === true;
}
function localModelConsentOnDisk(): boolean | undefined {
try {
return consentFrom(JSON.parse(readFileSync(CONFIG_FILE, "utf-8")).localEmbeddingEnabled);
} catch (error) {
// A settings file that exists but cannot be read may hold a no; only a missing one was never asked.
return (error as NodeJS.ErrnoException).code === "ENOENT" ? undefined : false;
}
}
let holdingConfigLock = false;
/** One process at a time between reading config.json and replacing it; re-entrant within this one. */
function withConfigLock<T>(task: () => T): T {
if (holdingConfigLock) return task();
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
return withFileLock(`${CONFIG_FILE}.lock`, fs, () => {
holdingConfigLock = true;
try {
return task();
} finally {
holdingConfigLock = false;
}
});
}
function persistConfig(config: HyperframesConfig): ConfigWriteResult {
try {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`;
@@ -0,0 +1,9 @@
export function envFlagEnabled(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
}
/** A person can answer here: stdin and stdout are both terminals and this is not CI. */
export function isAttendedTerminal(): boolean {
return !envFlagEnabled("CI") && process.stdin.isTTY === true && process.stdout.isTTY === true;
}
+89 -1
View File
@@ -1,4 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// fallow-ignore-file code-duplication
import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* These tests exercise the policy — when a background install should or
@@ -20,6 +25,7 @@ function setupMocks(opts: {
devMode?: boolean;
config?: ConfigShape;
env?: Record<string, string | undefined>;
writeFails?: boolean;
}): {
writeSpy: ReturnType<typeof vi.fn>;
spawnSpy: ReturnType<typeof vi.fn>;
@@ -29,12 +35,14 @@ function setupMocks(opts: {
const config = { ...(opts.config ?? {}) };
const writeSpy = vi.fn((next: ConfigShape) => {
if (opts.writeFails) return false;
Object.assign(config, next);
// writeConfig is given a full replacement — mirror that by pruning keys
// that disappeared.
for (const k of Object.keys(config)) {
if (!(k in next)) delete (config as Record<string, unknown>)[k];
}
return true;
});
vi.doMock("../telemetry/config.js", () => ({
@@ -116,6 +124,17 @@ describe("scheduleBackgroundInstall", () => {
expect(config.pendingUpdate?.command).toBe("npm install -g hyperframes@0.4.4");
});
it("launches nothing when the pending install cannot be recorded", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
writeFails: true,
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("does NOT schedule across a major-version jump", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@1.0.0" },
@@ -306,4 +325,73 @@ describe("scheduleBackgroundInstall", () => {
});
}
});
describe("the detached child's settings write", () => {
const home = mkdtempSync(join(tmpdir(), "hf-auto-update-"));
const dir = join(home, ".hyperframes");
const cfg = join(dir, "config.json");
beforeEach(() => mkdirSync(dir, { recursive: true }));
afterEach(() => {
rmSync(home, { recursive: true, force: true });
vi.doUnmock("node:os");
});
/** The script the CLI would spawn, pointed at a throwaway home and an install that does nothing. */
async function runChild(): Promise<void> {
const { spawnSpy } = setupMocks({ installer: { kind: "npm", command: "npm i" } });
vi.doMock("node:os", async () => ({
...(await vi.importActual<typeof import("node:os")>("node:os")),
homedir: () => home,
}));
vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({ kind: "npm", installCommand: () => "npm i" }),
installInvocation: () => ({ bin: process.execPath, args: ["-e", ""] }),
}));
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(true);
const args = spawnSpy.mock.calls[0]?.[1] as unknown as string[];
expect(args[1]).toContain(JSON.stringify(cfg));
execFileSync(process.execPath, args);
}
it("waits for another process's locked write, then keeps the no it saved", async () => {
const script = `
const fs = require("fs");
const dir = process.argv[1];
fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
process.stdout.write("locked\\n");
setTimeout(() => {
fs.writeFileSync(dir + "/other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
fs.renameSync(dir + "/other.tmp", dir + "/config.json");
fs.rmSync(dir + "/config.json.lock");
}, 1000);`;
const holder = spawn(process.execPath, ["-e", script, dir]);
const exited = new Promise((done) => holder.on("exit", done));
await new Promise((locked) => holder.stdout.once("data", locked));
await runChild();
await exited;
const saved = JSON.parse(readFileSync(cfg, "utf-8"));
expect([saved.localEmbeddingEnabled, saved.completedUpdate?.version]).toEqual([
false,
"0.4.4",
]);
});
it("leaves a settings file it cannot read untouched", async () => {
writeFileSync(cfg, "{not json");
await runChild();
expect(readFileSync(cfg, "utf-8")).toBe("{not json");
});
it("starts a settings file when there is none", async () => {
await runChild();
expect(JSON.parse(readFileSync(cfg, "utf-8")).completedUpdate?.version).toBe("0.4.4");
});
});
});
+11 -6
View File
@@ -28,6 +28,7 @@ import { appendFileSync, mkdirSync, openSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { compareVersions } from "compare-versions";
import { withFileLock } from "../media-use/lib/config-lock.mjs";
import { readConfig, writeConfig } from "../telemetry/config.js";
import { isDevMode } from "./env.js";
import {
@@ -90,20 +91,24 @@ function launchDetachedInstall(
// 1. Runs the install via execFile (bin + argv, NO shell) so a version
// string can never be re-interpreted as shell syntax — structural
// symmetry with the interactive `runDetectedInstall` path.
// 2. Rewrites the config file with completedUpdate, clears pendingUpdate.
// 2. Under the shared settings lock (withFileLock, embedded as source), rewrites the config with
// completedUpdate and clears pendingUpdate; skips on lock timeout or an unreadable file.
// We run it through `node -e` so we don't need to ship a separate file. Bin
// and args are embedded as JSON literals (data, not code).
const nodeScript = `
const { execFile } = require("node:child_process");
const { readFileSync, renameSync, writeFileSync } = require("node:fs");
const fs = require("node:fs");
const { readFileSync, renameSync, writeFileSync } = fs;
const CFG = ${JSON.stringify(configFile)};
const TMP = \`\${CFG}.tmp\`;
const VERSION = ${JSON.stringify(version)};
const BIN = ${JSON.stringify(invocation.bin)};
const ARGS = ${JSON.stringify(invocation.args)};
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
const withFileLock = ${withFileLock.toString()};
const withLock = (task) => { try { withFileLock(\`\${CFG}.lock\`, fs, task); } catch (e) {} };
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => withLock(() => {
let cfg = {};
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) {}
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) { if (e.code !== "ENOENT") return; }
cfg.completedUpdate = {
version: VERSION,
ok: !err,
@@ -115,7 +120,7 @@ function launchDetachedInstall(
writeFileSync(TMP, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 });
renameSync(TMP, CFG);
} catch (e) {}
});
}));
`;
const out = openSync(LOG_FILE, "a", 0o600);
@@ -187,7 +192,7 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
command: installCommand,
startedAt: new Date().toISOString(),
};
writeConfig(config);
if (!writeConfig(config)) return false;
try {
launchDetachedInstall(invocation, installCommand, latestVersion);
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { spawnSync } from "node:child_process";
import {
existsSync,
@@ -18,6 +19,7 @@ import {
install,
installedOptionalPackageVersion,
loadBesideCli,
loadInstalledOptionalPackage,
loadOptionalPackage,
optionalPackageDir,
type OptionalPackageDeps,
@@ -40,6 +42,15 @@ function fakeDeps(overrides: Partial<OptionalPackageDeps> = {}) {
return { deps, install, log, installed };
}
describe("loadInstalledOptionalPackage", () => {
it("returns null without installing when the package is not on this machine", () => {
const { deps, install } = fakeDeps();
expect(loadInstalledOptionalPackage("onnxruntime-node", deps)).toBeNull();
expect(install).not.toHaveBeenCalled();
});
});
describe("loadOptionalPackage", () => {
it("installs on first use with one plain line, then loads from the cache without installing again", async () => {
const { deps, install, log } = fakeDeps();
+11 -4
View File
@@ -59,6 +59,15 @@ export function installedOptionalPackageVersion(
.version;
}
export function loadInstalledOptionalPackage<N extends OptionalPackage>(
name: N,
deps: OptionalPackageDeps = defaultDeps,
): OptionalPackageModules[N] | null {
const found =
deps.loadBesideCli(name) ?? deps.loadInstalled(optionalPackageDir(name, deps.cacheDir), name);
return found as OptionalPackageModules[N] | null;
}
/**
* Load an optional package, installing it once on first use. No prompt: agents run headless.
* Throws an error that names the manual command when the install cannot complete.
@@ -68,11 +77,9 @@ export async function loadOptionalPackage<N extends OptionalPackage>(
feature: string,
deps: OptionalPackageDeps = defaultDeps,
): Promise<OptionalPackageModules[N]> {
const beside = deps.loadBesideCli(name);
if (beside !== null) return beside as OptionalPackageModules[N];
const present = loadInstalledOptionalPackage(name, deps);
if (present !== null) return present;
const dir = optionalPackageDir(name, deps.cacheDir);
const installed = deps.loadInstalled(dir, name);
if (installed !== null) return installed as OptionalPackageModules[N];
const version = OPTIONAL_PACKAGES[name];
deps.log(`installing ${name} for ${feature}, once`);
@@ -9,6 +9,7 @@ const skillLibDir = resolve("skills/media-use/scripts/lib");
const cliLibDir = resolve("packages/cli/src/media-use/lib");
export const MEDIA_USE_COPY_NAMES = [
"config-lock.mjs",
"cutlist.mjs",
"duck.mjs",
"error-diffusion.mjs",
+2 -2
View File
@@ -54,8 +54,8 @@
"files": 1
},
"media-use": {
"hash": "7915441c22070c00",
"files": 99
"hash": "7b96502739a63f48",
"files": 100
},
"motion-graphics": {
"hash": "ab9d2e9c205ce1b1",
@@ -0,0 +1,45 @@
// The one lock on ~/.hyperframes/config.json, for the CLI, its auto-update step and media-use. The auto-update step
// embeds this function's source, so it must use only its arguments and globals.
export function withFileLock(lockPath, fs, task) {
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const started = Date.now();
for (;;) {
let fd;
try {
fd = fs.openSync(lockPath, "wx");
} catch (error) {
if (error.code !== "EEXIST") throw error;
// Never taken over: a lock older than any hold was left by a process that stopped, and a person removes it.
let leftover = false;
try {
leftover = Date.now() - fs.statSync(lockPath).mtimeMs > 5000;
} catch {}
if (leftover || Date.now() - started > 10000)
throw Object.assign(
new Error(
`Settings are locked by another hyperframes process. If none is running, delete ${lockPath}`,
),
{ code: "HF_SETTINGS_LOCKED" },
);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
continue;
}
try {
fs.writeSync(fd, token);
} catch (error) {
fs.rmSync(lockPath, { force: true });
throw error;
} finally {
fs.closeSync(fd);
}
break;
}
try {
return task();
} finally {
// No code removes another's lock, so release needs no check (only a person deleting a live one defeats it).
try {
fs.rmSync(lockPath);
} catch {}
}
}
+24 -8
View File
@@ -2,9 +2,11 @@
// never carry intent text, file names, or paths.
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import * as fs from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { withFileLock } from "./config-lock.mjs";
import { globalMediaDir } from "./media-home.mjs";
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -62,10 +64,24 @@ function readSharedConfig() {
return {};
}
function writeSharedConfig(config) {
const dir = join(homedir(), ".hyperframes");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
// Best effort: skipped when the lock cannot be taken or the file cannot be read, never written unlocked.
function updateSharedConfig(patch) {
const file = sharedConfigPath();
mkdirSync(dirname(file), { recursive: true });
try {
withFileLock(`${file}.lock`, fs, () => {
let config = {};
try {
config = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
if (error.code !== "ENOENT") return;
}
if (!config || typeof config !== "object" || Array.isArray(config)) return;
const tmp = `${file}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify({ ...config, ...patch }, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, file);
});
} catch {}
}
// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
@@ -93,7 +109,7 @@ function anonymousId() {
return config.anonymousId.trim();
}
const id = legacyMediaAnonId() || randomUUID();
writeSharedConfig({ ...config, anonymousId: id });
updateSharedConfig({ anonymousId: id });
return id;
} catch {
return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
@@ -133,7 +149,7 @@ function showTelemetryNotice() {
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
].join("\n"),
);
writeSharedConfig({ ...config, telemetryNoticeShown: true });
updateSharedConfig({ telemetryNoticeShown: true });
} catch {
// notice is best-effort; never surface into the command
}