mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Guard daemon modules against synchronous Node IO (#7991)
* chore(server): guard against synchronous Node IO in daemon modules * chore(server): retire unused sync IO exceptions automatically * chore(server): remove the attachment IO exception after async materialization
This commit is contained in:
+145
@@ -85,6 +85,151 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
|
||||
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
|
||||
{
|
||||
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
|
||||
"rules": {
|
||||
"paseo/no-sync-io": [
|
||||
"error",
|
||||
{
|
||||
"allow": {
|
||||
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
|
||||
"executable-resolution/executable-resolution.ts": ["existsSync"],
|
||||
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
|
||||
"executable-resolution/windows.ts": ["readdirSync"],
|
||||
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
|
||||
"server/agent/providers/claude/agent.ts": ["existsSync"],
|
||||
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
|
||||
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
|
||||
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
|
||||
"server/agent/providers/claude/subagents/workflow-output.ts": [
|
||||
"closeSync",
|
||||
"openSync",
|
||||
"readSync"
|
||||
],
|
||||
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
|
||||
"server/agent/providers/omp/agent.ts": ["existsSync"],
|
||||
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
|
||||
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
|
||||
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
|
||||
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
|
||||
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
|
||||
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
|
||||
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
|
||||
"server/agent/providers/pi/agent.ts": [
|
||||
"existsSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
|
||||
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
|
||||
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
|
||||
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
|
||||
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
|
||||
"server/config.ts": ["existsSync"],
|
||||
// Startup identity: load or create the daemon keypair before accepting connections.
|
||||
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
|
||||
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
|
||||
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
|
||||
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
|
||||
"server/host-name.ts": ["execFileSync"],
|
||||
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
|
||||
"server/hub/relationship-controller.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
|
||||
"server/local-credential.ts": ["readFileSync"],
|
||||
// Startup logger construction: create the log directory before opening the logging destination.
|
||||
"server/logger.ts": ["mkdirSync"],
|
||||
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
|
||||
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
|
||||
// Module initialization: the package version is resolved once and cached at import.
|
||||
"server/package-version.ts": ["existsSync", "readFileSync"],
|
||||
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
|
||||
"server/persisted-config.ts": ["existsSync", "readFileSync"],
|
||||
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
|
||||
"server/pid-lock.ts": ["readFileSync"],
|
||||
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
|
||||
"server/plugins/builtin/index.ts": ["existsSync"],
|
||||
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
|
||||
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
|
||||
// Build child process: select the esbuild binary for the isolated plugin compiler.
|
||||
"server/plugins/compiler-esbuild.ts": ["existsSync"],
|
||||
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
|
||||
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
|
||||
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
|
||||
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
|
||||
"server/private-files.ts": [
|
||||
"chmodSync",
|
||||
"mkdirSync",
|
||||
"realpathSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
|
||||
"server/push/token-store.ts": ["existsSync", "readFileSync"],
|
||||
// Startup identity: load or create the server id before accepting connections.
|
||||
"server/server-id.ts": ["existsSync", "readFileSync"],
|
||||
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
|
||||
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
|
||||
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
|
||||
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
|
||||
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
|
||||
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
|
||||
// Speech child process: native addon discovery and patchelf run outside the daemon.
|
||||
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
|
||||
"existsSync",
|
||||
"spawnSync"
|
||||
],
|
||||
// Speech child process: model-file validation runs in the worker-backed local speech provider.
|
||||
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
|
||||
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
|
||||
"server/web-ui.ts": ["readFileSync", "statSync"],
|
||||
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
|
||||
"server/workspace-registry-bootstrap.ts": ["statSync"],
|
||||
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
|
||||
"terminal/agent-hooks/agent-hook-installer.ts": [
|
||||
"existsSync",
|
||||
"readFileSync",
|
||||
"rmSync"
|
||||
],
|
||||
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
|
||||
"terminal/terminal-ts-loader.mjs": ["existsSync"],
|
||||
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
|
||||
"terminal/terminal.ts": [
|
||||
"accessSync",
|
||||
"chmodSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"statSync"
|
||||
],
|
||||
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
|
||||
"utils/path.ts": ["realpathSync"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
|
||||
"packages/server/src/**/test-utils/**",
|
||||
"packages/server/src/server/daemon-e2e/**",
|
||||
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
|
||||
],
|
||||
"rules": { "paseo/no-sync-io": "off" }
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"plugins/*-provider/server/**/*.ts",
|
||||
|
||||
@@ -34,9 +34,130 @@ const noRawPressable = {
|
||||
},
|
||||
};
|
||||
|
||||
const SYNC_IO_MODULES = new Set(["fs", "node:fs", "child_process", "node:child_process"]);
|
||||
|
||||
function propertyName(node) {
|
||||
return node.type === "Identifier" ? node.name : node.value;
|
||||
}
|
||||
|
||||
function findDefinition(node, sourceCode) {
|
||||
let scope = sourceCode.getScope(node);
|
||||
while (scope && !scope.set.has(node.name)) scope = scope.upper;
|
||||
return scope?.set.get(node.name)?.defs[0];
|
||||
}
|
||||
|
||||
function isIoNamespaceImport(definition) {
|
||||
if (definition?.node.type === "TSImportEqualsDeclaration") {
|
||||
return SYNC_IO_MODULES.has(definition.node.moduleReference.expression?.value);
|
||||
}
|
||||
if (definition?.type !== "ImportBinding") return false;
|
||||
return (
|
||||
(definition.node.type !== "ImportSpecifier" ||
|
||||
propertyName(definition.node.imported) === "default") &&
|
||||
SYNC_IO_MODULES.has(definition.parent.source.value)
|
||||
);
|
||||
}
|
||||
|
||||
// Follow only immutable namespace aliases. Resolve bindings rather than spelling so a parameter
|
||||
// named `fs` is not mistaken for the imported namespace.
|
||||
function isIoNamespace(node, sourceCode, seen = new Set()) {
|
||||
if (!node || seen.has(node)) return false;
|
||||
seen.add(node);
|
||||
if (node.type === "AwaitExpression") return isIoNamespace(node.argument, sourceCode, seen);
|
||||
if (node.type === "ImportExpression") return SYNC_IO_MODULES.has(node.source.value);
|
||||
if (node.type === "CallExpression") {
|
||||
return (
|
||||
node.callee.type === "Identifier" &&
|
||||
node.callee.name === "require" &&
|
||||
SYNC_IO_MODULES.has(node.arguments[0]?.value)
|
||||
);
|
||||
}
|
||||
if (node.type !== "Identifier") return false;
|
||||
const definition = findDefinition(node, sourceCode);
|
||||
if (isIoNamespaceImport(definition)) return true;
|
||||
return (
|
||||
definition?.type === "Variable" &&
|
||||
definition.parent.kind === "const" &&
|
||||
definition.node.id.type === "Identifier" &&
|
||||
isIoNamespace(definition.node.init, sourceCode, seen)
|
||||
);
|
||||
}
|
||||
|
||||
const noSyncIo = {
|
||||
meta: {
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
allow: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename.replaceAll("\\", "/");
|
||||
const relativeFile = filename.split("/packages/server/src/")[1];
|
||||
const allowed = new Set(context.options[0]?.allow?.[relativeFile] ?? []);
|
||||
const unused = new Set(allowed);
|
||||
function check(node, name) {
|
||||
if (typeof name !== "string" || !name.endsWith("Sync")) return;
|
||||
if (allowed.has(name)) {
|
||||
unused.delete(name);
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message: `Synchronous Node IO (${name}) blocks the daemon event loop. Use an asynchronous capability at the owning module boundary.`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
"Program:exit"(node) {
|
||||
for (const name of unused) {
|
||||
context.report({ node, message: `Remove the unused sync IO exception for ${name}.` });
|
||||
}
|
||||
},
|
||||
ImportDeclaration(node) {
|
||||
if (node.importKind === "type" || !SYNC_IO_MODULES.has(node.source.value)) return;
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type") {
|
||||
check(specifier, propertyName(specifier.imported));
|
||||
}
|
||||
}
|
||||
},
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.exportKind === "type" || !SYNC_IO_MODULES.has(node.source?.value)) return;
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.exportKind !== "type") check(specifier, propertyName(specifier.local));
|
||||
}
|
||||
},
|
||||
MemberExpression(node) {
|
||||
if (node.computed && node.property.type !== "Literal") return;
|
||||
const name = propertyName(node.property);
|
||||
if (typeof name !== "string" || !name.endsWith("Sync")) return;
|
||||
if (isIoNamespace(node.object, context.sourceCode)) {
|
||||
check(node.property, name);
|
||||
}
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (node.id.type !== "ObjectPattern" || !isIoNamespace(node.init, context.sourceCode)) {
|
||||
return;
|
||||
}
|
||||
for (const property of node.id.properties) {
|
||||
if (property.type === "Property") check(property, propertyName(property.key));
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
meta: { name: "paseo" },
|
||||
rules: {
|
||||
"no-raw-pressable": noRawPressable,
|
||||
"no-sync-io": noSyncIo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
@@ -10,15 +10,21 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../
|
||||
const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint");
|
||||
const pluginPath = path.join(repoRoot, "scripts/oxlint/paseo-plugin.mjs");
|
||||
|
||||
function lint(source) {
|
||||
function lint(source, rule = "paseo/no-raw-pressable", options, filename = "probe.tsx") {
|
||||
const config = JSON.stringify({
|
||||
jsPlugins: [pluginPath],
|
||||
rules: { [rule]: options ? ["error", options] : "error" },
|
||||
});
|
||||
return lintWithConfig(source, config, filename);
|
||||
}
|
||||
|
||||
function lintWithConfig(source, config, filename) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-oxlint-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(dir, ".oxlintrc.json"),
|
||||
JSON.stringify({ jsPlugins: [pluginPath], rules: { "paseo/no-raw-pressable": "error" } }),
|
||||
);
|
||||
writeFileSync(path.join(dir, "probe.tsx"), source);
|
||||
const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", "probe.tsx"], {
|
||||
writeFileSync(path.join(dir, ".oxlintrc.json"), config);
|
||||
mkdirSync(path.dirname(path.join(dir, filename)), { recursive: true });
|
||||
writeFileSync(path.join(dir, filename), source);
|
||||
const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", filename], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -52,3 +58,95 @@ test("press primitives and pressable types are allowed", () => {
|
||||
);
|
||||
assert.equal(result.failed, false, result.output);
|
||||
});
|
||||
|
||||
for (const source of [
|
||||
'import { readFileSync as read } from "node:fs"; export { read };',
|
||||
'import { execSync } from "child_process"; export { execSync };',
|
||||
'import fs from "node:fs"; export const read = fs.readFileSync;',
|
||||
'import { default as fs } from "node:fs"; export const read = fs.readFileSync;',
|
||||
'import fs = require("node:fs"); export const read = fs.readFileSync;',
|
||||
'import * as fs from "fs"; export const read = fs["readFileSync"];',
|
||||
'import cp from "node:child_process"; export const run = cp.spawnSync;',
|
||||
'const { readFileSync: read } = require("node:fs"); export { read };',
|
||||
'const fs = require("fs"); export const read = fs.readFileSync;',
|
||||
'export const run = require("node:child_process").execFileSync;',
|
||||
'import fs from "node:fs"; const other = fs; export const read = other.statSync;',
|
||||
'export { readFileSync as read } from "node:fs";',
|
||||
'export const read = (await import("node:fs")).readFileSync;',
|
||||
]) {
|
||||
test(`sync IO is reported: ${source}`, () => {
|
||||
const result = lint(source, "paseo/no-sync-io");
|
||||
assert.equal(result.failed, true, result.output);
|
||||
assert.match(result.output, /Synchronous Node IO/);
|
||||
});
|
||||
}
|
||||
|
||||
test("async IO, types, unrelated methods and shadowed namespaces are allowed", () => {
|
||||
const result = lint(
|
||||
'import fs from "node:fs"; import { readFile } from "node:fs/promises";\n' +
|
||||
'import type { readFileSync } from "node:fs";\n' +
|
||||
"export const read = readFile; export const asyncRead = fs.promises.readFile;\n" +
|
||||
"export function unrelated(fs: { readFileSync(): void }) { fs.readFileSync(); }\n" +
|
||||
"export type Reader = typeof readFileSync;\n",
|
||||
"paseo/no-sync-io",
|
||||
);
|
||||
assert.equal(result.failed, false, result.output);
|
||||
});
|
||||
|
||||
test("sync IO exceptions allow only named APIs in the named server module", () => {
|
||||
const options = { allow: { "server/startup.ts": ["readFileSync"] } };
|
||||
const source = 'import { readFileSync } from "node:fs"; export { readFileSync };';
|
||||
const allowed = lint(
|
||||
source,
|
||||
"paseo/no-sync-io",
|
||||
options,
|
||||
"packages/server/src/server/startup.ts",
|
||||
);
|
||||
assert.equal(allowed.failed, false, allowed.output);
|
||||
const otherFile = lint(
|
||||
source,
|
||||
"paseo/no-sync-io",
|
||||
options,
|
||||
"packages/server/src/server/session.ts",
|
||||
);
|
||||
assert.equal(otherFile.failed, true, otherFile.output);
|
||||
assert.match(otherFile.output, /Synchronous Node IO/);
|
||||
const otherApi = lint(
|
||||
'import fs from "node:fs"; export const read = fs.statSync;',
|
||||
"paseo/no-sync-io",
|
||||
options,
|
||||
"packages/server/src/server/startup.ts",
|
||||
);
|
||||
assert.equal(otherApi.failed, true, otherApi.output);
|
||||
assert.match(otherApi.output, /Synchronous Node IO/);
|
||||
});
|
||||
|
||||
test("repository config guards request paths while allowing startup APIs and test fixtures", () => {
|
||||
const config = readFileSync(path.join(repoRoot, ".oxlintrc.json"), "utf8").replace(
|
||||
'"./scripts/oxlint/paseo-plugin.mjs"',
|
||||
JSON.stringify(pluginPath),
|
||||
);
|
||||
const source = 'import { existsSync } from "node:fs"; export const probe = existsSync;';
|
||||
const request = lintWithConfig(source, config, "packages/server/src/server/probe.ts");
|
||||
assert.equal(request.failed, true, request.output);
|
||||
assert.match(request.output, /Synchronous Node IO/);
|
||||
for (const filename of [
|
||||
"packages/server/src/server/config.ts",
|
||||
"packages/server/src/server/probe.test.ts",
|
||||
"packages/app/src/probe.ts",
|
||||
]) {
|
||||
const result = lintWithConfig(source, config, filename);
|
||||
assert.equal(result.failed, false, result.output);
|
||||
}
|
||||
});
|
||||
|
||||
test("sync IO exceptions must be removed when a module stops using the API", () => {
|
||||
const result = lint(
|
||||
'import { readFile } from "node:fs/promises"; export const read = readFile;',
|
||||
"paseo/no-sync-io",
|
||||
{ allow: { "server/startup.ts": ["readFileSync"] } },
|
||||
"packages/server/src/server/startup.ts",
|
||||
);
|
||||
assert.equal(result.failed, true, result.output);
|
||||
assert.match(result.output, /Remove the unused sync IO exception for readFileSync/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user