Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 3 additions & 39 deletions autofixer/server.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { spawn } from 'child_process';
import { readFile, writeFile, mkdir, access } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { join } from 'path';
// Dependency-light shared module (node builtins + pure arg builder only), so
// importing it from this standalone process doesn't pull in the AI toolkit.
// Lets the autofixer honor the user's configured CLI provider/model instead
Expand All @@ -23,9 +20,7 @@ import {
revertDiffFromLive,
runVerifyCommand,
} from './sandbox.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { execPm2, DATA_DIR, AUTOFIXER_DIR, INDEX_FILE, loadApps } from './shared.js';

// Prepend the guarded pm2 shim to this process's PATH as defense-in-depth. The
// fix agent runs in an isolated worktree with a sanitized env and (for claude)
Expand All @@ -35,34 +30,10 @@ const __dirname = dirname(__filename);
// preserves this guarded PATH into the agent's env.
Object.assign(process.env, agentGuardEnv());

// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows)
const require = createRequire(import.meta.url);
const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2');

/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */
function execPm2(pm2Args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`));
resolve({ stdout, stderr });
});
child.on('error', reject);
});
}

// Paths
const DATA_DIR = join(__dirname, '../data');
const APPS_FILE = join(DATA_DIR, 'apps.json');
// Paths not shared with ui.js
const PROVIDERS_FILE = join(DATA_DIR, 'providers.json');
const SETTINGS_FILE = join(DATA_DIR, 'settings.json');
const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer');
const SESSIONS_DIR = join(AUTOFIXER_DIR, 'sessions');
const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json');
// Disposable worktrees for isolated repair runs (gitignored under data/).
const WORKTREES_DIR = join(AUTOFIXER_DIR, 'worktrees');
// Bound the agent-proposed patch before it can reach the live checkout.
Expand All @@ -75,13 +46,6 @@ const CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes
let checkTimer = null;
let shuttingDown = false;

// Load apps from PortOS
async function loadApps() {
const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}');
const parsed = JSON.parse(data);
return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app }));
}

// Parse JSON, returning `fallback` on read OR parse failure. A corrupt config
// file (partial write, hand-edit) must not throw inside fixProcess — this runs
// in the autofixer's interval loop, outside any request lifecycle, where an
Expand Down
47 changes: 47 additions & 0 deletions autofixer/shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Plumbing shared by the autofixer's two PM2-managed processes — `server.js`
// (the repair loop) and `ui.js` (the dashboard). Kept package-local and
// dependency-light (node builtins only): PortOS's own `server/services/pm2.js`
// has an equivalent `execPm2`, but importing it here would drag the whole
// server dependency graph into a package whose package.json declares only
// express.
import { spawn } from 'child_process';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';

const __dirname = dirname(fileURLToPath(import.meta.url));

// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows)
const require = createRequire(import.meta.url);
export const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2');

/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */
export function execPm2(pm2Args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`));
resolve({ stdout, stderr });
});
child.on('error', reject);
});
}

// Paths. Resolved from THIS module's location (both consumers are siblings in
// `autofixer/`), so every process agrees on one `data/` directory.
export const DATA_DIR = join(__dirname, '../data');
export const APPS_FILE = join(DATA_DIR, 'apps.json');
export const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer');
export const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json');

// Load apps from PortOS
export async function loadApps() {
const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}');
const parsed = JSON.parse(data);
return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app }));
}
82 changes: 82 additions & 0 deletions autofixer/shared.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';

// loadApps() reads a fixed on-disk path, so the read is faked to keep the
// fallback assertions independent of whether this install has a data/apps.json.
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock('fs/promises', async (importOriginal) => ({
...(await importOriginal()),
readFile: (...args) => readFileMock(...args),
}));

// shared.js resolves the PM2 binary at import time, and Node walks node_modules
// upward from `autofixer/` — so it needs the ROOT install. CI installs only
// `server/node_modules` (`npm ci --prefix server`), which is never on that path,
// so skip there rather than fail: the autofixer only ever runs from a full
// `npm run install:all` checkout, which is where this suite has to hold.
const require = createRequire(import.meta.url);
const pm2Installed = (() => {
try {
require.resolve('pm2/package.json');
return true;
} catch {
return false;
}
})();
const describeShared = pm2Installed ? describe : describe.skip;
const shared = pm2Installed ? await import('./shared.js') : {};

const AUTOFIXER_SRC_DIR = dirname(fileURLToPath(import.meta.url));

describeShared('autofixer/shared — PM2 binary resolution', () => {
// server.js and ui.js both spawn `node <PM2_BIN>` rather than `pm2`, so a PM2
// package layout change would otherwise surface only at runtime, on the next
// repair attempt or dashboard restart.
it('resolves the JS entry point (not pm2.cmd) and it exists on disk', () => {
expect(shared.PM2_BIN.endsWith(join('bin', 'pm2'))).toBe(true);
expect(existsSync(shared.PM2_BIN)).toBe(true);
});
});

describeShared('autofixer/shared — data paths', () => {
// Both PM2 processes must agree on one data/ directory; resolving from this
// module's own location is what guarantees that. Spelled as a dirname climb
// rather than a '..' path literal so the repo-wide test-data isolation guard
// (server/lib/testDataIsolation.guards.test.js) doesn't read this string-only
// comparison as a suite that addresses the live data/ tree — nothing here
// touches the filesystem.
it('anchors every path to the package-sibling data/ directory', () => {
expect(shared.DATA_DIR).toBe(join(dirname(AUTOFIXER_SRC_DIR), 'data'));
expect(shared.APPS_FILE).toBe(join(shared.DATA_DIR, 'apps.json'));
expect(shared.AUTOFIXER_DIR).toBe(join(shared.DATA_DIR, 'autofixer'));
expect(shared.INDEX_FILE).toBe(join(shared.AUTOFIXER_DIR, 'index.json'));
});
});

describeShared('autofixer/shared — loadApps', () => {
beforeEach(() => {
readFileMock.mockReset();
});

it('returns [] when the apps file is missing', async () => {
readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
await expect(shared.loadApps()).resolves.toEqual([]);
});

it('returns [] when the apps file has no apps key', async () => {
readFileMock.mockResolvedValue('{}');
await expect(shared.loadApps()).resolves.toEqual([]);
});

it('flattens the apps map into records carrying their id', async () => {
readFileMock.mockResolvedValue(JSON.stringify({
apps: { 'example-app': { pm2ProcessNames: ['example-api'], repoPath: '/srv/example' } },
}));
await expect(shared.loadApps()).resolves.toEqual([
{ id: 'example-app', pm2ProcessNames: ['example-api'], repoPath: '/srv/example' },
]);
});
});
35 changes: 1 addition & 34 deletions autofixer/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,14 @@ import { spawn } from 'child_process';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { createTailscaleServers, watchCertReload } from '../lib/tailscale-https.js';
import { certPaths } from '../lib/certPaths.js';
import { createSidecarAuthGate } from '../lib/sidecarAuthGate.js';
import { PM2_BIN, execPm2, DATA_DIR, INDEX_FILE, loadApps } from './shared.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows)
const require = createRequire(import.meta.url);
const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2');

/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */
function execPm2(pm2Args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`));
resolve({ stdout, stderr });
});
child.on('error', reject);
});
}

const app = express();
const PORT = process.env.PORT || 5560;

Expand All @@ -39,19 +19,6 @@ const PORT = process.env.PORT || 5560;
const UI_TEMPLATE_FILE = join(__dirname, 'ui.template.html');
const UI_HTML = await readFile(UI_TEMPLATE_FILE, 'utf8');

// Paths
const DATA_DIR = join(__dirname, '../data');
const APPS_FILE = join(DATA_DIR, 'apps.json');
const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer');
const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json');

// Load apps from PortOS
async function loadApps() {
const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}');
const parsed = JSON.parse(data);
return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app }));
}

// Load autofixer history
async function loadHistory() {
const data = await readFile(INDEX_FILE, 'utf8').catch(() => '[]');
Expand Down