From 40b137c992b04eb521f61f96bafdac3395e32a04 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 20 Jul 2026 21:37:48 +0200 Subject: [PATCH 1/3] fix(cloud): prefer project Relay workspace --- .../2026-07/traj_t8kdxk32okbu/summary.md | 34 +++++ .../2026-07/traj_t8kdxk32okbu/trajectory.json | 76 ++++++++++++ CHANGELOG.md | 6 +- .../cli/src/cli/lib/project-workspace-key.ts | 93 ++------------ packages/cli/src/cli/lib/sdk-client.ts | 37 ++---- packages/cloud/package.json | 4 + packages/cloud/src/index.ts | 10 ++ .../cloud/src/project-workspace-key.test.ts | 82 +++++++++++++ packages/cloud/src/project-workspace-key.ts | 116 ++++++++++++++++++ packages/cloud/src/workspace-key.ts | 9 ++ 10 files changed, 353 insertions(+), 114 deletions(-) create mode 100644 .agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/summary.md create mode 100644 .agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/trajectory.json create mode 100644 packages/cloud/src/project-workspace-key.test.ts create mode 100644 packages/cloud/src/project-workspace-key.ts create mode 100644 packages/cloud/src/workspace-key.ts diff --git a/.agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/summary.md b/.agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/summary.md new file mode 100644 index 000000000..d74e2991b --- /dev/null +++ b/.agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/summary.md @@ -0,0 +1,34 @@ +# Trajectory: Unify project-aware Relay workspace key resolution for Factory and CLI + +> **Status:** ✅ Completed +> **Confidence:** 98% +> **Started:** July 20, 2026 at 09:26 PM +> **Completed:** July 20, 2026 at 09:36 PM + +--- + +## Summary + +Added and verified a public project-aware workspace-key resolver, migrated the CLI to it, retained compatibility re-exports, documented the patch, and validated 1300 tests plus typecheck, formatting, core build, and package contents. + +**Approach:** Moved the existing secure project-key persistence into the cloud package, encoded resolution precedence in one public API, exposed a side-effect-minimal subpath, and regression-tested every source and malformed-state fallback. + +--- + +## Key Decisions + +### Made project-aware workspace resolution a public @agent-relay/cloud contract + +- **Chose:** Made project-aware workspace resolution a public @agent-relay/cloud contract +- **Reasoning:** Factory and other SDK consumers must use the same explicit/env/project/global precedence as the CLI; centralizing the existing project workspace-key store prevents cross-workspace agent_not_found failures. + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Made project-aware workspace resolution a public @agent-relay/cloud contract: Made project-aware workspace resolution a public @agent-relay/cloud contract +- Centralized workspace resolution in @agent-relay/cloud with explicit flag and environment precedence, then the project broker key before the global active store. A dedicated workspace-key package subpath avoids cloud barrel side effects and works in source tests and packed output. diff --git a/.agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/trajectory.json b/.agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/trajectory.json new file mode 100644 index 000000000..b94f3b91a --- /dev/null +++ b/.agentworkforce/trajectories/factory-recovery/completed/2026-07/traj_t8kdxk32okbu/trajectory.json @@ -0,0 +1,76 @@ +{ + "id": "traj_t8kdxk32okbu", + "version": 1, + "task": { + "title": "Unify project-aware Relay workspace key resolution for Factory and CLI" + }, + "status": "completed", + "startedAt": "2026-07-20T19:26:57.644Z", + "completedAt": "2026-07-20T19:36:59.651Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-20T19:31:36.220Z" + } + ], + "chapters": [ + { + "id": "chap_kct4jagc9eik", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-20T19:31:36.220Z", + "endedAt": "2026-07-20T19:36:59.651Z", + "events": [ + { + "ts": 1784575896221, + "type": "decision", + "content": "Made project-aware workspace resolution a public @agent-relay/cloud contract: Made project-aware workspace resolution a public @agent-relay/cloud contract", + "raw": { + "question": "Made project-aware workspace resolution a public @agent-relay/cloud contract", + "chosen": "Made project-aware workspace resolution a public @agent-relay/cloud contract", + "alternatives": [], + "reasoning": "Factory and other SDK consumers must use the same explicit/env/project/global precedence as the CLI; centralizing the existing project workspace-key store prevents cross-workspace agent_not_found failures." + }, + "significance": "high" + }, + { + "ts": 1784576219351, + "type": "reflection", + "content": "Centralized workspace resolution in @agent-relay/cloud with explicit flag and environment precedence, then the project broker key before the global active store. A dedicated workspace-key package subpath avoids cloud barrel side effects and works in source tests and packed output.", + "raw": { + "focalPoints": [ + "public SDK boundary", + "project/global precedence", + "package subpath", + "test isolation" + ], + "adjustments": "Added a source-level workspace-key entrypoint after Vitest aliases exposed that the package subpath name must map to a real source module.", + "confidence": 0.98 + }, + "significance": "high", + "tags": [ + "focal:public SDK boundary", + "focal:project/global precedence", + "focal:package subpath", + "focal:test isolation", + "confidence:0.98" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added and verified a public project-aware workspace-key resolver, migrated the CLI to it, retained compatibility re-exports, documented the patch, and validated 1300 tests plus typecheck, formatting, core build, and package contents.", + "approach": "Moved the existing secure project-key persistence into the cloud package, encoded resolution precedence in one public API, exposed a side-effect-minimal subpath, and regression-tested every source and malformed-state fallback.", + "confidence": 0.98 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "09d2e359c17d741ba66f8403cdd7bb06c3a63d2e", + "endRef": "09d2e359c17d741ba66f8403cdd7bb06c3a63d2e" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e0b58c0..61db32940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `@agent-relay/cloud` now exposes project-aware workspace resolution, and SDK-backed CLI consumers prefer the workspace recorded by the broker in the current checkout over an unrelated machine-global active workspace. ## [10.6.6] - 2026-07-19 diff --git a/packages/cli/src/cli/lib/project-workspace-key.ts b/packages/cli/src/cli/lib/project-workspace-key.ts index c36dca72f..9099851e7 100644 --- a/packages/cli/src/cli/lib/project-workspace-key.ts +++ b/packages/cli/src/cli/lib/project-workspace-key.ts @@ -1,86 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -/** - * Project-local record of the workspace key the broker in this directory was - * started with. `agent-relay up` writes it into the project data dir - * (`.agentworkforce/relay/`, the same git-excluded directory that holds - * `connection.json`) so that later SDK-backed commands run in the same CWD - * (`fleet nodes`, `node …`, etc.) resolve the workspace the local broker - * actually joined — rather than falling through to the machine-global active - * workspace, which may point at a different workspace. - */ -const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; - -interface ProjectWorkspaceKeyFile { - workspaceKey: string; -} - -/** Absolute path to the project-local workspace-key file within `dataDir`. */ -export function projectWorkspaceKeyPath(dataDir: string): string { - return path.join(dataDir, PROJECT_WORKSPACE_KEY_FILENAME); -} - -/** - * Read the workspace key recorded for this project's data dir. A missing or - * malformed file (or a blank key) reads as `undefined` — the caller falls - * through to the next resolution source rather than failing. - */ -export function readProjectWorkspaceKey(dataDir: string): string | undefined { - try { - const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); - const parsed = JSON.parse(raw) as Partial; - const key = typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey.trim() : ''; - return key || undefined; - } catch { - return undefined; - } -} - -/** - * Persist the workspace key for this project's data dir with owner-only - * permissions (matching `connection.json`). A blank key is ignored so a broker - * that never resolved a key does not clobber a previously recorded one. - * - * The write is atomic and symlink-safe: the payload is written to a fresh, - * exclusively-created temp file (so a pre-planted symlink at the temp path - * cannot redirect it) and then `rename`d over the destination. `rename` never - * follows a symlink at the destination and is atomic on POSIX, so a concurrent - * reader sees either the old or the new complete file — never a truncated one, - * and never an attacker-chosen target. - */ -export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | undefined): void { - const key = workspaceKey?.trim(); - if (!key) return; - fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); - const file = projectWorkspaceKeyPath(dataDir); - const tmp = `${file}.tmp.${process.pid}`; - const payload: ProjectWorkspaceKeyFile = { workspaceKey: key }; - const data = `${JSON.stringify(payload, null, 2)}\n`; - - // 'wx' == O_CREAT | O_EXCL: create a brand-new regular file, failing (rather - // than following a symlink or truncating an existing file) if anything is - // already at the temp path. A stale temp from a crashed run is removed first. - let fd: number; - try { - fd = fs.openSync(tmp, 'wx', 0o600); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - fs.rmSync(tmp, { force: true }); - fd = fs.openSync(tmp, 'wx', 0o600); - } - try { - try { - fs.writeSync(fd, data); - } finally { - fs.closeSync(fd); - } - // openSync's mode is masked by umask; enforce owner-only before publishing. - fs.chmodSync(tmp, 0o600); - fs.renameSync(tmp, file); - } catch (err) { - // Never leave a partial temp file behind, whichever step failed. - fs.rmSync(tmp, { force: true }); - throw err; - } -} +// Compatibility re-export for existing CLI-local imports. The public cloud +// package owns this contract so SDK consumers and the CLI cannot drift. +export { + projectWorkspaceKeyPath, + readProjectWorkspaceKey, + writeProjectWorkspaceKey, +} from '@agent-relay/cloud/workspace-key'; diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index 6b87e7ea7..3082928d5 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -1,8 +1,8 @@ import { AgentRelay, type AgentRelayAgent } from '@agent-relay/sdk'; -import { getProjectPaths } from '@agent-relay/config'; - -import { activeWorkspaceKey } from './workspace-store.js'; -import { readProjectWorkspaceKey } from './project-workspace-key.js'; +import { + resolveWorkspaceKeyWithSource as resolveCloudWorkspaceKeyWithSource, + type WorkspaceKeySource, +} from '@agent-relay/cloud/workspace-key'; /** Options shared by the SDK-backed (Relaycast) CLI command groups. */ export interface SdkClientOptions { @@ -22,7 +22,7 @@ function trimOrUndefined(value: string | undefined): string | undefined { } /** Where a resolved workspace key came from, in precedence order. */ -export type WorkspaceKeySource = 'flag' | 'env' | 'project' | 'store'; +export type { WorkspaceKeySource }; /** * Resolve the workspace key and report which source it came from. Precedence: @@ -35,15 +35,11 @@ export function resolveWorkspaceKeyWithSource(options: SdkClientOptions = {}): { key: string; source: WorkspaceKeySource; } { - const e = env(options); - const flag = trimOrUndefined(options.workspaceKey); - if (flag) return { key: flag, source: 'flag' }; - const envKey = trimOrUndefined(e.RELAY_WORKSPACE_KEY) ?? trimOrUndefined(e.RELAY_API_KEY); - if (envKey) return { key: envKey, source: 'env' }; - const project = trimOrUndefined(projectWorkspaceKey()); - if (project) return { key: project, source: 'project' }; - const store = trimOrUndefined(activeWorkspaceKey(e)); - if (store) return { key: store, source: 'store' }; + const resolved = resolveCloudWorkspaceKeyWithSource({ + workspaceKey: options.workspaceKey, + env: env(options), + }); + if (resolved) return resolved; throw new Error( 'No workspace key found. Pass --workspace-key, set RELAY_WORKSPACE_KEY, or run `relay workspace set_key `.' ); @@ -53,19 +49,6 @@ export function resolveWorkspaceKey(options: SdkClientOptions = {}): string { return resolveWorkspaceKeyWithSource(options).key; } -/** - * Read the workspace key recorded by `relay up` for the current project - * directory, or `undefined` when there is none / the project root cannot be - * resolved. Never throws — a resolution failure just falls through. - */ -function projectWorkspaceKey(): string | undefined { - try { - return readProjectWorkspaceKey(getProjectPaths().dataDir); - } catch { - return undefined; - } -} - export function resolveBaseUrl(options: SdkClientOptions = {}): string | undefined { return trimOrUndefined(options.baseUrl) ?? trimOrUndefined(env(options).RELAY_BASE_URL); } diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 25e0d9371..98791d36d 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -30,6 +30,10 @@ "types": "./dist/audit.d.ts", "import": "./dist/audit.js" }, + "./workspace-key": { + "types": "./dist/workspace-key.d.ts", + "import": "./dist/workspace-key.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index d6ad240e3..8739e32ce 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -93,6 +93,16 @@ export { type WorkspaceStore, } from './workspace-store.js'; +export { + projectWorkspaceKeyPath, + readProjectWorkspaceKey, + resolveWorkspaceKey, + resolveWorkspaceKeyWithSource, + writeProjectWorkspaceKey, + type ResolveWorkspaceKeyOptions, + type WorkspaceKeySource, +} from './project-workspace-key.js'; + export { deployProactiveAgent, listProactiveAgents, diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts new file mode 100644 index 000000000..682994e16 --- /dev/null +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + projectWorkspaceKeyPath, + readProjectWorkspaceKey, + resolveWorkspaceKeyWithSource, + writeProjectWorkspaceKey, +} from './project-workspace-key.js'; +import { setWorkspaceKey } from './workspace-store.js'; + +let root: string; +let dataDir: string; +let home: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-workspace-')); + dataDir = path.join(root, '.agentworkforce/relay'); + home = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-workspace-home-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe('project workspace key resolution', () => { + it('round-trips an atomic owner-only project key record', () => { + writeProjectWorkspaceKey(dataDir, ' rk_project '); + expect(readProjectWorkspaceKey(dataDir)).toBe('rk_project'); + expect(fs.statSync(projectWorkspaceKeyPath(dataDir)).mode & 0o777).toBe(0o600); + }); + + it('prefers explicit and environment keys over the project broker key', () => { + writeProjectWorkspaceKey(dataDir, 'rk_project'); + expect( + resolveWorkspaceKeyWithSource({ + workspaceKey: ' rk_flag ', + projectDataDir: dataDir, + env: { AGENT_RELAY_HOME: home }, + }) + ).toEqual({ key: 'rk_flag', source: 'flag' }); + expect( + resolveWorkspaceKeyWithSource({ + projectDataDir: dataDir, + env: { AGENT_RELAY_HOME: home, AGENT_RELAY_WORKSPACE_KEY: ' rk_env ' }, + }) + ).toEqual({ key: 'rk_env', source: 'env' }); + }); + + it('prefers the current project broker over an unrelated global active workspace', () => { + const env = { AGENT_RELAY_HOME: home }; + setWorkspaceKey('global', 'rk_global', env); + writeProjectWorkspaceKey(dataDir, 'rk_project'); + + expect(resolveWorkspaceKeyWithSource({ projectDataDir: dataDir, env })).toEqual({ + key: 'rk_project', + source: 'project', + }); + }); + + it('falls back through malformed project state to the global store', () => { + const env = { AGENT_RELAY_HOME: home }; + setWorkspaceKey('global', 'rk_global', env); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(projectWorkspaceKeyPath(dataDir), 'not json'); + + expect(resolveWorkspaceKeyWithSource({ projectDataDir: dataDir, env })).toEqual({ + key: 'rk_global', + source: 'store', + }); + }); + + it('returns undefined when no workspace source exists', () => { + expect( + resolveWorkspaceKeyWithSource({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } }) + ).toBeUndefined(); + }); +}); diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts new file mode 100644 index 000000000..f7321ac69 --- /dev/null +++ b/packages/cloud/src/project-workspace-key.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { getProjectPaths } from '@agent-relay/config'; + +import { resolveActiveWorkspaceKey } from './workspace-store.js'; + +const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; + +interface ProjectWorkspaceKeyFile { + workspaceKey: string; +} + +export type WorkspaceKeySource = 'flag' | 'env' | 'project' | 'store'; + +export interface ResolveWorkspaceKeyOptions { + workspaceKey?: string; + env?: NodeJS.ProcessEnv; + /** Project root whose local broker workspace should be preferred. Defaults to the current project. */ + projectRoot?: string; + /** Explicit project Relay data directory. Takes precedence over projectRoot. */ + projectDataDir?: string; +} + +/** Absolute path to the workspace key recorded by `agent-relay node up`. */ +export function projectWorkspaceKeyPath(dataDir: string): string { + return path.join(dataDir, PROJECT_WORKSPACE_KEY_FILENAME); +} + +/** Read a project broker's workspace key, falling through on absent or malformed state. */ +export function readProjectWorkspaceKey(dataDir: string): string | undefined { + try { + const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return trimOrUndefined(parsed.workspaceKey); + } catch { + return undefined; + } +} + +/** + * Persist the project broker's workspace key atomically with owner-only permissions. + * A blank key never clobbers a previously recorded workspace. + */ +export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | undefined): void { + const key = trimOrUndefined(workspaceKey); + if (!key) return; + fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + const file = projectWorkspaceKeyPath(dataDir); + const tmp = `${file}.tmp.${process.pid}`; + const data = `${JSON.stringify({ workspaceKey: key } satisfies ProjectWorkspaceKeyFile, null, 2)}\n`; + + let fd: number; + try { + fd = fs.openSync(tmp, 'wx', 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + fs.rmSync(tmp, { force: true }); + fd = fs.openSync(tmp, 'wx', 0o600); + } + try { + try { + fs.writeSync(fd, data); + } finally { + fs.closeSync(fd); + } + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + } catch (error) { + fs.rmSync(tmp, { force: true }); + throw error; + } +} + +/** + * Resolve the Relay workspace used by SDK clients. The project-local key comes + * before the machine-global active workspace so a process addresses the same + * workspace as the broker and fleet node running in that checkout. + */ +export function resolveWorkspaceKeyWithSource( + options: ResolveWorkspaceKeyOptions = {} +): { key: string; source: WorkspaceKeySource } | undefined { + const env = options.env ?? process.env; + const flag = trimOrUndefined(options.workspaceKey); + if (flag) return { key: flag, source: 'flag' }; + + const envKey = + trimOrUndefined(env.RELAY_WORKSPACE_KEY) ?? + trimOrUndefined(env.AGENT_RELAY_WORKSPACE_KEY) ?? + trimOrUndefined(env.RELAY_API_KEY); + if (envKey) return { key: envKey, source: 'env' }; + + const dataDir = options.projectDataDir ?? projectDataDir(options.projectRoot); + const project = dataDir ? readProjectWorkspaceKey(dataDir) : undefined; + if (project) return { key: project, source: 'project' }; + + const store = trimOrUndefined(resolveActiveWorkspaceKey(env)); + return store ? { key: store, source: 'store' } : undefined; +} + +export function resolveWorkspaceKey(options: ResolveWorkspaceKeyOptions = {}): string | undefined { + return resolveWorkspaceKeyWithSource(options)?.key; +} + +function projectDataDir(projectRoot: string | undefined): string | undefined { + try { + return getProjectPaths(projectRoot).dataDir; + } catch { + return undefined; + } +} + +function trimOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts new file mode 100644 index 000000000..e9066e91e --- /dev/null +++ b/packages/cloud/src/workspace-key.ts @@ -0,0 +1,9 @@ +export { + projectWorkspaceKeyPath, + readProjectWorkspaceKey, + resolveWorkspaceKey, + resolveWorkspaceKeyWithSource, + writeProjectWorkspaceKey, + type ResolveWorkspaceKeyOptions, + type WorkspaceKeySource, +} from './project-workspace-key.js'; From 0b74a8d8c1f928c46ba60a0e5d3d18fc2e0ac6de Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 20 Jul 2026 21:43:07 +0200 Subject: [PATCH 2/3] fix(cloud): make workspace key writes collision-safe --- packages/cloud/src/project-workspace-key.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index f7321ac69..61b205ced 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -47,7 +48,8 @@ export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | if (!key) return; fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const file = projectWorkspaceKeyPath(dataDir); - const tmp = `${file}.tmp.${process.pid}`; + // Worker threads share a PID, so include a per-write nonce as well as the PID. + const tmp = `${file}.tmp.${process.pid}.${randomUUID()}`; const data = `${JSON.stringify({ workspaceKey: key } satisfies ProjectWorkspaceKeyFile, null, 2)}\n`; let fd: number; From a45df6c194b17f805018c20f057e0973fe82eabf Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 20 Jul 2026 21:56:56 +0200 Subject: [PATCH 3/3] fix(cloud): preserve colliding workspace temp files --- CHANGELOG.md | 2 +- packages/cloud/src/project-workspace-key.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61db32940..5f8a3b957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] ### Fixed diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index 61b205ced..3321eff24 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -49,7 +49,7 @@ export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const file = projectWorkspaceKeyPath(dataDir); // Worker threads share a PID, so include a per-write nonce as well as the PID. - const tmp = `${file}.tmp.${process.pid}.${randomUUID()}`; + let tmp = `${file}.tmp.${process.pid}.${randomUUID()}`; const data = `${JSON.stringify({ workspaceKey: key } satisfies ProjectWorkspaceKeyFile, null, 2)}\n`; let fd: number; @@ -57,7 +57,9 @@ export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | fd = fs.openSync(tmp, 'wx', 0o600); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - fs.rmSync(tmp, { force: true }); + // The colliding path belongs to another writer. Never remove it; retry + // exclusive creation with a new nonce so that writer can finish safely. + tmp = `${file}.tmp.${process.pid}.${randomUUID()}`; fd = fs.openSync(tmp, 'wx', 0o600); } try {