Route routine core, engine, and dashboard diagnostics through debug-gated shared loggers. - Demote steady-state diagnostic sites while preserving warnings and errors for actionable failures. - Add cross-package severity contracts and manifest coverage for demoted log sites. - Document logging severity guidance and add a patch changeset. Files changed: .changeset/fn-8603-log-severity.md | 7 ++ docs/diagnostics.md | 20 ++++-- .../__tests__/log-severity-spam-contract.test.ts | 71 ++++++++++++++++++ packages/core/src/activity-analytics.ts | 5 +- packages/core/src/ai-summarize.ts | 61 +++++++--------- packages/core/src/async-mission-store.ts | 5 +- packages/core/src/async-secrets-store.ts | 7 +- packages/core/src/central-core.ts | 17 ++--- packages/core/src/docker-provisioning.ts | 13 ++-- packages/core/src/index.ts | 1 + packages/core/src/master-key.ts | 9 ++- packages/core/src/memory-compaction.ts | 29 ++++---- packages/core/src/memory-insights.ts | 7 +- packages/core/src/migration-orchestrator.ts | 7 +- packages/core/src/mission-store.ts | 5 +- packages/core/src/node-discovery.ts | 7 +- packages/core/src/notification/dispatcher.ts | 9 ++- .../core/src/plugins/bundled-plugin-install.ts | 11 +-- packages/core/src/reflection-store.ts | 5 +- packages/core/src/secrets-store.ts | 7 +- packages/core/src/task-store/agent-logs.ts | 21 +++--- packages/core/src/task-store/async-events.ts | 5 +- packages/core/src/task-store/async-maintenance.ts | 7 +- packages/core/src/task-store/comments-ops.ts | 7 +- packages/core/src/task-store/task-mutation-ops.ts | 11 +-- packages/core/src/task-store/workflow-integrity.ts | 9 ++- packages/core/src/types/merge-policy.ts | 5 +- packages/core/src/usage-events.ts | 5 +- .../__tests__/log-severity-spam-contract.test.ts | 48 +++++++++++++ packages/dashboard/src/ai-refine.ts | 5 +- packages/dashboard/src/ai-session-diagnostics.ts | 10 +-- packages/dashboard/src/chat.ts | 8 ++- packages/dashboard/src/devserver-manager.ts | 9 ++- packages/dashboard/src/file-service.ts | 5 +- packages/dashboard/src/github-tracking-comments.ts | 7 +- .../dashboard/src/github-tracking-reconciler.ts | 5 +- packages/dashboard/src/github-tracking-state.ts | 5 +- packages/dashboard/src/gitlab-lifecycle.ts | 5 +- packages/dashboard/src/insights-routes.ts | 9 ++- packages/dashboard/src/issue-image-attachments.ts | 5 +- packages/dashboard/src/knowledge-index.ts | 5 +- packages/dashboard/src/plugin-routes.ts | 7 +- packages/dashboard/src/routes/board-workflows.ts | 5 +- packages/dashboard/src/routes/context.ts | 5 +- .../dashboard/src/routes/register-auth-routes.ts | 13 ++-- .../routes/register-docker-provisioning-routes.ts | 7 +- .../dashboard/src/routes/register-git-github.ts | 21 +++--- packages/dashboard/src/routes/register-gitlab.ts | 7 +- .../src/routes/register-session-diff-routes.ts | 9 ++- .../src/routes/register-settings-memory-routes.ts | 7 +- .../src/routes/register-setup-activity-routes.ts | 7 +- .../dashboard/src/routes/register-signal-routes.ts | 5 +- .../src/routes/register-task-workflow-routes.ts | 11 +-- packages/dashboard/src/runtime-logger.ts | 11 +-- packages/dashboard/src/server.ts | 7 +- packages/dashboard/src/sse.ts | 8 ++- packages/dashboard/src/terminal-service.ts | 34 ++++----- packages/dashboard/src/view-chunk-manifest.ts | 5 +- .../engine/src/__tests__/log-severity-manifest.ts | 83 ++++++++++++++++++++++ .../__tests__/log-severity-spam-contract.test.ts | 40 ++++++++++- .../src/__tests__/logger-debug-gating.test.ts | 7 +- packages/engine/src/goal-anchoring-audit.ts | 5 +- packages/engine/src/plugin-runner.ts | 44 ++++++------ packages/engine/src/pty-native.ts | 9 ++- .../engine/src/runtimes/child-process-worker.ts | 4 +- packages/engine/src/self-healing.ts | 12 ++-- packages/engine/src/worktree-hooks.ts | 10 ++- 67 files changed, 632 insertions(+), 250 deletions(-) Fusion-Task-Id: FN-8603 Fusion-Task-Lineage: 53901db6-1af2-4bd7-b5ea-49507e048ef2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
248 lines
7.2 KiB
TypeScript
248 lines
7.2 KiB
TypeScript
import { createLogger } from "./logger.js";
|
|
|
|
const severityAuditLog = createLogger("core-master-key");
|
|
import { randomBytes } from "node:crypto";
|
|
import * as fs from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { resolveGlobalDir } from "./global-settings.js";
|
|
|
|
export const MASTER_KEY_KEYCHAIN_SERVICE = "fusion";
|
|
export const MASTER_KEY_KEYCHAIN_ACCOUNT = "master-key";
|
|
export const MASTER_KEY_FILENAME = "master.key";
|
|
|
|
export type KeytarLike = {
|
|
getPassword(service: string, account: string): Promise<string | null>;
|
|
setPassword(service: string, account: string, password: string): Promise<void>;
|
|
deletePassword(service: string, account: string): Promise<boolean>;
|
|
};
|
|
|
|
export class MasterKeyPermissionError extends Error {
|
|
constructor(message = "master key file permissions must be 0600") {
|
|
super(message);
|
|
this.name = "MasterKeyPermissionError";
|
|
}
|
|
}
|
|
|
|
export class MasterKeyCorruptError extends Error {
|
|
constructor(public readonly backend: "keychain" | "file", message: string) {
|
|
super(message);
|
|
this.name = "MasterKeyCorruptError";
|
|
}
|
|
}
|
|
|
|
type FsLike = Pick<typeof fs, "mkdir" | "open" | "chmod" | "stat" | "readFile">;
|
|
|
|
export class MasterKeyManager {
|
|
private readonly globalDir: string;
|
|
private readonly filePath: string;
|
|
private readonly injectedKeytar?: KeytarLike;
|
|
private readonly fsModule: FsLike;
|
|
|
|
constructor(options?: { globalDir?: string; keytarModule?: KeytarLike; fsModule?: FsLike }) {
|
|
this.globalDir = resolveGlobalDir(options?.globalDir);
|
|
this.filePath = join(this.globalDir, MASTER_KEY_FILENAME);
|
|
this.injectedKeytar = options?.keytarModule;
|
|
this.fsModule = options?.fsModule ?? fs;
|
|
}
|
|
|
|
async getOrCreateKey(): Promise<Buffer> {
|
|
const keychainKey = await this.readKeychainKey();
|
|
if (keychainKey) {
|
|
return keychainKey;
|
|
}
|
|
|
|
const fileKey = await this.readFileKey();
|
|
if (fileKey) {
|
|
return fileKey;
|
|
}
|
|
|
|
const generated = randomBytes(32);
|
|
const persisted = await this.persistNewKeyWithRaceHandling(generated);
|
|
console.info(`master key created (${persisted.backend})`);
|
|
return persisted.key;
|
|
}
|
|
|
|
async rotateKey(): Promise<Buffer> {
|
|
const next = randomBytes(32);
|
|
const backend = await this.getBackend();
|
|
|
|
if (backend === "file") {
|
|
await this.writeFileKey(next, { overwrite: true });
|
|
console.info("master key rotated (file)");
|
|
return next;
|
|
}
|
|
|
|
if (backend === "keychain") {
|
|
const wroteKeychain = await this.writeKeychainKey(next);
|
|
if (!wroteKeychain) {
|
|
throw new Error("unable to rotate master key in active keychain backend");
|
|
}
|
|
console.info("master key rotated (keychain)");
|
|
return next;
|
|
}
|
|
|
|
const persisted = await this.persistNewKeyWithRaceHandling(next);
|
|
console.info(`master key rotated (${persisted.backend})`);
|
|
return persisted.key;
|
|
}
|
|
|
|
async getBackend(): Promise<"keychain" | "file" | "missing"> {
|
|
const keychainKey = await this.readKeychainKey();
|
|
if (keychainKey) {
|
|
return "keychain";
|
|
}
|
|
|
|
const fileKey = await this.readFileKey();
|
|
if (fileKey) {
|
|
return "file";
|
|
}
|
|
|
|
return "missing";
|
|
}
|
|
|
|
private async persistNewKeyWithRaceHandling(
|
|
generated: Buffer,
|
|
): Promise<{ key: Buffer; backend: "keychain" | "file" }> {
|
|
const keytar = await this.loadKeytar();
|
|
if (keytar) {
|
|
const raced = await this.readKeychainKey();
|
|
if (raced) {
|
|
return { key: raced, backend: "keychain" };
|
|
}
|
|
try {
|
|
await keytar.setPassword(
|
|
MASTER_KEY_KEYCHAIN_SERVICE,
|
|
MASTER_KEY_KEYCHAIN_ACCOUNT,
|
|
generated.toString("base64"),
|
|
);
|
|
return { key: generated, backend: "keychain" };
|
|
} catch {
|
|
const afterRace = await this.readKeychainKey();
|
|
if (afterRace) {
|
|
return { key: afterRace, backend: "keychain" };
|
|
}
|
|
severityAuditLog.warn("master key keychain unavailable; using file backend");
|
|
}
|
|
}
|
|
|
|
const racedFile = await this.readFileKey();
|
|
if (racedFile) {
|
|
return { key: racedFile, backend: "file" };
|
|
}
|
|
|
|
try {
|
|
await this.writeFileKey(generated, { overwrite: false });
|
|
return { key: generated, backend: "file" };
|
|
} catch (error) {
|
|
if (error instanceof MasterKeyPermissionError) {
|
|
throw error;
|
|
}
|
|
const afterRace = await this.readFileKey();
|
|
if (afterRace) {
|
|
return { key: afterRace, backend: "file" };
|
|
}
|
|
throw new Error("failed to persist master key", { cause: error });
|
|
}
|
|
}
|
|
|
|
private async readKeychainKey(): Promise<Buffer | null> {
|
|
const keytar = await this.loadKeytar();
|
|
if (!keytar) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const value = await keytar.getPassword(
|
|
MASTER_KEY_KEYCHAIN_SERVICE,
|
|
MASTER_KEY_KEYCHAIN_ACCOUNT,
|
|
);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const decoded = Buffer.from(value, "base64");
|
|
if (decoded.length !== 32 || decoded.toString("base64") !== value) {
|
|
throw new MasterKeyCorruptError("keychain", "keychain master key is corrupt");
|
|
}
|
|
return decoded;
|
|
} catch (error) {
|
|
if (error instanceof MasterKeyCorruptError) {
|
|
throw error;
|
|
}
|
|
severityAuditLog.warn("master key keychain unavailable; using file backend");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async readFileKey(): Promise<Buffer | null> {
|
|
try {
|
|
const value = await this.fsModule.readFile(this.filePath);
|
|
if (value.length !== 32) {
|
|
throw new MasterKeyCorruptError("file", "file master key is corrupt");
|
|
}
|
|
return value;
|
|
} catch (error) {
|
|
if (error instanceof MasterKeyCorruptError) {
|
|
throw error;
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async writeFileKey(value: Buffer, options: { overwrite: boolean }): Promise<void> {
|
|
await this.fsModule.mkdir(this.globalDir, { recursive: true });
|
|
const handle = await this.fsModule.open(this.filePath, options.overwrite ? "w" : "wx");
|
|
try {
|
|
await handle.writeFile(value);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
await this.fsModule.chmod(this.filePath, 0o600);
|
|
const fileStat = await this.fsModule.stat(this.filePath);
|
|
if ((fileStat.mode & 0o777) !== 0o600) {
|
|
throw new MasterKeyPermissionError();
|
|
}
|
|
}
|
|
|
|
private async writeKeychainKey(value: Buffer): Promise<boolean> {
|
|
const keytar = await this.loadKeytar();
|
|
if (!keytar) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await keytar.setPassword(
|
|
MASTER_KEY_KEYCHAIN_SERVICE,
|
|
MASTER_KEY_KEYCHAIN_ACCOUNT,
|
|
value.toString("base64"),
|
|
);
|
|
return true;
|
|
} catch {
|
|
severityAuditLog.warn("master key keychain unavailable; using file backend");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async loadKeytar(): Promise<KeytarLike | null> {
|
|
if (this.injectedKeytar) {
|
|
return this.injectedKeytar;
|
|
}
|
|
|
|
if (process.env.FUSION_MASTER_KEY_DISABLE_KEYCHAIN === "1") {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const require = createRequire(import.meta.url);
|
|
const modName = `key${"tar"}`;
|
|
const module = require(modName) as { default?: KeytarLike } & KeytarLike;
|
|
return module.default ?? module;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|