Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Restored bare `prime-agent --resume` opening the agents view and the `/resume [id|path]` slash command; bare commands open the agents view and an argument resumes that session in place.
- Fixed URLs not opening on click in fullscreen mode on terminals such as Ghostty; clicking a link in the transcript, dock, or overlays now opens it in the browser.
- Fixed ctrl+p ("Toggle agent message expansion") only toggling received agent messages; it now expands and collapses sent agent messages together with received ones.
- Fixed session and artifact storage accepting traversal IDs, symlinked paths, permissive modes, and unsafe snapshot files ([#1105](https://github.com/PrimeIntellect-ai/prime-agent/pull/1105)).

## [0.7.2] - 2026-08-11

Expand Down
9 changes: 9 additions & 0 deletions packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,3 +702,12 @@ MIT
- [Prime Agent AI](../ai): Core LLM toolkit
- [Prime Agent Core](../agent): Agent framework
- [Prime Agent TUI](../tui): Terminal UI components


## Platform support

Persistent continual-harness storage, `/refine`, and all security-hardened private persistence require POSIX filesystem semantics and Node `O_NOFOLLOW` support. Windows is not currently supported; unsupported filesystems fail closed rather than using an insecure no-follow fallback. On Windows, Prime Agent disables persistent harness reads and auto-refine and rejects `/refine` before model work; in-memory session features remain available.

### Filesystem race limitation

Private file helpers reject symlinks and non-regular final components. They cannot fully prevent a same-user concurrent replacement of an already-validated parent directory with only Node path APIs; complete dirfd `openat`/`renameat` protection requires a native layer.
20 changes: 5 additions & 15 deletions packages/coding-agent/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
import { spawnSync } from "child_process";
import { createHash } from "crypto";
import {
accessSync,
appendFileSync,
constants,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync,
} from "fs";
import { accessSync, constants, existsSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url";
import { shouldUseWindowsShell } from "./utils/child-process.js";
import { appendPrivateFile, ensurePrivateFile } from "./utils/private-files.js";

// =============================================================================
// Package Detection
Expand Down Expand Up @@ -593,17 +583,17 @@ const MAX_LOG_BYTES = 5 * 1024 * 1024;
*/
export function appendRotatingLog(logPath: string, message: string, maxBytes: number = MAX_LOG_BYTES): void {
Comment thread
sethkarten marked this conversation as resolved.
try {
mkdirSync(dirname(logPath), { recursive: true });
ensurePrivateFile(logPath);
try {
if (existsSync(logPath) && statSync(logPath).size > maxBytes) {
if (statSync(logPath).size > maxBytes) {
// Drop any prior .old first: renameSync fails on Windows if it exists.
rmSync(`${logPath}.old`, { force: true });
renameSync(logPath, `${logPath}.old`);
}
} catch {
// Keep appending rather than dropping the log on a rotation failure.
}
appendFileSync(logPath, `${message}\n`);
appendPrivateFile(logPath, `${message}\n`);
} catch {
// A read-only or missing log dir must never break the caller.
}
Expand Down
30 changes: 23 additions & 7 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import {
Expand Down Expand Up @@ -50,6 +50,7 @@ import {
} from "@earendil-works/pi-ai";
import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { ensurePrivateDirectory, writePrivateFileAtomic } from "../utils/private-files.js";
import { sleep } from "../utils/sleep.js";
import {
AGENT_MESSAGE_CUSTOM_TYPE,
Expand Down Expand Up @@ -196,11 +197,13 @@ import {
type AutoRefineReview,
appendGlobalRefinement,
applyRefinementProposal,
assertHarnessStateWritable,
getGlobalHarnessStateDir,
getLocalHarnessStateDir,
getRefinementHistory,
type HarnessState,
inferRefinementResultScope,
isPersistentHarnessStorageSupported,
loadGlobalRefinementHistory,
loadHarnessState,
mergeHarnessStates,
Expand All @@ -211,6 +214,7 @@ import {
type RefinementResult,
reviewAutoRefine,
saveHarnessState,
WINDOWS_HARNESS_PERSISTENCE_UNSUPPORTED_ERROR,
} from "./refinement/index.js";
import { resolveConfigValue } from "./resolve-config-value.js";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js";
Expand Down Expand Up @@ -7270,7 +7274,14 @@ export class AgentSession {
}

private _autoRefineAllowedForSession(): boolean {
return this._rlmDepth === 0 && this._localHarnessStateDir() !== undefined;
if (!isPersistentHarnessStorageSupported() || this._rlmDepth !== 0 || this._localHarnessStateDir() === undefined)
return false;
try {
assertHarnessStateWritable(loadHarnessState(this._localHarnessStateDir()!, "local"));
return true;
} catch {
return false;
}
}

private _cancelPostCompactionContinue(): void {
Expand Down Expand Up @@ -7676,6 +7687,11 @@ export class AgentSession {
} = {},
internal: { skipAbort?: boolean } = {},
): Promise<RefinementResult> {
if (!isPersistentHarnessStorageSupported()) {
throw new Error(WINDOWS_HARNESS_PERSISTENCE_UNSUPPORTED_ERROR);
}
const preflightDir = options.global ? getGlobalHarnessStateDir() : this._localHarnessStateDir();
if (preflightDir) assertHarnessStateWritable(loadHarnessState(preflightDir, options.global ? "global" : "local"));
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// Queued /refine executes from the session-input pump between turns;
// refine never aborts the agent (planning is backgrounded and the apply
// phase waits for quiescence), so skipAbort only asserts the pump's
Expand Down Expand Up @@ -8944,13 +8960,13 @@ export class AgentSession {
// does RLM work. The temp dir is created lazily in _createChildRlmSessionDir.
private _ensureRlmSessionDir(): string | undefined {
if (this._rlmSessionDir) {
mkdirSync(this._rlmSessionDir, { recursive: true });
ensurePrivateDirectory(this._rlmSessionDir);
return this._rlmSessionDir;
}

const sessionArtifactDir = this.sessionManager.getSessionArtifactDir();
if (sessionArtifactDir) {
mkdirSync(sessionArtifactDir, { recursive: true });
ensurePrivateDirectory(sessionArtifactDir);
this._rlmSessionDir = sessionArtifactDir;
return sessionArtifactDir;
}
Expand All @@ -8963,7 +8979,7 @@ export class AgentSession {
for (let i = 0; i < 100; i++) {
const childDir = join(parentDir, `sub-${randomUUID().slice(0, 8)}`);
try {
mkdirSync(childDir);
mkdirSync(childDir, { mode: 0o700 });
return childDir;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
Expand Down Expand Up @@ -11094,7 +11110,7 @@ export class AgentSession {

/** RLM session dir holding sub-* child sessions, without creating directories. */
private _rlmSessionDirForReading(): string | undefined {
return this._rlmSessionDir ?? this.sessionManager.getSessionArtifactDir();
return this._rlmSessionDir ?? this.sessionManager.getSessionArtifactDir({ create: false });
Comment thread
sethkarten marked this conversation as resolved.
}

private _contextWindowResolver(): ContextWindowResolver {
Expand Down Expand Up @@ -11200,7 +11216,7 @@ export class AgentSession {
prevId = entry.id;
}

writeFileSync(filePath, `${lines.join("\n")}\n`);
writePrivateFileAtomic(filePath, `${lines.join("\n")}\n`, { privateParent: false });
return filePath;
}

Expand Down
28 changes: 7 additions & 21 deletions packages/coding-agent/src/core/auth-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import {
type OAuthProviderId,
} from "@earendil-works/pi-ai";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
import { ensurePrivateFile, readPrivateFile, writePrivateFileAtomic } from "../utils/private-files.js";
import {
clearPrimeCliCredentials,
getPrimeCliConfigPath,
Expand Down Expand Up @@ -108,18 +108,8 @@ export interface AuthStorageBackend {
export class FileAuthStorageBackend implements AuthStorageBackend {
constructor(private authPath: string = join(getAgentDir(), "auth.json")) {}

private ensureParentDir(): void {
const dir = dirname(this.authPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}

private ensureFileExists(): void {
if (!existsSync(this.authPath)) {
writeFileSync(this.authPath, "{}", "utf-8");
chmodSync(this.authPath, 0o600);
}
ensurePrivateFile(this.authPath, "{}");
Comment thread
sethkarten marked this conversation as resolved.
}

private acquireLockSyncWithRetry(path: string): () => void {
Expand Down Expand Up @@ -150,17 +140,15 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
}

withLock<T>(fn: (current: string | undefined) => LockResult<T>): T {
this.ensureParentDir();
this.ensureFileExists();

let release: (() => void) | undefined;
try {
release = this.acquireLockSyncWithRetry(this.authPath);
const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
const current = readPrivateFile(this.authPath, "utf-8");
const { result, next } = fn(current);
if (next !== undefined) {
writeFileSync(this.authPath, next, "utf-8");
chmodSync(this.authPath, 0o600);
writePrivateFileAtomic(this.authPath, next);
}
return result;
} finally {
Expand All @@ -171,7 +159,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
}

async withLockAsync<T>(fn: (current: string | undefined) => Promise<LockResult<T>>): Promise<T> {
this.ensureParentDir();
this.ensureFileExists();

let release: (() => Promise<void>) | undefined;
Expand Down Expand Up @@ -200,12 +187,11 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
});

throwIfCompromised();
const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
const current = readPrivateFile(this.authPath, "utf-8");
const { result, next } = await fn(current);
throwIfCompromised();
if (next !== undefined) {
writeFileSync(this.authPath, next, "utf-8");
chmodSync(this.authPath, 0o600);
writePrivateFileAtomic(this.authPath, next);
}
throwIfCompromised();
return result;
Expand Down
7 changes: 4 additions & 3 deletions packages/coding-agent/src/core/export-html/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { AgentState } from "@earendil-works/pi-agent-core";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.js";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
import { writePrivateFileAtomic } from "../../utils/private-files.js";
import type { ToolDefinition } from "../extensions/types.js";
import type { SessionEntry } from "../session-manager.js";
import { SessionManager } from "../session-manager.js";
Expand Down Expand Up @@ -276,7 +277,7 @@ export async function exportSessionToHtml(
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
}

writeFileSync(outputPath, html, "utf8");
writePrivateFileAtomic(outputPath, html, { privateParent: false });
return outputPath;
}

Expand Down Expand Up @@ -309,6 +310,6 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
}

writeFileSync(outputPath, html, "utf8");
writePrivateFileAtomic(outputPath, html, { privateParent: false });
return outputPath;
}
63 changes: 55 additions & 8 deletions packages/coding-agent/src/core/kernel/state-snapshot.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ export function buildSnapshotCode(outPath: string, manifestPath: string, maxByte
// working even when the user namespace shadows names like list/open/print/len.
return `
def _prime_agent_snapshot_state():
import builtins as _b, json, os, sys, datetime
import builtins as _b, json, os, stat, sys, datetime
if not _b.hasattr(os, "O_NOFOLLOW"):
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"error": "O_NOFOLLOW unavailable"}))
return
try:
import dill
except _b.Exception as _err:
Expand Down Expand Up @@ -99,11 +102,28 @@ def _prime_agent_snapshot_state():
payload[name] = blob
total += _b.len(blob)

os.makedirs(os.path.dirname(${pyStr(outPath)}), exist_ok=True)
tmp = ${pyStr(outPath)} + ".tmp"
out_dir = os.path.dirname(${pyStr(outPath)})
if os.path.lexists(out_dir):
out_dir_info = os.lstat(out_dir)
if stat.S_ISLNK(out_dir_info.st_mode) or not stat.S_ISDIR(out_dir_info.st_mode):
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"error": "unsafe snapshot directory"}))
return
else:
os.makedirs(out_dir, mode=0o700, exist_ok=False)
tmp = ${pyStr(outPath)} + ".tmp." + _b.str(os.getpid()) + "." + os.urandom(8).hex()
try:
with _b.open(tmp, "wb") as fh:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
fd = os.open(tmp, flags, 0o600)
with os.fdopen(fd, "wb") as fh:
dill.dump(payload, fh)
fh.flush()
os.fsync(fh.fileno())
if hasattr(os, "fchmod"):
os.fchmod(fh.fileno(), 0o600)
if os.path.lexists(${pyStr(outPath)}):
out_info = os.lstat(${pyStr(outPath)})
if stat.S_ISLNK(out_info.st_mode) or not stat.S_ISREG(out_info.st_mode):
raise OSError("unsafe snapshot destination")
os.replace(tmp, ${pyStr(outPath)})
except _b.Exception as _err:
try:
Expand All @@ -123,11 +143,28 @@ def _prime_agent_snapshot_state():
"pythonVersion": sys.version.split()[0],
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
manifest_tmp = None
try:
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
with _b.open(${pyStr(manifestPath)}, "w") as fh:
manifest_tmp = ${pyStr(manifestPath)} + ".tmp." + _b.str(os.getpid()) + "." + os.urandom(8).hex()
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
fd = os.open(manifest_tmp, flags, 0o600)
with os.fdopen(fd, "w") as fh:
json.dump(manifest, fh)
fh.flush()
os.fsync(fh.fileno())
if hasattr(os, "fchmod"):
os.fchmod(fh.fileno(), 0o600)
if os.path.lexists(${pyStr(manifestPath)}):
manifest_info = os.lstat(${pyStr(manifestPath)})
if stat.S_ISLNK(manifest_info.st_mode) or not stat.S_ISREG(manifest_info.st_mode):
raise OSError("unsafe snapshot manifest")
os.replace(manifest_tmp, ${pyStr(manifestPath)})
except _b.Exception:
pass
try:
if manifest_tmp is not None:
os.remove(manifest_tmp)
except _b.Exception:
pass
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"saved": saved, "skipped": skipped, "bytes": bytes_written}))


Expand All @@ -149,7 +186,10 @@ export function buildRestoreCode(inPath: string): string {
return `
def _prime_agent_restore_state():
import builtins as _b, json, os, sys
if not os.path.exists(${pyStr(inPath)}):
if not _b.hasattr(os, "O_NOFOLLOW"):
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"restored": [], "failed": [], "error": "O_NOFOLLOW unavailable"}))
return
if not os.path.lexists(${pyStr(inPath)}):
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"restored": [], "failed": []}))
return
try:
Expand All @@ -159,7 +199,14 @@ def _prime_agent_restore_state():
return

try:
with _b.open(${pyStr(inPath)}, "rb") as fh:
import stat as _stat
snapshot_stat = os.lstat(${pyStr(inPath)})
if not _stat.S_ISREG(snapshot_stat.st_mode):
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"restored": [], "failed": [], "error": "load failed: snapshot is not a regular file"}))
return
flags = os.O_RDONLY | os.O_NOFOLLOW
fd = os.open(${pyStr(inPath)}, flags)
with os.fdopen(fd, "rb") as fh:
payload = dill.load(fh)
except _b.Exception as _err:
_b.print(${pyStr(RESULT_MARKER)} + json.dumps({"restored": [], "failed": [], "error": "load failed: " + _b.str(_err)}))
Expand Down
Loading